Skip to content

feat: implement .apply REPL dot-command (Approach 1: Minimalist Function Injection)#1789

Merged
pmcelhaney merged 28 commits intomainfrom
copilot/implement-apply-command-design
Apr 10, 2026
Merged

feat: implement .apply REPL dot-command (Approach 1: Minimalist Function Injection)#1789
pmcelhaney merged 28 commits intomainfrom
copilot/implement-apply-command-design

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 8, 2026

Summary

Implements .apply as a REPL dot-command that dynamically imports a TypeScript/JavaScript scenario file and calls a named export, injecting the live REPL environment (context, loadContext, routes, route) as its argument.

Path resolution:

Command File Function
.apply foo scenarios/index.ts foo
.apply foo/bar scenarios/foo.ts bar
.apply foo/bar/baz scenarios/foo/bar.ts baz

Example scenario script:

// scenarios/index.ts
import type { Scenario } from "../types/scenario-context.js";

export const soldPets: Scenario = ($) => {
  $.context.petService.reset();
  $.routes.findSold = $.route("/pet/findByStatus").method("get").query({ status: "sold" });
};

Then in the REPL: .apply soldPets

The ApplyContext interface and Scenario type are generated into types/scenario-context.ts. ApplyContext references the generated Context class from routes/_.context, giving scenario scripts full type safety against the user's own context shape. Scenario is defined as ($: ApplyContext) => Promise<void> | void, so scenario functions can be typed with a single import. Additionally, loadContext() is typed with narrowed overloads for every _.context.ts file found in subdirectories of routes/, so calls like loadContext("/pets") return the specific PetsContext type rather than Record<string, unknown>. Overloads are sorted deepest-first so TypeScript resolves the most specific type. Each {param} segment in a route path is individually replaced with ${string} (e.g. /pets/{petId}/info`/pets/${string}/info`), preserving full route shape for precise overload resolution. The scenario-context.ts file is always regenerated at startup and also regenerated at runtime whenever a _.context.ts file is added or removed while the server is running — ContextRegistry (which extends EventTarget) dispatches a "context-changed" event on add() and remove(), and app.ts listens for it to call writeApplyContextType, so loadContext overloads stay in sync with the live context registry.

Code generation scaffolds a scenarios/index.ts file (only when it does not already exist) using JSDoc comments, $ as the parameter name typed via Scenario, and a concrete help arrow-function example (export const help: Scenario = ($) => { ... };) that users can immediately call with .apply help. The scaffold includes a void $; statement to suppress unused-variable warnings and JSDoc blocks explaining common usage patterns for $.context, $.loadContext, and $.routes.

Scenario files are loaded by ModuleLoader into a new ScenarioRegistry (modeled after Registry and ContextRegistry), which is then passed to the REPL. The REPL no longer does any file I/O — the .apply command and tab completion look up modules from ScenarioRegistry directly. ScenarioRegistry stores modules keyed by slash-delimited relative file path (e.g. "index", "myscript", "foo/bar") and exposes getModule, getExportedFunctionNames, and getFileKeys. Declaration files (.d.ts) and source maps (.map) are excluded from scenario loading to prevent noisy runtime errors.

The REPL completer supports tab completion for .apply: typing .apply sol<Tab> completes to exported function names from scenarios/index.ts matching sol; file-prefix completion (e.g. myscript/) is also supported for multi-file scenarios. Only exported functions (not const, let, var, or class exports) appear as completions.

Original Prompt

Implement .apply as a REPL dot-command that dynamically imports a TypeScript/JavaScript scenario file and calls a named export, injecting the live REPL environment as its argument.

Manual acceptance tests

  • Running .apply help in the REPL prints the help message about scenarios
  • Tab-completing .apply shows exported function names from scenarios/index.ts
  • Adding a new _.context.ts file to a routes subdirectory while the server is running causes types/scenario-context.ts to be rewritten with new narrowed loadContext overloads
  • Removing a _.context.ts file at runtime also triggers regeneration of types/scenario-context.ts
  • Starting the server fresh generates types/scenario-context.ts and scenarios/index.ts (when not already present)

Tasks

  • Implemented .apply REPL dot-command with path-based file/function resolution
  • Generated types/scenario-context.ts with ApplyContext interface (importing Context from routes/_.context), narrowed loadContext overloads for each _.context.ts subdirectory, and Scenario type alias
  • Made ContextRegistry extend EventTarget and dispatch "context-changed" on add() and remove(); app.ts listens on contextRegistry to regenerate scenario-context.ts at runtime
  • Scaffolded scenarios/index.ts (only when absent) with JSDoc comments and a runnable help example using export const help: Scenario = ($) => { ... }
  • Created ScenarioRegistry (src/server/scenario-registry.ts) to store scenario modules keyed by relative file path; ModuleLoader loads and watches the scenarios directory, excluding .d.ts and .map files
  • Added .apply-aware tab completion to the REPL completer (exported functions only)
  • Renamed generated file from apply-context.ts to scenario-context.ts throughout
  • Fixed build error: Dirent imported from node:fs instead of node:fs/promises
  • Added docs in docs/usage.md and a changeset

Copilot AI linked an issue Apr 8, 2026 that may be closed by this pull request
8 tasks
@pmcelhaney pmcelhaney marked this pull request as ready for review April 8, 2026 21:41
Copilot AI changed the title [WIP] Implement .apply command design with minimalist function injection feat: implement .apply REPL dot-command (Approach 1: Minimalist Function Injection) Apr 8, 2026
Copilot AI requested a review from pmcelhaney April 8, 2026 21:59
@pmcelhaney pmcelhaney enabled auto-merge April 8, 2026 22:07
Copy link
Copy Markdown
Collaborator

@pmcelhaney pmcelhaney left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add documentation and a changeset

auto-merge was automatically disabled April 8, 2026 22:11

Head branch was pushed to by a user without write access

Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 8, 2026

Add documentation and a changeset

Added a "Scenario scripts with .apply" section to docs/usage.md (commit ad25c0c). It covers path resolution, a full example script, and how to use ctx.routes after applying. The changeset was already in place from the previous commit.

Copilot AI requested a review from pmcelhaney April 8, 2026 22:12
Copy link
Copy Markdown
Collaborator

@pmcelhaney pmcelhaney left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code generator should create a default ./scenarios/index.ts file if one doesn't already exist.

It should import the ApplyContext type and have some commented out example code to demonstrate how to use it.

Comment thread src/typescript-generator/generate.ts Outdated
/** Create a new route builder for a given path */
route: (path: string) => unknown;
}
`;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ApplyContext should import the Context class:

// This file is generated by Counterfact. Do not edit manually.
import type { Context } from "../routes/_.context";

export interface ApplyContext {
  /** Root context, same as loadContext("/") */
  context: Context;
  /** Load a context object for a specific path */
  loadContext: (path: string) => Record<string, unknown>;
  /** Named route builders stored in the REPL execution context */
  routes: Record<string, unknown>;
  /** Create a new route builder for a given path */
  route: (path: string) => unknown;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in commit 3bdc527. ApplyContext now imports Context from ../routes/_.context and uses it as the type for the context field.

Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 8, 2026

The code generator should create a default ./scenarios/index.ts file if one doesn't already exist.

It should import the ApplyContext type and have some commented out example code to demonstrate how ...

Done in commit 18a7690. writeDefaultScenariosIndex now runs alongside writeApplyContextType during code generation and writes scenarios/index.ts only when the file doesn't already exist. The scaffold imports ApplyContext and includes generic commented-out example code covering context mutation, loadContext, and route builder storage.

Copilot AI requested a review from pmcelhaney April 8, 2026 22:42
Copy link
Copy Markdown
Collaborator

@pmcelhaney pmcelhaney left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The apply context type file should also look for _.context.ts files in subdirectories.

If found it should import them and use them to narrow the type of loadContext().

This means that the file will need to be regenerate whenever a _.context.ts file is added or removed.

Copy link
Copy Markdown
Collaborator

@pmcelhaney pmcelhaney left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scenario-context.ts should be rewritten when a context is added to or removed from the context registry

Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 10, 2026

scenario-context.ts should be rewritten when a context is added to or removed from the context registry

Done in commits 85db3c2 and 5505dcf.

writeApplyContextType is now exported from generate.ts. ModuleLoader accepts an optional onContextFileChanged callback (added a private isContextFile() helper to avoid duplicating the _.context. basename check). The callback is called in the file watcher when a _.context.* file is added or removed. In app.ts, the callback is wired as () => writeApplyContextType(modulesPath), so types/scenario-context.ts is regenerated automatically whenever a context file is added or removed at runtime.

Copilot AI requested a review from pmcelhaney April 10, 2026 16:39
Copy link
Copy Markdown
Collaborator

@pmcelhaney pmcelhaney left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ModuleLoader is an EventTarget. Use an event instead of a callback.

Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 10, 2026

ModuleLoader is an EventTarget. Use an event instead of a callback.

Done in commit e8dd300. Removed the onContextFileChanged constructor parameter — ModuleLoader now dispatches new Event("context-file-changed") when a _.context.* file is added or removed. In app.ts, the listener is wired with moduleLoader.addEventListener("context-file-changed", () => { void writeApplyContextType(modulesPath); }).

Copilot AI requested a review from pmcelhaney April 10, 2026 16:44
Copy link
Copy Markdown
Collaborator

@pmcelhaney pmcelhaney left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, ContextRegistry should be an EventTarget. It should listen to an event on ContextRegistry rather than ModuleLoader.

Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 10, 2026

Actually, ContextRegistry should be an EventTarget. It should listen to an event on ContextRegistry rather than ModuleLoader.

Done in commit 795a039. ContextRegistry now extends EventTarget and dispatches "context-changed" from add() and the new remove() method. ModuleLoader no longer dispatches its own "context-file-changed" event — it calls contextRegistry.remove() on context file unlink instead. app.ts now listens on contextRegistry for "context-changed" to trigger writeApplyContextType.

Copilot AI requested a review from pmcelhaney April 10, 2026 17:24
@pmcelhaney pmcelhaney enabled auto-merge April 10, 2026 17:49
@pmcelhaney pmcelhaney added this pull request to the merge queue Apr 10, 2026
Merged via the queue into main with commit ee04260 Apr 10, 2026
9 checks passed
@pmcelhaney pmcelhaney deleted the copilot/implement-apply-command-design branch April 10, 2026 17:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

.apply Command Design (Approach 1): Minimalist Function Injection

3 participants