diff --git a/.changeset/593-route-contract-imports.md b/.changeset/593-route-contract-imports.md new file mode 100644 index 000000000..b41a6a437 --- /dev/null +++ b/.changeset/593-route-contract-imports.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Resolve `inputSchema` (and `config` string consts, `AB4806`) declared in another module: `export const inputSchema = statusInputSchema`, imported through relative specifiers inside the project across any number of `export const` alias hops, is parsed statically in the declaring module's scope — no module is executed — so a `src/cli/**` command and an MCP tool share one schema. The route graph normalizes each statically read schema once into a `RouteContract` (`id` = `contract:#`, `input`, `origin`, `routes`), exposed as `contracts` on `CompiledRouteGraph`, `agent-bundle inspect --routes`, the route manifest, and the Workbench Routes page, and referenced by each route as `route.contract`; the argv grammar and static MCP `inputSchema` are projections of it. On CLI routes a reference the resolver cannot follow is `AB4838` (names the import chain and the boundary) and a cyclic chain is `AB4839`; grammar violations inside a resolved schema stay `AB4814` with the position qualified by the declaring module (#603) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 657733625..d5bbc2ef5 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -30,7 +30,7 @@ even when no error diagnostic was reported. | `AB4765`–`AB4766` | Artifact-hosted routed CLI: a target without the `cli` capability omits `bin/.mjs`; a host-emitted file collides with it (see below). | | `AB477x` | MCP App view compilation (`AB4770`: compile error with file, line, column and the bundler message; `AB4771`: compile warning; `AB4772`: emitted-size advisory; see below). | | `AB490x`/`AB492x` | Conventional host components (#100 stage 2): rules `src/rules/*.mdc` (`AB4900`–`AB4908`) and commands `src/commands/*.md` (`AB4920`–`AB4928`), including per-host feature-set enforcement (`AB4907`/`AB4908`, `AB4927`/`AB4928`); see below. | -| `AB48xx`/`AB494x` | Route graph, state, layout (`AB4830`–`AB4832`), generated route declarations outside the TypeScript program (`AB4834`), route render budgets (`AB4835`), tool task support (`AB4836`), a route module that value-imports a compiler-carrying framework entry (`AB4837`), and provider conventions (see below). | +| `AB48xx`/`AB494x` | Route graph, state, layout (`AB4830`–`AB4832`), generated route declarations outside the TypeScript program (`AB4834`), route render budgets (`AB4835`), tool task support (`AB4836`), a route module that value-imports a compiler-carrying framework entry (`AB4837`), a CLI route `inputSchema` reference the static resolver cannot follow (`AB4838`) or that cycles (`AB4839`), and provider conventions (see below). | | `AB5000` | General CLI and adapter failures (see below). | | `AB60xx` | Built-artifact validation, including schema documents and referenced files (`AB6005`: an emitted JavaScript module — a host-pack module or a package build `dist` bundle (`dist/bin/*.js`, the Flight workers, the `lib` entry), prebuilt payloads excepted — has an import that is neither a Node built-in nor a relative or `file:` specifier resolving to a listed regular file inside its tree, or a non-literal dynamic import; a `dist` finding names `dist/`; `AB6011`/`AB6012`: a target's required pinned-schema document is missing or invalid; `AB6025`: a manifest-declared `logo` path is missing from the artifact or escapes the deploy tree; `AB6034`: emitted Skill Markdown has no instruction body; `AB6035`–`AB6038`: Agent Plugins portable validation, see below). | | `AB6200`–`AB6202` | Workbench artifact inspection over published epochs: `AB6200` the epoch does not validate or its provenance is inconsistent, `AB6201` an epoch reference could not be released, `AB6202` unsafe runtime metadata (see below). | @@ -933,7 +933,7 @@ framework-owned plugin twice by accident. | `AB4723` | error | `tools.rspack` is not an Rspack config object, a mutator function, or an array of both. | Use one of the three Rslib `tools.rspack` forms. | | `AB4724` | error | `tools.rsbuild.plugins` supplies a plugin whose `name` matches a framework-owned registration (`rsbuild:react` from `@rsbuild/plugin-react`). The message names the plugin and its package. | Remove the plugin from `tools.rsbuild.plugins`; agent-bundle registers it in every config it synthesizes. | -## Route graph, state, layout, and provider conventions (`AB4800`–`AB4837`, `AB4940`–`AB4942`) +## Route graph, state, layout, and provider conventions (`AB4800`–`AB4839`, `AB4940`–`AB4942`) The route-graph compiler discovers conventional route modules (`src/mcp//{tools,resources,prompts,apps}/*`, `src/events/*/*`, @@ -963,11 +963,14 @@ in every tool that opens it: `export const X = ''` of a module reached through a *relative* import (`import { X } from '../constants'`; `.ts`/`.tsx` resolution, `.js`-style specifiers map onto their TypeScript source, index modules - resolve) inside the project root. The sibling module is parsed, never - executed, and only that one hop is followed: the exported const's - initializer must itself be a string literal. Because the identifier is a - real import, the same value is available at run time (for example in - `Agent.Result metadata`). + resolve) inside the project root. The referenced modules are parsed, never + executed, by the same static resolver that follows `inputSchema` + references (below): an alias chain (`export const X = Y`, where `Y` is + itself a top-level `const` of that module or a named import from another + relative module inside the project) is followed across any number of + modules, and the binding at the end of it must be initialized with a + string literal. Because the identifier is a real import, the same value is + available at run time (for example in `Agent.Result metadata`). - **`appResourceUri('')`** imported from `agent-bundle/routes`. The compiler resolves the reference to the target App route's static `config.resourceUri` while compiling the graph. The App must belong to the @@ -991,10 +994,11 @@ in every tool that opens it: use the const form when the URI is also needed inside the component. Anything else — any other identifier, a call, a package import, a relative -import that leaves the project or does not export a string-literal const — -is dynamic: the route compiles with an empty config beside a named `AB4806` -error whose recovery names both reference forms. A module without a `config` -export compiles silently with an empty config. +import that leaves the project or whose chain does not end in a +string-literal `export const` — is dynamic: the route compiles with an empty +config beside a named `AB4806` error whose recovery names both reference +forms. A module without a `config` export compiles silently with an empty +config. An MCP App route's `config.template` resolves **relative to the route module**, the way its imports do (`template: './dashboard.html'`). The older @@ -1105,9 +1109,66 @@ project onto kebab-case options (`maxFiles` becomes `--max-files`); booleans are flags and must carry `.optional()` or `.default(...)`; `config.positionals` names the keys consumed as bare arguments in order, where only the trailing positional may be a `z.array(...)` (variadic). -Anything outside that grammar — identifier references (including shared -schema constants), unions, nested objects, transforms, coercions — raises -`AB4814` naming the offending construct. +Anything outside that grammar — unions, nested objects, transforms, +coercions — raises `AB4814` naming the offending construct and its position, +wherever the schema is declared. + +The schema does not have to be written inline. `inputSchema` may be bound to +a reference (`export const inputSchema = statusInputSchema`), and a +reference may also stand as a property initializer, at the root of a method +chain (`requestStatusSchema.optional()` — the resolved chain's calls come +first, then the local ones), or as the argument of `z.array()`, of +`z.enum()` (an array literal of string literals; `as const` unwraps), +of `z.object()`/`z.strictObject()` (an object literal), or of +`.default()` (a static literal). The static resolver follows a +reference without executing anything: a same-module top-level `const` +(exported or not) resolves to its initializer; a named import +(`import { X as Y } from './rel'`, `.js`-style specifiers mapping onto their +`.ts`/`.tsx` source) resolves to the target module's `export const X`, +provided the specifier is relative and resolves inside the project root; +alias hops (`export const a = b`) are followed to any depth; and every +visited `#` is recorded, so revisiting one is a cycle. The +zod expression at the end of the chain is parsed in the *declaring* module's +scope under the same grammar, and a grammar violation there is still +`AB4814`, its position qualified by that module (`z.object at +src/lib/protocol-schemas.ts:12:5 is outside the bounded argv grammar`). What +the resolver will not cross: a bare (non-relative) specifier, a module +outside the project or one that cannot be read, a target module without a +top-level `export const `, a `let`/`var`, destructured, function, +class, default-import, or namespace-import binding, an unknown identifier, +and a dynamic initializer (a bare call, a function, a template literal with +substitutions). On a CLI route such a reference is `AB4838`, whose message +prints the chain (`inputSchema -> statusInputSchema +(src/lib/protocol-schemas.ts) -> requestStatusSchema -> requestStatuses`) and the boundary +(`imported from "@shared/protocol", which is not a relative module path`); a +cyclic chain is `AB4839`, whose message prints the cycle. Only CLI routes +raise them, because there the argv grammar is load-bearing and the command +cannot compile without it; an MCP tool, resource, prompt, script, or event +route whose schema the resolver cannot follow compiles silently without a +static contract, exactly as an out-of-grammar inline schema does, and the +runtime derives its MCP JSON Schema from the real zod object. `resultSchema` +may be imported the same way: the route contract check (`AB4810`/`AB4815`) +requires only that the named export exists, TypeScript types the route +through the import, and the runtime validates with the real zod object — no +static result projection exists. + +Every statically extracted `inputSchema` is normalized once into a +`RouteContract` in the compiled graph (`graph.contracts`, sorted by id, absent +when no route has one): `id` is `contract:#` — the +declaration site at the end of the alias chain, so +`contract:src/lib/protocol-schemas.ts#statusInputSchema` for an imported +schema and `contract:src/cli/status.tsx#inputSchema` for an inline one; +`input` is the deep-frozen JSON Schema projection, the same object as each +bound route's `inputSchema`; `origin` is `{ module, binding }`; and `routes` +lists the sorted ids of every route bound to it. Each route names its +contract as `route.contract`. Identity is the declaration site, not the +content: two routes importing one binding share one contract, while two +textually equal schemas declared separately stay two contracts. A contract +declared outside the route's own module joins the route's digest identity; +graphs whose schemas are all inline keep their recorded digests. +`agent-bundle inspect --routes` prints the contracts with the graph, and the +Workbench route detail shows a route's contract origin and the other routes +sharing it. | Code | Severity | Trigger | | --- | --- | --- | @@ -1117,7 +1178,7 @@ schema constants), unions, nested objects, transforms, coercions — raises | `AB4803` | error | A route path derives an unsafe identity segment (each segment must match `^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$`). | | `AB4804` | error | A `routes` mode override is not `generated`/`custom`/`command`/`remote` for a server, or `generated`/`conventional` for the CLI. | | `AB4805` | error | A route module exports `config` through a rejected declaration shape (`let`/`var`, destructuring, `export { config }`, a function or class, a missing initializer), or the extracted value is not an object. | -| `AB4806` | error | A route module's `config` initializer is dynamic — the message names the offending construct and position, and the recovery names the two accepted reference forms (a top-level `const` string literal declared locally or `export const`-ed by a relative sibling module, and `appResourceUri('')` from `agent-bundle/routes`). | +| `AB4806` | error | A route module's `config` initializer is dynamic — the message names the offending construct and position (for a reference the static resolver could not follow, the boundary it stopped at: a non-relative specifier, a module outside the project, a missing `export const`, a non-`const` binding, a non-literal initializer), and the recovery names the two accepted reference forms (a top-level `const` string literal declared locally or reached through `export const` alias hops across any number of relative modules inside the project, and `appResourceUri('')` from `agent-bundle/routes`). | | `AB4807` | retired | The stage-1 rendered-script gate. Rendered script routes ship through the Agent renderer pipeline since #102 stage 3; the code is never reused. | | `AB4808` | error | A conventional `src/scripts/` route nests below the scripts root; conventional scripts ship as direct children only. Move it up, prefix a path segment with `_`, or declare it under `scripts` in config with a flat name. | | `AB4809` | error | A conventional `src/scripts/` route and a configured `scripts` entry share one script identity through different files. Point the config entry at the module to claim it, or rename one of the two. | @@ -1125,7 +1186,7 @@ schema constants), unions, nested objects, transforms, coercions — raises | `AB4811` | error | A generated MCP route exports `execute` or `render`; route mode accepts only the async default Server Component contract. | | `AB4812` | error | A generated MCP App route has no non-empty static `config.resourceUri`. | | `AB4813` | error | The command graph collides: a route is both a command module and a command group, an alias collides with a sibling command, group, or alias, an alias is unsafe or duplicated, or an explicit `bin` entry claims the generated CLI executable's name. | -| `AB4814` | error | A CLI route's `inputSchema` leaves the bounded argv grammar (the message names the offending construct and position), a key projects onto a reserved or duplicate option name, a required boolean has no flag expression, or `config.positionals` violates the positional policy. | +| `AB4814` | error | A CLI route's `inputSchema` leaves the bounded argv grammar wherever the schema is declared — inline, or in a relative module the route imports (the message names the offending construct and position, qualified by the declaring module for a resolved import: `z.object at src/lib/protocol-schemas.ts:12:5 is outside the bounded argv grammar`) — a key projects onto a reserved or duplicate option name, a required boolean has no flag expression, or `config.positionals` violates the positional policy. A reference the static resolver cannot follow is `AB4838`, and a cyclic one `AB4839`, not `AB4814`. | | `AB4815` | error | A CLI route does not satisfy the routed command contract: missing named `inputSchema`/`resultSchema` exports, a default export that is not an async function, or malformed `config.description`/`aliases`/`exitCode` fields. | | `AB4816` | retired | The stage-2 rendered-command gate. Rendered command routes render through the dispatcher since #102 stage 3; the code is never reused. | | `AB4817` | error | An event route requires the shared runtime for a target, but no generated MCP entry hosts that runtime and the route does not allow standalone fallback. | @@ -1149,6 +1210,8 @@ schema constants), unions, nested objects, transforms, coercions — raises | `AB4835` | error | A route's static `config.render` (the render budget of one call, #454) is malformed: `render` is not an object, carries a key other than `maxElapsedMs`, `maxElapsedMs` is not a positive integer of milliseconds, or it exceeds the framework ceiling of `86400000` (24 hours) — or a plain `.ts` CLI command declares one, although it executes without a render session. Reported once per route: on an MCP tool, resource, or prompt route with its server (the tool's projected CLI command inherits the value), or on a `src/cli/**` command route; a route with a rejected budget compiles no command. Omit `render` to keep the runtime default (`60000`). Declare `config.render = { maxElapsedMs: }` on a rendered route, or remove it. The budget bounds the framework's render session only: Codex's `tool_timeout_sec` (60 s by default) and any per-server host timeout must be raised by the operator separately, while Claude Code's default per-call wall clock is about 28 hours and its idle timer is kept alive by the `notifications/progress` the projector forwards. | | `AB4836` | error | A route's static `config.execution` (MCP task support, #369) is malformed: `execution` is not an object, carries a key other than `taskSupport`, or `taskSupport` is not one of `forbidden`, `optional`, `required` — or a resource or prompt route declares it, although the `2025-11-25` Tasks utility augments `tools/call` only. Reported once per route with its server. Omit `execution` to keep the wire default (`forbidden`: every call is an ordinary request), or declare `config.execution = { taskSupport: 'optional' }` so a task-aware client may receive a `CreateTaskResult` and poll `tasks/get` / `tasks/result` while the render continues, or `'required'` to refuse ordinary calls with JSON-RPC `-32601`. The generated server advertises the value in `tools/list` and declares the `tasks` capability only when at least one tool opted in. | | `AB4837` | error | A route module of any kind except an App — a `src/cli/**` command, a `src/scripts/**` script, a tool, resource, or prompt route of a generated server, an event route — a layout, or a provider, or a module one of them reaches through relative value imports, imports `agent-bundle`, `agent-bundle/api`, `agent-bundle/config`, `agent-bundle/eval`, `agent-bundle/rstest`, `agent-bundle/test`, or `agent-bundle/test/browser` as a value (a static import whose binding is read at run time, `import 'agent-bundle/api'`, `import('agent-bundle/api')` with a literal specifier, or a non-type re-export). Those entries carry the compiler, and the generated executable is self-contained (#387): the bundler would inline the compiler and fail on the framework's runtime-relative module references (`Module not found: Can't resolve '../events'`), or the artifact validator would reject the inlined compiler's non-literal dynamic imports with `AB6005` — either way naming a generated file instead of the route (#558). Judged statically when the route graph compiles, so `inspect`, `validate`, `build`, and `dev` all report it, once per module, naming the route and the helper the import lives in. `import type`, `type`-qualified specifiers, and imports used only in type positions are elided by the bundler and never reported; routes of a server that is not generated (`custom`/`command`/`remote`, or an `AB4800` conflict) or of a CLI that is not generated (`conventional`, or an `AB4801` conflict) are never bundled, so they are not judged; likewise a layout that no bundled rendered route composes through (a worker imports only the layouts its routes reach: the tool, resource, and prompt routes of a generated server, the rendered `.tsx` commands of a generated CLI, and rendered `.tsx` scripts), and a provider in a project whose only executables are plain `.ts` scripts, which are bundled from their own source and mount none. Spawn the framework instead of importing it: serve an MCP App from a routed command with `spawnServeApp` from `agent-bundle/serve-app-command`, which runs `agent-bundle serve-app` as a child process; keep other framework calls in host processes (`package.json` scripts, a hand-written `.mjs` run from the checkout). The bundle-safe entries stay allowed: `agent-bundle/routes`, `agent-bundle/launch-env`, `agent-bundle/meta`, `agent-bundle/mcp-apps`, `agent-bundle/mcp-entry`, `agent-bundle/cli-entry`, `agent-bundle/terminal-capability`, and `agent-bundle/serve-app-command`. | +| `AB4838` | error | A CLI route's `inputSchema` references a binding the static resolver cannot follow. The message is `CLI route inputSchema: .` — the chain is the reference path from `inputSchema`, each step ``, or ` ()` when it crosses into another module (`inputSchema -> statusInputSchema (src/lib/protocol-schemas.ts) -> requestStatusSchema -> requestStatuses`), and the reason names the boundary: a specifier that `is not a relative module path`, one that `resolves outside the project` or `does not resolve to a module inside the project` (missing or unreadable), a target module that does not declare a top-level `export const `, a binding that is not a top-level `const` (`let`/`var`, destructuring, a function, a class, a default or namespace import — the message says what it is), an identifier that `is neither a top-level const in this module nor a named import from a relative module`, or a dynamic initializer — one that is neither a method chain, an object or array literal, nor a static literal (`whose initializer is a call expression`, `a function expression`, `a template literal with substitutions`). Reported on the route module; the recovery names the supported forms — relative imports inside the project, `export const`, alias chains — then says to inspect again. Only CLI routes raise it, because only there the static contract is load-bearing: an MCP, script, or event route whose schema the resolver cannot follow compiles without a static contract, as an out-of-grammar inline schema does, and the runtime derives its MCP JSON Schema from the real zod object. A reference that resolves but whose schema leaves the grammar is `AB4814`. | +| `AB4839` | error | A CLI route's `inputSchema` reference chain is cyclic — `a` → `b` → `a`, within one module or across several: every visited `#` is recorded and revisiting one stops the walk. The message is `CLI route inputSchema: is a reference cycle.` and prints the cycle; it is reported on the route module, with the same recovery and the same CLI-only rule as `AB4838`. | | `AB4940` | error | A conventional provider module has no default export or its default export is not a function. Default-export a factory receiving `{ invocation, plugin, signal }`. | | `AB4941` | error | Two provider filenames derive the same camel-cased provider key. Rename one file so every provider key is unique. | | `AB4942` | error | A provider filename derives the reserved `processLifetime` key. Rename the file so its camel-cased key does not collide with the framework-owned provider. | diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index a90318a88..3d602a658 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -821,7 +821,7 @@ export default async function inspect({ input, signal }: CliRouteProps.js` with the shebang and executable bit through the same Rslib synthesis as every other bin. At run time the shell resolves the @@ -836,6 +836,17 @@ already emit the canonical JSON document. Routed CLI projects need `@agent-bundle/runtime` as a dependency — the generated executable installs the request context through it. +The schema need not be written inline: an `inputSchema` bound to a schema +`export const`-ed by a relative module inside the project +(`export const inputSchema = statusInputSchema`, through any number of alias +hops, the `.js` specifier mapping onto its `.ts`/`.tsx` source) is resolved +statically, parsed in the declaring module's scope under the same grammar, +and normalized once into a `RouteContract` shared by every route — CLI +command or MCP tool — that binds it; a reference the resolver cannot follow +is `AB4838` and a cyclic one `AB4839`, both documented in the same +[Diagnostics](diagnostics.md#route-graph-state-layout-and-provider-conventions-ab4800ab4839-ab4940ab4942) +section. + When the module's `inputSchema` rejects the parsed argv, the shell reports each issue in CLI terms rather than the raw schema issue JSON (#465): one line per issue naming the argument as typed (`--max-files` for a named diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 1bf116788..0db293a9a 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -90,6 +90,8 @@ export type { CompiledRouteKind, CompiledServerMode, CompiledServerSurface, + RouteContract, + RouteContractOrigin, RouteProvenance, } from './routes/types.ts'; export type { BuildResult } from './build/build.ts'; diff --git a/packages/agent-bundle/src/contracts/routes.ts b/packages/agent-bundle/src/contracts/routes.ts index f1139a301..bc8b60315 100644 --- a/packages/agent-bundle/src/contracts/routes.ts +++ b/packages/agent-bundle/src/contracts/routes.ts @@ -10,6 +10,7 @@ export type { RouteManifestCliOption, RouteManifestCliSurface, RouteManifestConfigEntry, + RouteManifestContract, RouteManifestKind, RouteManifestProvenance, RouteManifestProvider, diff --git a/packages/agent-bundle/src/dev/index.ts b/packages/agent-bundle/src/dev/index.ts index 276ded971..b7733ac6e 100644 --- a/packages/agent-bundle/src/dev/index.ts +++ b/packages/agent-bundle/src/dev/index.ts @@ -96,6 +96,7 @@ export type { RouteManifestCliOption, RouteManifestCliSurface, RouteManifestConfigEntry, + RouteManifestContract, RouteManifestProvider, RouteManifestResponse, RouteManifestRoute, diff --git a/packages/agent-bundle/src/dev/routes/route-manifest.ts b/packages/agent-bundle/src/dev/routes/route-manifest.ts index 9a386a31d..28b62a9f6 100644 --- a/packages/agent-bundle/src/dev/routes/route-manifest.ts +++ b/packages/agent-bundle/src/dev/routes/route-manifest.ts @@ -49,8 +49,21 @@ export interface RouteManifestProvenance { readonly kind: 'conventional'; } +/** Mirrors one compiler route contract without exposing server-only module paths. */ +export interface RouteManifestContract { + readonly id: string; + readonly input: RouteInputSchema; + readonly origin: { + readonly binding: string; + readonly module: string; + }; + readonly routes: readonly string[]; +} + /** One compiled route projected for the browser catalog. */ export interface RouteManifestRoute { + /** Id of the compiler contract this route binds. */ + readonly contract?: string; readonly config: readonly RouteManifestConfigEntry[]; /** `config.description` when it is a string; the catalog's human label. */ readonly description?: string; @@ -128,6 +141,8 @@ export type RouteManifestState = StateDefinitionProjection; */ export interface RouteManifest { readonly cli?: RouteManifestCliSurface; + /** Present only when the compiler graph carries route contracts. */ + readonly contracts?: readonly RouteManifestContract[]; readonly diagnostics: readonly Diagnostic[]; /** The graph digest over project-relative route identity. */ readonly digest: string; @@ -185,6 +200,7 @@ const description = (config: Readonly>): string | undefi const manifestRoute = (route: CompiledAgentRoute): RouteManifestRoute => { const summary = description(route.config); return { + ...(route.contract === undefined ? {} : { contract: route.contract }), config: configSummary(route.config), ...(summary === undefined ? {} : { description: summary }), ...(route.event === undefined ? {} : { event: route.event }), @@ -245,6 +261,14 @@ export const routeManifestFor = ( notices?: NormalizedNotices, ): RouteManifest => deepFreeze({ ...(graph.cli === undefined ? {} : { cli: manifestCli(graph.cli) }), + ...(graph.contracts === undefined ? {} : { + contracts: graph.contracts.map((contract) => ({ + id: contract.id, + input: contract.input, + origin: { ...contract.origin }, + routes: [...contract.routes], + })), + }), diagnostics: graph.diagnostics.map((diagnostic) => ({ ...diagnostic })), digest: graph.digest, events: graph.events.map(manifestRoute), diff --git a/packages/agent-bundle/src/routes/cli-argv.ts b/packages/agent-bundle/src/routes/cli-argv.ts index 227bc1337..4ec278213 100644 --- a/packages/agent-bundle/src/routes/cli-argv.ts +++ b/packages/agent-bundle/src/routes/cli-argv.ts @@ -1,7 +1,20 @@ import type { Diagnostic } from '../core/diagnostics.ts'; import { deepFreeze } from '../core/freeze.ts'; -import { parseInputSchema, type StaticInputSchemaProperty } from './input-schema.ts'; -import type { CompiledCliOption } from './types.ts'; +import { + parseInputSchema, + type InputSchemaExtractionOptions, + type InputSchemaResolutionFailure, + type ParsedInputSchemaEntry, + type ResolvedSchemaOrigin, + type ScalarBase, + type StaticInputSchemaProperty, +} from './input-schema.ts'; +import type { + CompiledCliOption, + RouteInputArrayItemSchema, + RouteInputPropertySchema, + RouteInputSchema, +} from './types.ts'; /** * The bounded zod-to-argv grammar (#102 stage 2). A routed CLI command's @@ -25,12 +38,14 @@ export const reservedCliOptionNames: ReadonlySet = Object.freeze(new Set * The statically extracted argv projection of one CLI route module's * `inputSchema` export. `found` is false when the module has no extractable * `export const inputSchema` declaration (the route-contract diagnostic owns - * that state); `options` is absent whenever a diagnostic fired. + * that state); `options` is absent whenever a diagnostic fired; `origin` is + * where the schema is declared whenever that is known. */ export interface ExtractedCliArgv { readonly diagnostics: readonly Diagnostic[]; readonly found: boolean; readonly options?: readonly CompiledCliOption[]; + readonly origin?: ResolvedSchemaOrigin; } const grammarRecovery = `Restrict the inputSchema initializer to the bounded argv grammar (${cliArgvGrammar}), then inspect again.`; @@ -43,6 +58,26 @@ const argvError = (message: string, sourcePath: string): Diagnostic => ({ sourcePath, }); +const resolutionRecovery = 'Declare the schema inline, or reference a top-level `export const` of a module reached through relative imports inside the project (alias chains such as `export const inputSchema = shared` are followed); then inspect again.'; + +/** AB4838 for a reference the static resolver cannot follow; AB4839 for a reference cycle. */ +const resolutionError = ( + failure: InputSchemaResolutionFailure, + relativePath: string, + sourcePath: string, +): Diagnostic => { + const chain = failure.chain.join(' -> '); + return { + code: failure.kind === 'cycle' ? 'AB4839' : 'AB4838', + message: failure.kind === 'cycle' + ? `CLI route ${relativePath} inputSchema: ${chain} is a reference cycle.` + : `CLI route ${relativePath} inputSchema: ${chain} ${failure.reason}.`, + recovery: resolutionRecovery, + severity: 'error', + sourcePath, + }; +}; + const optionNameOf = (key: string): string => key .replace(/([a-z0-9])([A-Z])/gu, '$1-$2') .replace(/([A-Z]+)([A-Z][a-z])/gu, '$1-$2') @@ -53,6 +88,7 @@ interface CliPropertyProjection { readonly option?: CompiledCliOption; } +/** The argv policy for one schema property: flag rule, kebab-case naming, and reserved names. */ const cliOptionFor = ( property: StaticInputSchemaProperty, relativePath: string, @@ -100,30 +136,22 @@ const cliOptionFor = ( }; }; -/** - * Statically projects one CLI route module's `export const inputSchema` - * declaration onto the argv contract. The module is parsed with the - * TypeScript compiler and never executed; validation-only refinements pass - * through uninterpreted because the real zod schema validates at run time. - */ -export const extractCliArgv = ( - moduleText: string, +/** The option surface one schema projects onto; `options` is absent whenever a diagnostic fired. */ +export interface ProjectedCliOptions { + readonly diagnostics: readonly Diagnostic[]; + readonly options?: readonly CompiledCliOption[]; +} + +/** The one argv projection policy: per-property rules, then option-name collisions, then deterministic order. */ +const projectOptions = ( + entries: readonly ParsedInputSchemaEntry[], relativePath: string, sourcePath: string, -): ExtractedCliArgv => { - const parsed = parseInputSchema(moduleText, relativePath); - if (!parsed.found) return deepFreeze({ diagnostics: [], found: false }); - if (parsed.entries === undefined) { - return deepFreeze({ - diagnostics: parsed.issues.map((issue) => argvError(issue, sourcePath)), - found: true, - }); - } - +): ProjectedCliOptions => { const diagnostics: Diagnostic[] = []; const options: CompiledCliOption[] = []; const seenOptions = new Map(); - for (const entry of parsed.entries) { + for (const entry of entries) { if ('issue' in entry) { diagnostics.push(argvError(entry.issue, sourcePath)); continue; @@ -145,11 +173,78 @@ export const extractCliArgv = ( seenOptions.set(option.option, option.key); options.push(option); } - - if (diagnostics.length > 0) return deepFreeze({ diagnostics, found: true }); - return deepFreeze({ + if (diagnostics.length > 0) return { diagnostics }; + return { diagnostics: [], - found: true, options: [...options].sort((left, right) => left.option.localeCompare(right.option)), - }); + }; +}; + +const scalarBaseOfSchema = (schema: RouteInputArrayItemSchema): ScalarBase => + schema.type === 'string' && schema.enum !== undefined + ? { choices: schema.enum, kind: 'enum' } + : { kind: schema.type }; + +/** One canonical contract property in the shape the module parse produces, so both take the same policy. */ +const staticPropertyOf = ( + key: string, + schema: RouteInputPropertySchema, + required: readonly string[], +): StaticInputSchemaProperty => ({ + base: schema.type === 'array' ? scalarBaseOfSchema(schema.items) : scalarBaseOfSchema(schema), + ...(schema.default === undefined ? {} : { defaultValue: schema.default }), + ...(schema.description === undefined ? {} : { description: schema.description }), + hasDefault: schema.default !== undefined, + key, + optional: !required.includes(key), + repeated: schema.type === 'array', +}); + +/** + * Projects a route's canonical input contract — the `RouteInputSchema` + * graph.ts normalized once, shared by every route bound to it — onto argv + * with exactly the policy the module parse applies, so the command grammar + * is a projection of the contract rather than a second reading of the + * module. + */ +export const projectInputSchemaOptions = ( + schema: RouteInputSchema, + relativePath: string, + sourcePath: string, +): ProjectedCliOptions => deepFreeze(projectOptions( + Object.entries(schema.properties).map(([key, property]) => ({ + property: staticPropertyOf(key, property, schema.required ?? []), + })), + relativePath, + sourcePath, +)); + +/** + * Statically projects one CLI route module's `export const inputSchema` + * declaration onto the argv contract. The module is parsed with the + * TypeScript compiler and never executed; validation-only refinements pass + * through uninterpreted because the real zod schema validates at run time. A + * schema reached through a reference the resolver cannot follow is AB4838 + * (AB4839 for a cycle); grammar issues stay AB4814. + */ +export const extractCliArgv = ( + moduleText: string, + relativePath: string, + sourcePath: string, + options: InputSchemaExtractionOptions = {}, +): ExtractedCliArgv => { + const parsed = parseInputSchema(moduleText, relativePath, options); + if (!parsed.found) return deepFreeze({ diagnostics: [], found: false }); + const origin = parsed.origin === undefined ? {} : { origin: parsed.origin }; + if (parsed.entries === undefined) { + return deepFreeze({ + diagnostics: [ + ...parsed.issues.map((issue) => argvError(issue, sourcePath)), + ...(parsed.resolution === undefined ? [] : [resolutionError(parsed.resolution, relativePath, sourcePath)]), + ], + found: true, + ...origin, + }); + } + return deepFreeze({ ...projectOptions(parsed.entries, relativePath, sourcePath), found: true, ...origin }); }; diff --git a/packages/agent-bundle/src/routes/cli-commands.ts b/packages/agent-bundle/src/routes/cli-commands.ts index 62d44c72b..72e6ab7c4 100644 --- a/packages/agent-bundle/src/routes/cli-commands.ts +++ b/packages/agent-bundle/src/routes/cli-commands.ts @@ -1,6 +1,6 @@ import { extname } from 'node:path'; -import { extractCliArgv } from './cli-argv.ts'; +import { extractCliArgv, projectInputSchemaOptions, type ExtractedCliArgv } from './cli-argv.ts'; import { scanRouteModuleExports } from './contract.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { deepFreeze } from '../core/freeze.ts'; @@ -350,6 +350,33 @@ export const compileMcpCliCommands = ( }); }; +export interface CompileCliCommandsOptions { + /** Absolute project root; an `inputSchema` reference resolving outside it is rejected (AB4838). */ + readonly projectRoot?: string; +} + +/** + * The argv surface of one route: a projection of its canonical contract when + * the graph bound one (`route.inputSchema` is the contract's normalized + * `input`), so the command grammar and the route's declared input are one + * object; otherwise the module is parsed again, which is what reports why no + * contract exists (AB4814, AB4838, AB4839). + */ +const routeArgv = ( + route: CompiledAgentRoute, + moduleText: string, + options: CompileCliCommandsOptions, +): ExtractedCliArgv => { + const relativePath = route.provenance.relativePath; + if (route.inputSchema !== undefined) { + return { ...projectInputSchemaOptions(route.inputSchema, relativePath, route.source), found: true }; + } + return extractCliArgv(moduleText, relativePath, route.source, { + ...(options.projectRoot === undefined ? {} : { projectRoot: options.projectRoot }), + source: route.source, + }); +}; + /** * Compiles the generated-mode CLI route surface into the collision-checked * command graph. `readModuleText` supplies each plain route's source text @@ -360,6 +387,7 @@ export const compileCliCommands = async ( routes: readonly CompiledAgentRoute[], readModuleText: (route: CompiledAgentRoute) => Promise, projected: CompiledMcpCliCommandSurface = { commands: [], diagnostics: [], routes: [] }, + compileOptions: CompileCliCommandsOptions = {}, ): Promise => { const diagnostics: Diagnostic[] = [...projected.diagnostics]; const commands: CompiledCliCommand[] = [...projected.commands]; @@ -376,7 +404,7 @@ export const compileCliCommands = async ( // A default re-exported from a module the scan cannot read is judged at // run time, like the MCP route contract. const asyncDefault = exports.asyncDefault || exports.defaultReExport?.resolution === 'unresolved'; - const argv = extractCliArgv(moduleText, relativePath, route.source); + const argv = routeArgv(route, moduleText, compileOptions); const missing = [ ...(argv.found ? [] : ['inputSchema']), ...(exports.named.has('resultSchema') ? [] : ['resultSchema']), diff --git a/packages/agent-bundle/src/routes/config-extract.ts b/packages/agent-bundle/src/routes/config-extract.ts index 37eeb4a0c..e06d9d135 100644 --- a/packages/agent-bundle/src/routes/config-extract.ts +++ b/packages/agent-bundle/src/routes/config-extract.ts @@ -1,4 +1,4 @@ -import { dirname, extname, isAbsolute, relative, resolve } from 'node:path'; +import { dirname, extname, resolve } from 'node:path'; // Aliased: the workspace toolchain is typescript@7 (native compiler, no // single-file parse API), and a plain `typescript` dependency here would @@ -8,10 +8,24 @@ import ts from 'typescript-5'; import type { Diagnostic } from '../core/diagnostics.ts'; import { deepFreeze } from '../core/freeze.ts'; -import { isRelativeSpecifier, moduleCandidates, readModuleFromDisk } from './module-candidates.ts'; -import { hasExportModifier, positionOf, unwrapExpression } from './syntax.ts'; +import { isRelativeSpecifier } from './module-candidates.ts'; +import { + createModuleScopeResolver, + describeExpression, + rootReferencePath, + type ModuleScopeResolver, + type ModuleSourceFile, + type ReferencePath, +} from './module-scope.ts'; +import { hasExportModifier, positionOf, unwrapExpression, type SyntaxNode } from './syntax.ts'; import { emptyRouteConfig } from './types.ts'; +// The scope model hands out structural node slices so its shipped declaration +// never names typescript-5 (see module-scope.ts); every slice is a compiler +// node, narrowed back here, once, at the boundary. +const compilerExpression = (node: SyntaxNode): ts.Expression => node as ts.Expression; +const compilerSourceFile = (sourceFile: ModuleSourceFile): ts.SourceFile => sourceFile as ts.SourceFile; + /** The package subpath route modules import compile-time authoring helpers from. */ export const routeHelpersSpecifier = 'agent-bundle/routes'; @@ -76,8 +90,9 @@ export interface RouteConfigExtractionOptions { * accepted form (they unwrap to their inner expression); * - two constrained reference forms for string values: an identifier bound * to a top-level `const` whose initializer is a string literal, declared - * in the same module or `export const`-ed by a module reached through a - * relative import inside the project; and `appResourceUri('')` + * in the same module or `export const`-ed by a module reached through + * relative imports inside the project, following alias hops + * (`export const a = b`) any depth (module-scope.ts); and `appResourceUri('')` * imported from `agent-bundle/routes`, which the route-graph compiler * replaces with the referenced App route's `resourceUri`. * @@ -117,105 +132,13 @@ type Extraction = const dynamic = (description: string, node: ts.Node): Extraction => ({ dynamic: { description, node }, kind: 'dynamic' }); -/** One `import { name as local } from ''` binding of the route module. */ -interface ImportedBinding { - readonly importedName: string; - readonly node: ts.Node; - readonly specifier: string; -} - -/** The top-level bindings of one parsed module the reference forms may consult. */ -interface ModuleScope { - /** Top-level `const` declarations by local name; the flag records `export`. */ - readonly consts: ReadonlyMap; - readonly imports: ReadonlyMap; - /** Local names bound by `let`/`var`, functions, classes, or non-named imports: known, but never static. */ - readonly nonConst: ReadonlySet; - readonly sourceFile: ts.SourceFile; -} - -const collectBindingNames = (name: ts.BindingName, into: Set): void => { - if (ts.isIdentifier(name)) { - into.add(name.text); - return; - } - for (const element of name.elements) { - if (!ts.isOmittedExpression(element)) collectBindingNames(element.name, into); - } -}; - -const scopeOf = (sourceFile: ts.SourceFile): ModuleScope => { - const consts = new Map(); - const imports = new Map(); - const nonConst = new Set(); - for (const statement of sourceFile.statements) { - if (ts.isVariableStatement(statement)) { - const isConst = (statement.declarationList.flags & ts.NodeFlags.Const) !== 0; - const exported = hasExportModifier(statement); - for (const declaration of statement.declarationList.declarations) { - if (isConst && ts.isIdentifier(declaration.name)) { - consts.set(declaration.name.text, { exported, initializer: declaration.initializer }); - } else { - collectBindingNames(declaration.name, nonConst); - } - } - continue; - } - if (ts.isImportDeclaration(statement)) { - const clause = statement.importClause; - if (clause === undefined || !ts.isStringLiteral(statement.moduleSpecifier)) continue; - const specifier = statement.moduleSpecifier.text; - if (clause.name !== undefined) nonConst.add(clause.name.text); - const bindings = clause.namedBindings; - if (bindings === undefined) continue; - if (ts.isNamespaceImport(bindings)) { - nonConst.add(bindings.name.text); - continue; - } - for (const element of bindings.elements) { - if (clause.isTypeOnly || element.isTypeOnly) continue; - const importedName = element.propertyName?.text ?? element.name.text; - imports.set(element.name.text, { importedName, node: element, specifier }); - } - continue; - } - if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && statement.name !== undefined) { - nonConst.add(statement.name.text); - } - } - return { consts, imports, nonConst, sourceFile }; -}; - -/** Names one rejected construct for the AB4806 message. */ -const describeExpression = (node: ts.Node): string => { - if (ts.isIdentifier(node)) { - return node.text === 'undefined' - ? 'the non-JSON value `undefined`' - : `a reference to the identifier ${JSON.stringify(node.text)}`; - } - if (ts.isCallExpression(node)) return 'a call expression'; - if (ts.isTemplateExpression(node)) return 'a template literal with substitutions'; - if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) return 'a function expression'; - if (ts.isSpreadAssignment(node) || ts.isSpreadElement(node)) return 'a spread'; - if (ts.isShorthandPropertyAssignment(node)) return 'a shorthand property reference'; - if (ts.isMethodDeclaration(node) || ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node)) { - return 'a method or accessor'; - } - if (ts.isComputedPropertyName(node)) return 'a computed property name'; - if (ts.isOmittedExpression(node)) return 'an array hole'; - if (node.kind === ts.SyntaxKind.BigIntLiteral) return 'a bigint literal'; - if (node.kind === ts.SyntaxKind.RegularExpressionLiteral) return 'a regular expression literal'; - return `a ${ts.SyntaxKind[node.kind] ?? 'dynamic'} expression`; -}; - const literalPropertyName = (name: ts.PropertyName): string | undefined => { if (ts.isIdentifier(name)) return name.text; if (ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text; return undefined; }; -const stringLiteralText = (expression: ts.Expression | undefined): string | undefined => { - if (expression === undefined) return undefined; +const stringLiteralText = (expression: ts.Expression): string | undefined => { const node = unwrapExpression(expression); return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) ? node.text : undefined; }; @@ -231,100 +154,56 @@ const finiteNumber = (value: number, node: ts.Node): Extraction => ? { kind: 'value', value } : dynamic(`the non-finite number \`${String(value)}\``, node); -const scriptKindOf = (relativePath: string): ts.ScriptKind => { - if (relativePath.endsWith('.tsx')) return ts.ScriptKind.TSX; - if (relativePath.endsWith('.jsx')) return ts.ScriptKind.JSX; - return ts.ScriptKind.TS; -}; - -const parseModule = (path: string, text: string): ts.SourceFile => - ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true, scriptKindOf(path)); - -const insideProject = (projectRoot: string | undefined, path: string): boolean => { - if (projectRoot === undefined) return true; - const relativePath = relative(projectRoot, path); - return relativePath !== '' && !relativePath.startsWith('..') && !isAbsolute(relativePath); -}; - -/** Per-extraction state: the route module's scope, the reference sink, and a sibling-module cache. */ +/** Per-extraction state: the route module's `config` reference path, the shared resolver, and the App-reference sink. */ interface ExtractionContext { readonly appReferences: RouteConfigAppReference[]; - readonly options: RouteConfigExtractionOptions; - readonly readModule: (path: string) => string | undefined; - readonly scope: ModuleScope; - readonly siblingScopes: Map; - readonly sourceDirectory: string; + readonly resolver: ModuleScopeResolver; + /** The `config` binding in the route module: every identifier in the initializer resolves from here. */ + readonly root: ReferencePath; } -type ImportedConstResolution = - | { readonly kind: 'value'; readonly value: string } - | { readonly kind: 'rejected'; readonly reason: string }; - -const resolveImportedConst = ( - binding: ImportedBinding, - context: ExtractionContext, -): ImportedConstResolution => { - const from = JSON.stringify(binding.specifier); - if (!isRelativeSpecifier(binding.specifier)) { - return { kind: 'rejected', reason: `imported from ${from}, which is not a relative module path` }; - } - const candidates = moduleCandidates(context.sourceDirectory, binding.specifier); - let scope: ModuleScope | undefined; - for (const candidate of candidates) { - if (!insideProject(context.options.projectRoot, candidate)) { - return { kind: 'rejected', reason: `imported from ${from}, which resolves outside the project` }; - } - if (context.siblingScopes.has(candidate)) { - scope = context.siblingScopes.get(candidate); - } else { - const text = context.readModule(candidate); - scope = text === undefined ? undefined : scopeOf(parseModule(candidate, text)); - context.siblingScopes.set(candidate, scope); - } - if (scope !== undefined) break; - } - if (scope === undefined) { - return { kind: 'rejected', reason: `imported from ${from}, which does not resolve to a module inside the project` }; - } - const declaration = scope.consts.get(binding.importedName); - if (declaration === undefined || !declaration.exported) { - return { - kind: 'rejected', - reason: `imported from ${from}, which does not declare a top-level \`export const ${binding.importedName}\``, - }; - } - const value = stringLiteralText(declaration.initializer); - if (value === undefined) { - return { - kind: 'rejected', - reason: `imported from ${from}, whose \`export const ${binding.importedName}\` initializer is not a string literal`, - }; - } - return { kind: 'value', value }; -}; - -/** Resolves one identifier through the two constrained reference forms. */ +/** + * Resolves one identifier through the const string-literal reference form: + * a top-level `const` in this module or an `export const` of a module reached + * through relative imports inside the project, following alias hops + * (`export const a = b`) any depth. The first hop keeps the wording the + * grammar documents; a failure deeper in the chain prints the chain, so the + * boundary is named where it lies. + */ const extractIdentifier = (node: ts.Identifier, context: ExtractionContext): Extraction => { if (node.text === 'undefined') return dynamic(describeExpression(node), node); const reference = `a reference to the identifier ${JSON.stringify(node.text)}`; - const local = context.scope.consts.get(node.text); - if (local !== undefined) { - const value = stringLiteralText(local.initializer); - return value === undefined - ? dynamic(`${reference}, whose top-level const initializer is not a string literal`, node) - : { kind: 'value', value }; - } - const imported = context.scope.imports.get(node.text); - if (imported !== undefined) { - const resolved = resolveImportedConst(imported, context); - return resolved.kind === 'value' - ? { kind: 'value', value: resolved.value } - : dynamic(`${reference}, ${resolved.reason}`, node); - } - if (context.scope.nonConst.has(node.text)) { - return dynamic(`${reference}, which is not a top-level \`const\` string literal`, node); + const resolved = context.resolver.resolve(context.root, node.text); + switch (resolved.kind) { + case 'resolved': { + const value = stringLiteralText(compilerExpression(resolved.initializer)); + if (value !== undefined) return { kind: 'value', value }; + return resolved.scope === context.root.scope + ? dynamic(`${reference}, whose top-level const initializer is not a string literal`, node) + : dynamic( + `${reference}, declared in ${resolved.scope.relativePath}, whose \`export const ${resolved.binding}\` initializer is not a string literal`, + node, + ); + } + case 'unresolved': { + if (resolved.chain.length > 2) { + return dynamic(`${reference} resolving through ${resolved.chain.slice(1).join(' -> ')}, ${resolved.reason}`, node); + } + if (resolved.boundary === 'non-const') { + return dynamic(`${reference}, which is not a top-level \`const\` string literal`, node); + } + if (resolved.boundary === 'unknown') { + return dynamic(`${reference}, which is neither a top-level const string literal in this module nor a named import from a relative module`, node); + } + return dynamic(`${reference}, ${resolved.reason}`, node); + } + case 'cycle': + return dynamic(`${reference}, whose alias chain ${resolved.chain.slice(1).join(' -> ')} is a reference cycle`, node); + default: { + const unreachable: never = resolved; + throw new TypeError(`Unhandled reference resolution ${String(unreachable)}.`); + } } - return dynamic(`${reference}, which is neither a top-level const string literal in this module nor a named import from a relative module`, node); }; /** Recognizes `appResourceUri('')` imported from the route helpers subpath. */ @@ -335,7 +214,7 @@ const extractAppReferenceCall = ( ): Extraction => { const callee = unwrapExpression(node.expression); if (!ts.isIdentifier(callee)) return dynamic(describeExpression(node), node); - const binding = context.scope.imports.get(callee.text); + const binding = context.root.scope.imports.get(callee.text); if (binding === undefined || binding.importedName !== appResourceUriHelperName) { if (callee.text === appResourceUriHelperName) { return dynamic(`a call to ${JSON.stringify(callee.text)} that is not imported from ${routeHelpersSpecifier}`, node); @@ -359,7 +238,7 @@ const extractAppReferenceCall = ( } context.appReferences.push({ path, - position: positionOf(context.scope.sourceFile, node), + position: positionOf(context.root.scope.sourceFile, node), reference: extracted.value, }); // The reference text stands in until the graph compiler substitutes the @@ -489,13 +368,9 @@ export const extractRouteConfig = ( sourcePath: string, options: RouteConfigExtractionOptions = {}, ): ExtractedRouteConfig => { - const sourceFile = ts.createSourceFile( - relativePath, - moduleText, - ts.ScriptTarget.Latest, - true, - scriptKindOf(relativePath), - ); + const resolver = createModuleScopeResolver(options); + const scope = resolver.scopeOf(moduleText, relativePath, sourcePath); + const sourceFile = compilerSourceFile(scope.sourceFile); const site = findConfigExport(sourceFile); if (site === undefined) return emptyExtraction; if (site.initializer === undefined) { @@ -512,11 +387,8 @@ export const extractRouteConfig = ( } const context: ExtractionContext = { appReferences: [], - options, - readModule: options.readModule ?? readModuleFromDisk, - scope: scopeOf(sourceFile), - siblingScopes: new Map(), - sourceDirectory: dirname(sourcePath), + resolver, + root: rootReferencePath(scope, 'config'), }; const extracted = extractExpression(site.initializer, [], context); if (extracted.kind === 'dynamic') { diff --git a/packages/agent-bundle/src/routes/framework-imports.ts b/packages/agent-bundle/src/routes/framework-imports.ts index 8b9821e7f..18304388e 100644 --- a/packages/agent-bundle/src/routes/framework-imports.ts +++ b/packages/agent-bundle/src/routes/framework-imports.ts @@ -5,6 +5,7 @@ import ts from 'typescript-5'; import type { Diagnostic } from '../core/diagnostics.ts'; import { isInside, toPosixPath, toPosixRelative } from '../core/paths.ts'; import { isRelativeSpecifier, moduleCandidates, readModuleFromDisk } from './module-candidates.ts'; +import { parseModule } from './module-scope.ts'; /** * Framework entries whose module graph carries the compiler. Every generated @@ -66,13 +67,6 @@ export interface ScanFrameworkValueImportsOptions { readonly source: string; } -const scriptKindOf = (path: string): ts.ScriptKind => { - if (path.endsWith('.tsx')) return ts.ScriptKind.TSX; - if (path.endsWith('.jsx')) return ts.ScriptKind.JSX; - if (path.endsWith('.js') || path.endsWith('.mjs') || path.endsWith('.cjs')) return ts.ScriptKind.JS; - return ts.ScriptKind.TS; -}; - const compareStrings = (left: string, right: string): number => (left < right ? -1 : left > right ? 1 : 0); /** @@ -283,7 +277,9 @@ const scanModule = ( visited: Set, findings: FrameworkValueImport[], ): void => { - const sourceFile = ts.createSourceFile(source, moduleText, ts.ScriptTarget.Latest, true, scriptKindOf(source)); + // parseModule keeps `ts.*` out of module-scope's signatures; the structural + // ModuleSourceFile it returns is the compiler's SourceFile. + const sourceFile = parseModule(source, moduleText) as ts.SourceFile; for (const { form, specifier } of valueImportsOf(sourceFile)) { if (compilerCarrying.has(specifier)) { findings.push({ form, importer: source, specifier }); diff --git a/packages/agent-bundle/src/routes/graph.ts b/packages/agent-bundle/src/routes/graph.ts index 60544109e..ea097d204 100644 --- a/packages/agent-bundle/src/routes/graph.ts +++ b/packages/agent-bundle/src/routes/graph.ts @@ -25,7 +25,7 @@ import { validateRouteModuleContract, } from './contract.ts'; import { validateRouteFrameworkImports } from './framework-imports.ts'; -import { extractInputSchema } from './input-schema.ts'; +import { extractInputSchema, type ExtractedInputSchema, type ResolvedSchemaOrigin } from './input-schema.ts'; import { isLayoutRouteKind, layoutChainFor } from './layouts.ts'; import { providerKeyFromName } from './providers.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; @@ -47,6 +47,7 @@ import { type CompiledRouteKind, type CompiledServerMode, type CompiledServerSurface, + type RouteContract, type RouteInputSchema, } from './types.ts'; @@ -500,15 +501,30 @@ const routeFrameworkImportDiagnostics = ( return validateRouteFrameworkImports(moduleText, route.provenance.relativePath, route.source, executable); }; +/** The contract a route binds: the id and the one normalized `input` object every bound route shares. */ +interface ContractBinding { + readonly id: string; + readonly input: RouteInputSchema; + readonly origin: ResolvedSchemaOrigin; +} + +/** Contract identity is the declaration site: `contract:#`. */ +const contractIdOf = (origin: ResolvedSchemaOrigin): string => `contract:${origin.module}#${origin.binding}`; + +/** The contract id a route-local `export const inputSchema = z.object({ ... })` literal declares. */ +const inlineContractIdOf = (route: CompiledAgentRoute): string => + contractIdOf({ binding: 'inputSchema', module: route.provenance.relativePath }); + const compiledRoute = ( module: DiscoveredRouteModule, config: Readonly>, - inputSchema?: RouteInputSchema, + contract?: ContractBinding, ): CompiledAgentRoute => ({ config, + ...(contract === undefined ? {} : { contract: contract.id }), ...(module.event === undefined ? {} : { event: module.event }), id: module.id, - ...(inputSchema === undefined ? {} : { inputSchema }), + ...(contract === undefined ? {} : { inputSchema: contract.input }), kind: module.kind, provenance: { kind: 'conventional', relativePath: module.relativePath }, ...(module.serverName === undefined ? {} : { serverId: `mcp:${module.serverName}` }), @@ -536,7 +552,7 @@ const readRouteModuleText = async (source: string): Promise */ interface ExtractedModuleMetadata { readonly extracted: ExtractedRouteConfig; - readonly inputSchema?: RouteInputSchema; + readonly inputSchema?: ExtractedInputSchema; } const emptyExtractedRouteConfig: ExtractedRouteConfig = deepFreeze({ @@ -554,7 +570,7 @@ const extractedModuleMetadata = ( return { extracted: emptyExtractedRouteConfig }; } const extracted = extractRouteConfig(moduleText, module.relativePath, module.source, { projectRoot }); - const inputSchema = extractInputSchema(moduleText, module.relativePath); + const inputSchema = extractInputSchema(moduleText, module.relativePath, { projectRoot, source: module.source }); return { extracted, ...(inputSchema === undefined ? {} : { inputSchema }), @@ -582,8 +598,15 @@ const appReferenceTargets = ( : []; }); +/** + * A route's project-relative identity. An imported contract joins it by id: + * which shared declaration a route binds is part of what the route is. A + * route-local literal's contract is the route's own `inputSchema`, already + * covered, so inline-only graphs digest exactly as before #593. + */ const routeIdentity = (route: CompiledAgentRoute): Readonly> => ({ config: route.config, + ...(route.contract === undefined || route.contract === inlineContractIdOf(route) ? {} : { contract: route.contract }), ...(route.event === undefined ? {} : { event: route.event }), id: route.id, ...(route.inputSchema === undefined ? {} : { inputSchema: route.inputSchema }), @@ -792,6 +815,17 @@ export const compileRouteGraph = async ( } } const appTargets = appReferenceTargets(pending, serverModes); + // Routes declaring one schema — the same module and binding at the end of + // the alias chain — bind one contract and share its normalized `input` + // object; the first route by id supplies it. + const contractBindings = new Map(); + for (const { metadata } of [...pending].sort((left, right) => left.module.id.localeCompare(right.module.id))) { + if (metadata.inputSchema === undefined) continue; + const id = contractIdOf(metadata.inputSchema.origin); + if (!contractBindings.has(id)) { + contractBindings.set(id, { id, input: metadata.inputSchema.schema, origin: metadata.inputSchema.origin }); + } + } for (const { metadata, module } of pending) { const moduleText = moduleTextBySource.get(module.source); // Routes of a server that is not generated never ship their config: the @@ -811,7 +845,11 @@ export const compileRouteGraph = async ( ) : metadata.extracted; diagnostics.push(...resolved.diagnostics); - const route = compiledRoute(module, resolved.config, metadata.inputSchema); + const route = compiledRoute( + module, + resolved.config, + metadata.inputSchema === undefined ? undefined : contractBindings.get(contractIdOf(metadata.inputSchema.origin)), + ); if (route.kind === 'event-route' && moduleText !== undefined) { diagnostics.push(...validateEventRouteModuleContract( moduleText, @@ -1000,7 +1038,7 @@ export const compileRouteGraph = async ( } if (mode === 'generated') { const compiled = await compileCliCommands(cliRoutes, async (route) => - moduleTextBySource.get(route.source), projected); + moduleTextBySource.get(route.source), projected, { projectRoot }); diagnostics.push(...compiled.diagnostics); // The routed CLI executable inlines every command route (AB4837, #558). for (const route of cliRoutes) { @@ -1070,6 +1108,25 @@ export const compileRouteGraph = async ( } } + // Contracts are read off the final route set: a route of a custom, command, + // or remote server left the graph with its server and binds nothing here. + const contractRoutes = new Map(); + for (const route of new Map( + [...servers.flatMap((server) => server.routes), ...events, ...scripts, ...(cli?.routes ?? [])] + .map((route) => [route.id, route] as const), + ).values()) { + if (route.contract === undefined) continue; + const bound = contractRoutes.get(route.contract) ?? []; + bound.push(route.id); + contractRoutes.set(route.contract, bound); + } + const contracts: RouteContract[] = [...contractRoutes.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([id, routeIds]) => { + const { input, origin } = contractBindings.get(id)!; + return { id, input, origin, routes: [...routeIds].sort((left, right) => left.localeCompare(right)) }; + }); + const identity = { ...(cli === undefined ? {} @@ -1097,6 +1154,7 @@ export const compileRouteGraph = async ( return deepFreeze({ ...(cli === undefined ? {} : { cli }), + ...(contracts.length === 0 ? {} : { contracts }), diagnostics, digest: digest(identity), events, diff --git a/packages/agent-bundle/src/routes/index.ts b/packages/agent-bundle/src/routes/index.ts index b4fbb7856..1eda43e9a 100644 --- a/packages/agent-bundle/src/routes/index.ts +++ b/packages/agent-bundle/src/routes/index.ts @@ -2,7 +2,7 @@ export { compileRouteGraph, emptyCompiledRouteGraph, isEmptyRouteGraph } from '. export { cliArgvGrammar, extractCliArgv, reservedCliOptionNames } from './cli-argv.ts'; export type { ExtractedCliArgv } from './cli-argv.ts'; export { cliCommandPath, compileCliCommands, isRenderedCliRoute } from './cli-commands.ts'; -export type { CompiledCliCommandSurface } from './cli-commands.ts'; +export type { CompileCliCommandsOptions, CompiledCliCommandSurface } from './cli-commands.ts'; export { appResourceUriHelperName, extractRouteConfig, @@ -38,6 +38,8 @@ export type { CompiledRouteKind, CompiledServerMode, CompiledServerSurface, + RouteContract, + RouteContractOrigin, RouteProvenance, } from './types.ts'; export { generateRouteTypes, routeTypesRelativePath, writeRouteTypes } from './typegen.ts'; diff --git a/packages/agent-bundle/src/routes/input-schema.ts b/packages/agent-bundle/src/routes/input-schema.ts index abb7b0605..c6a146b2e 100644 --- a/packages/agent-bundle/src/routes/input-schema.ts +++ b/packages/agent-bundle/src/routes/input-schema.ts @@ -3,7 +3,16 @@ import ts from 'typescript-5'; import { deepFreeze } from '../core/freeze.ts'; -import { hasExportModifier, positionOf, unwrapExpression } from './syntax.ts'; +import { + createModuleScopeResolver, + describeExpression, + rootReferencePath, + type ModuleScope, + type ModuleScopeResolver, + type ModuleSourceFile, + type ReferencePath, +} from './module-scope.ts'; +import { hasExportModifier, positionOf, unwrapExpression, type SyntaxNode } from './syntax.ts'; import type { RouteInputArrayItemSchema, RouteInputPropertySchema, @@ -11,20 +20,48 @@ import type { RouteInputSchemaLiteral, } from './types.ts'; -// The zod-chain grammar below is this module's own; nothing else imports it. -// Keeping it module-private also keeps `ts.*` out of the shipped declaration -// (see syntax.ts), where `typescript-5` would be unresolvable for consumers. -interface ChainCall { - readonly args: readonly ts.Expression[]; - readonly method: string; - readonly node: ts.Node; +// The scope model hands out structural node slices so its shipped declaration +// never names typescript-5 (see module-scope.ts); every slice is a compiler +// node, narrowed back here, once, at the boundary. +const compilerExpression = (node: SyntaxNode): ts.Expression => node as ts.Expression; +const compilerSourceFile = (sourceFile: ModuleSourceFile): ts.SourceFile => sourceFile as ts.SourceFile; + +/** How `parseInputSchema` reads the modules a schema reference leads to. */ +export interface InputSchemaExtractionOptions { + /** Absolute project root; a relative import resolving outside it is rejected. Unset = unconstrained (tests). */ + readonly projectRoot?: string; + /** Reads one module's text by absolute path; undefined when unreadable. Defaults to a sync fs read. */ + readonly readModule?: (path: string) => string | undefined; + /** + * Absolute path of the route module; relative imports resolve against its + * directory. Without it an import reference is rejected (the reason names + * the missing source path). + */ + readonly source?: string; } -interface ZodChain { - readonly base: ChainCall; - readonly calls: readonly ChainCall[]; +/** + * Where a schema is declared: the module and the binding whose initializer is + * the schema expression, at the end of any alias chain. `module` is + * project-relative POSIX when the project root is known (or the route's own + * relativePath for a route-local declaration); otherwise the path as read. + */ +export interface ResolvedSchemaOrigin { + readonly binding: string; + readonly module: string; } +/** + * Why a reference in an `inputSchema` declaration could not be followed. The + * chain is printable: `inputSchema` first, then each binding reached, as + * `` when declared in the same module as the previous step and + * ` ()` otherwise. `reason` continues the last step + * (`imported from "x", which ...`, `which is ...`). + */ +export type InputSchemaResolutionFailure = + | { readonly chain: readonly string[]; readonly kind: 'unresolved'; readonly reason: string } + | { readonly chain: readonly string[]; readonly kind: 'cycle' }; + export type ScalarBaseKind = 'boolean' | 'enum' | 'number' | 'string'; export interface ScalarBase { @@ -45,28 +82,145 @@ export interface StaticInputSchemaProperty { export interface ParsedInputSchema { readonly entries?: readonly ParsedInputSchemaEntry[]; readonly found: boolean; + /** + * AB4814-class grammar issues in their existing wording; a position in a + * module other than the route is qualified as `::`. + */ readonly issues: readonly string[]; + /** Present whenever the export was found and its declaration site is known. */ + readonly origin?: ResolvedSchemaOrigin; readonly properties?: readonly StaticInputSchemaProperty[]; + /** + * Present when a reference could not be followed; `entries` and + * `properties` are then absent, and `issues` holds what was judged before + * the reference was reached. + */ + readonly resolution?: InputSchemaResolutionFailure; } export type ParsedInputSchemaEntry = | Readonly<{ readonly issue: string }> | Readonly<{ readonly property: StaticInputSchemaProperty }>; -/** Flattens `z.base(...).m1(...).m2(...)` into base + ordered calls. */ -const flattenZodChain = (expression: ts.Expression): ZodChain | undefined => { +/** One route's statically projected input contract and where it is declared. */ +export interface ExtractedInputSchema { + readonly origin: ResolvedSchemaOrigin; + readonly schema: RouteInputSchema; +} + +/** + * An expression together with the reference path it is read in: every + * identifier inside it resolves from `path`, so a schema imported from + * another module reads that module's bindings, not the route's. + */ +interface Located { + readonly node: Node; + readonly path: ReferencePath; +} + +// The zod-chain grammar below is this module's own; nothing else imports it. +// Keeping it module-private also keeps `ts.*` out of the shipped declaration +// (see syntax.ts), where `typescript-5` would be unresolvable for consumers. +interface ChainCall { + readonly args: readonly ts.Expression[]; + readonly method: string; + readonly node: ts.Node; + /** Where the call and its arguments live. */ + readonly path: ReferencePath; +} + +interface ZodChain { + readonly base: ChainCall; + readonly calls: readonly ChainCall[]; +} + +/** One extraction: the route module's scope, its display path, and the resolver every reference shares. */ +interface Parser { + readonly relativePath: string; + readonly resolver: ModuleScopeResolver; + readonly root: ModuleScope; +} + +type ResolutionFailed = { readonly failure: InputSchemaResolutionFailure; readonly kind: 'failure' }; + +type Dereferenced = + | ResolutionFailed + | { readonly kind: 'expression'; readonly located: Located }; + +/** `undefined` is a value, not a binding to follow; every other identifier is a reference. */ +const isReference = (node: ts.Node): node is ts.Identifier => ts.isIdentifier(node) && node.text !== 'undefined'; + +/** + * The 1-based position every issue quotes, qualified with the module when + * the node lies outside the route module. + */ +const locate = (parser: Parser, located: Located): string => { + const position = positionOf(located.path.scope.sourceFile, located.node); + return located.path.scope === parser.root ? position : `${located.path.scope.relativePath}:${position}`; +}; + +/** + * Follows a bare identifier through the resolver to the expression it stands + * for, read in its declaring scope; any other expression stands as written + * (wrappers removed). + */ +const dereference = (located: Located, parser: Parser): Dereferenced => { + const node = unwrapExpression(located.node); + if (!isReference(node)) return { kind: 'expression', located: { node, path: located.path } }; + const resolution = parser.resolver.resolve(located.path, node.text); + switch (resolution.kind) { + case 'resolved': { + const { binding, chain, scope, visited } = resolution; + const initializer = unwrapExpression(compilerExpression(resolution.initializer)); + if (!isStaticInitializer(initializer)) { + return { + failure: { chain, kind: 'unresolved', reason: `whose initializer is ${describeExpression(initializer)}` }, + kind: 'failure', + }; + } + return { kind: 'expression', located: { node: initializer, path: { binding, chain, scope, visited } } }; + } + case 'unresolved': + return { failure: { chain: resolution.chain, kind: 'unresolved', reason: resolution.reason }, kind: 'failure' }; + case 'cycle': + return { failure: { chain: resolution.chain, kind: 'cycle' }, kind: 'failure' }; + default: { + const unreachable: never = resolution; + throw new TypeError(`Unhandled reference resolution ${String(unreachable)}.`); + } + } +}; + +type ChainOutcome = + | ResolutionFailed + | { readonly chain: ZodChain; readonly kind: 'chain' } + | { readonly kind: 'outside'; readonly located: Located }; + +/** + * Flattens `z.base(...).m1(...).m2(...)` into base + ordered calls. A chain + * may also be rooted at a reference (`shared` or `shared.optional()`): the + * referenced chain is flattened in its own scope and its calls come first, + * then the local ones. `outside` names an expression that is not a chain. + */ +const flattenZodChain = (located: Located, parser: Parser): ChainOutcome => { const calls: ChainCall[] = []; - let current = unwrapExpression(expression); + let current = unwrapExpression(located.node); while (ts.isCallExpression(current) && ts.isPropertyAccessExpression(current.expression)) { - const target = unwrapExpression(current.expression.expression); - calls.unshift({ args: current.arguments, method: current.expression.name.text, node: current }); - if (ts.isIdentifier(target) && target.text === 'z') { + calls.unshift({ args: current.arguments, method: current.expression.name.text, node: current, path: located.path }); + current = unwrapExpression(current.expression.expression); + if (ts.isIdentifier(current) && current.text === 'z') { const base = calls.shift()!; - return { base, calls }; + return { chain: { base, calls }, kind: 'chain' }; } - current = target; } - return undefined; + if (!isReference(current)) { + return { kind: 'outside', located: { node: unwrapExpression(located.node), path: located.path } }; + } + const resolved = dereference({ node: current, path: located.path }, parser); + if (resolved.kind === 'failure') return resolved; + const referenced = flattenZodChain(resolved.located, parser); + if (referenced.kind !== 'chain' || calls.length === 0) return referenced; + return { chain: { base: referenced.chain.base, calls: [...referenced.chain.calls, ...calls] }, kind: 'chain' }; }; type StaticLiteral = @@ -106,6 +260,35 @@ const staticLiteral = (expression: ts.Expression): StaticLiteral => { return { kind: 'dynamic', node }; }; +/** + * Whether a method chain (`a.b(...).c(...)`) hangs off an identifier — `z`, + * or a reference the caller dereferences — with only property accesses and + * calls between. A bare call (`build()`) does not qualify. + */ +const isMethodChain = (node: ts.Node): boolean => { + let current = node; + while (ts.isCallExpression(current)) { + const callee = unwrapExpression(current.expression); + if (!ts.isPropertyAccessExpression(callee)) return false; + current = unwrapExpression(callee.expression); + } + return ts.isIdentifier(current) || ts.isPropertyAccessExpression(current); +}; + +/** + * Whether a resolved initializer is one the grammar can read at all: a + * method chain (a zod schema, or one rooted at a further reference), an + * object or array literal, or a static literal. Anything else — a call to a + * builder, a spread, a template with substitutions, a function — is dynamic: + * the reference resolved, but to a value only execution could produce, so the + * resolver reports it with the chain rather than the grammar with a position. + */ +const isStaticInitializer = (node: ts.Expression): boolean => + isMethodChain(node) || + ts.isObjectLiteralExpression(node) || + ts.isArrayLiteralExpression(node) || + staticLiteral(node).kind === 'value'; + export const validationOnlyMethods: Readonly>> = Object.freeze({ array: new Set(['length', 'max', 'min', 'nonempty']), boolean: new Set(), @@ -131,39 +314,37 @@ export const validationOnlyMethods: Readonly(...)` call as a bounded scalar projection base. */ -const scalarBaseOf = ( - chain: ZodChain, - sourceFile: ts.SourceFile, - relativePath: string, - key: string, -): ScalarBaseResult => { - const { args, method, node } = chain.base; +const scalarBaseOf = (chain: ZodChain, parser: Parser, key: string): ScalarBaseResult => { + const { args, method, node, path } = chain.base; const reject = (detail: string): ScalarBaseResult => ({ - message: `CLI route ${relativePath} property ${JSON.stringify(key)}: ${detail} at ${positionOf(sourceFile, node)} is outside the bounded argv grammar.`, - ok: false, + kind: 'issue', + message: `CLI route ${parser.relativePath} property ${JSON.stringify(key)}: ${detail} at ${locate(parser, { node, path })} is outside the bounded argv grammar.`, }); switch (method) { case 'string': case 'number': case 'boolean': { if (args.length > 0) return reject(`z.${method} with arguments`); - return { base: { kind: method }, ok: true }; + return { base: { kind: method }, kind: 'base' }; } case 'url': { if (args.length > 0) return reject('z.url with arguments'); - return { base: { kind: 'string' }, ok: true }; + return { base: { kind: 'string' }, kind: 'base' }; } case 'enum': { - const argument = args.length === 1 ? unwrapExpression(args[0]!) : undefined; - if (argument === undefined || !ts.isArrayLiteralExpression(argument)) { - return reject('z.enum without one array-literal argument'); - } + if (args.length !== 1) return reject('z.enum without one array-literal argument'); + // `z.enum(requestStatuses)` reads the referenced `as const` array in its own module. + const argument = dereference({ node: args[0]!, path }, parser); + if (argument.kind === 'failure') return argument; + const members = argument.located.node; + if (!ts.isArrayLiteralExpression(members)) return reject('z.enum without one array-literal argument'); const choices: string[] = []; - for (const element of argument.elements) { + for (const element of members.elements) { const literal = unwrapExpression(element as ts.Expression); if (!ts.isStringLiteral(literal) && !ts.isNoSubstitutionTemplateLiteral(literal)) { return reject('a non-string-literal z.enum member'); @@ -171,7 +352,7 @@ const scalarBaseOf = ( choices.push(literal.text); } if (choices.length === 0) return reject('an empty z.enum'); - return { base: { choices, kind: 'enum' }, ok: true }; + return { base: { choices, kind: 'enum' }, kind: 'base' }; } default: return reject(`the zod base z.${method}`); @@ -184,47 +365,45 @@ const validationOnlyChain = ( ): ChainCall | undefined => calls.find((call) => !validationOnlyMethods[kind].has(call.method)); type PropertyProjection = - | { readonly issue: string } - | { readonly property: StaticInputSchemaProperty }; + | ResolutionFailed + | { readonly issue: string; readonly kind: 'issue' } + | { readonly kind: 'property'; readonly property: StaticInputSchemaProperty }; -const projectProperty = ( - key: string, - initializer: ts.Expression, - sourceFile: ts.SourceFile, - relativePath: string, -): PropertyProjection => { - const reject = (detail: string, node: ts.Node): PropertyProjection => ({ - issue: `CLI route ${relativePath} property ${JSON.stringify(key)}: ${detail} at ${positionOf(sourceFile, node)} is outside the bounded argv grammar.`, +const projectProperty = (key: string, initializer: Located, parser: Parser): PropertyProjection => { + const reject = (detail: string, at: Located): PropertyProjection => ({ + issue: `CLI route ${parser.relativePath} property ${JSON.stringify(key)}: ${detail} at ${locate(parser, at)} is outside the bounded argv grammar.`, + kind: 'issue', }); - const chain = flattenZodChain(initializer); - if (chain === undefined) { - const node = unwrapExpression(initializer); - const description = ts.isIdentifier(node) - ? `a reference to the identifier ${JSON.stringify(node.text)}` - : 'an expression outside the z.(...) chain form'; - return reject(description, node); + const flattened = flattenZodChain(initializer, parser); + if (flattened.kind === 'failure') return flattened; + if (flattened.kind === 'outside') { + return reject('an expression outside the z.(...) chain form', flattened.located); } + const { chain } = flattened; let base: ScalarBase; let repeated = false; if (chain.base.method === 'array') { repeated = true; - const argument = chain.base.args.length === 1 ? chain.base.args[0]! : undefined; - const element = argument === undefined ? undefined : flattenZodChain(argument); - if (element === undefined) return reject('z.array without one z.(...) chain argument', chain.base.node); - const scalar = scalarBaseOf(element, sourceFile, relativePath, key); - if (!scalar.ok) return { issue: scalar.message }; + const invalidArray = (): PropertyProjection => + reject('z.array without one z.(...) chain argument', chain.base); + if (chain.base.args.length !== 1) return invalidArray(); + const element = flattenZodChain({ node: chain.base.args[0]!, path: chain.base.path }, parser); + if (element.kind === 'failure') return element; + if (element.kind === 'outside') return invalidArray(); + const scalar = scalarBaseOf(element.chain, parser, key); + if (scalar.kind !== 'base') return scalar.kind === 'issue' ? { issue: scalar.message, kind: 'issue' } : scalar; if (scalar.base.kind === 'boolean') { - return reject('z.array of z.boolean cannot be projected onto argv;', chain.base.node); + return reject('z.array of z.boolean cannot be projected onto argv;', chain.base); } - const invalidElementCall = validationOnlyChain(element.calls, scalar.base.kind); + const invalidElementCall = validationOnlyChain(element.chain.calls, scalar.base.kind); if (invalidElementCall !== undefined) { - return reject(`the array-element method .${invalidElementCall.method}()`, invalidElementCall.node); + return reject(`the array-element method .${invalidElementCall.method}()`, invalidElementCall); } base = scalar.base; } else { - const scalar = scalarBaseOf(chain, sourceFile, relativePath, key); - if (!scalar.ok) return { issue: scalar.message }; + const scalar = scalarBaseOf(chain, parser, key); + if (scalar.kind !== 'base') return scalar.kind === 'issue' ? { issue: scalar.message, kind: 'issue' } : scalar; base = scalar.base; } @@ -235,32 +414,36 @@ const projectProperty = ( const validationKind = repeated ? 'array' : base.kind; for (const call of chain.calls) { if (call.method === 'optional') { - if (call.args.length > 0) return reject('.optional() with arguments', call.node); + if (call.args.length > 0) return reject('.optional() with arguments', call); optional = true; continue; } if (call.method === 'default') { - const argument = call.args.length === 1 ? staticLiteral(call.args[0]!) : undefined; - if (argument === undefined || argument.kind === 'dynamic') { - return reject('.default() without one static literal argument', call.node); - } - defaultValue = argument.value; + const invalidDefault = (): PropertyProjection => reject('.default() without one static literal argument', call); + if (call.args.length !== 1) return invalidDefault(); + // `.default(defaultStatus)` reads the referenced literal in its own module. + const argument = dereference({ node: call.args[0]!, path: call.path }, parser); + if (argument.kind === 'failure') return argument; + const literal = staticLiteral(argument.located.node); + if (literal.kind === 'dynamic') return invalidDefault(); + defaultValue = literal.value; hasDefault = true; continue; } if (call.method === 'describe') { const argument = call.args.length === 1 ? unwrapExpression(call.args[0]!) : undefined; if (argument === undefined || (!ts.isStringLiteral(argument) && !ts.isNoSubstitutionTemplateLiteral(argument))) { - return reject('.describe() without one string-literal argument', call.node); + return reject('.describe() without one string-literal argument', call); } description = argument.text; continue; } if (validationOnlyMethods[validationKind].has(call.method)) continue; - return reject(`the method .${call.method}()`, call.node); + return reject(`the method .${call.method}()`, call); } return { + kind: 'property', property: { base, ...(hasDefault ? { defaultValue } : {}), @@ -307,30 +490,19 @@ const findInputSchemaExport = (sourceFile: ts.SourceFile): InputSchemaExportSite return undefined; }; -/** - * Parses the shared bounded input-schema grammar. Issues intentionally retain - * the existing CLI diagnostic wording; non-CLI projection simply ignores them. - */ -export const parseInputSchema = (moduleText: string, relativePath: string): ParsedInputSchema => { - const sourceFile = ts.createSourceFile(relativePath, moduleText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); - const site = findInputSchemaExport(sourceFile); - if (site === undefined) return { found: false, issues: [] }; - if (site.initializer === undefined) { - return { - found: true, - issues: [ - `CLI route ${relativePath} exports inputSchema through ${site.rejection!}; only a single top-level \`export const inputSchema = z.object({ ... })\` declaration is projected onto argv.`, - ], - }; - } +type ParsedShape = Pick; - const chain = flattenZodChain(site.initializer); - const objectBase = chain !== undefined && (chain.base.method === 'object' || chain.base.method === 'strictObject') - ? chain +/** Parses the declared schema expression — `z.object({ ... })` and its properties — in the scope that declares it. */ +const parseObjectSchema = (declared: Located, parser: Parser): ParsedShape => { + const { relativePath } = parser; + const flattened = flattenZodChain(declared, parser); + if (flattened.kind === 'failure') return { issues: [], resolution: flattened.failure }; + const objectBase = flattened.kind === 'chain' && + (flattened.chain.base.method === 'object' || flattened.chain.base.method === 'strictObject') + ? flattened.chain : undefined; if (objectBase === undefined) { return { - found: true, issues: [ `CLI route ${relativePath} has an inputSchema outside the argv grammar: the top level must be z.object({ ... }) or z.strictObject({ ... }).`, ], @@ -339,28 +511,29 @@ export const parseInputSchema = (moduleText: string, relativePath: string): Pars const invalidTopLevelCall = objectBase.calls.find((call) => call.method !== 'strict'); if (invalidTopLevelCall !== undefined) { return { - found: true, - issues: [ - `CLI route ${relativePath} has an inputSchema outside the argv grammar: the top-level method .${invalidTopLevelCall.method}() at ${positionOf(sourceFile, invalidTopLevelCall.node)} is not supported.`, - ], - }; - } - const shape = objectBase.base.args.length === 1 ? unwrapExpression(objectBase.base.args[0]!) : undefined; - if (shape === undefined || !ts.isObjectLiteralExpression(shape)) { - return { - found: true, issues: [ - `CLI route ${relativePath} has an inputSchema outside the argv grammar: z.${objectBase.base.method} requires one object-literal argument.`, + `CLI route ${relativePath} has an inputSchema outside the argv grammar: the top-level method .${invalidTopLevelCall.method}() at ${locate(parser, invalidTopLevelCall)} is not supported.`, ], }; } + const invalidShape: ParsedShape = { + issues: [ + `CLI route ${relativePath} has an inputSchema outside the argv grammar: z.${objectBase.base.method} requires one object-literal argument.`, + ], + }; + if (objectBase.base.args.length !== 1) return invalidShape; + // `z.object(shape)` reads the referenced object literal in its own module. + const shapeArgument = dereference({ node: objectBase.base.args[0]!, path: objectBase.base.path }, parser); + if (shapeArgument.kind === 'failure') return { issues: [], resolution: shapeArgument.failure }; + const shape = shapeArgument.located; + if (!ts.isObjectLiteralExpression(shape.node)) return invalidShape; const issues: string[] = []; const entries: ParsedInputSchemaEntry[] = []; const properties: StaticInputSchemaProperty[] = []; - for (const property of shape.properties) { + for (const property of shape.node.properties) { if (!ts.isPropertyAssignment(property)) { - const issue = `CLI route ${relativePath} has an inputSchema property outside the argv grammar at ${positionOf(sourceFile, property)}; use plain \`key: z...\` property assignments.`; + const issue = `CLI route ${relativePath} has an inputSchema property outside the argv grammar at ${locate(parser, { node: property, path: shape.path })}; use plain \`key: z...\` property assignments.`; issues.push(issue); entries.push({ issue }); continue; @@ -369,21 +542,68 @@ export const parseInputSchema = (moduleText: string, relativePath: string): Pars ? property.name.text : undefined; if (name === undefined) { - const issue = `CLI route ${relativePath} has a computed inputSchema property name at ${positionOf(sourceFile, property.name)}; property names must be identifiers or string literals.`; + const issue = `CLI route ${relativePath} has a computed inputSchema property name at ${locate(parser, { node: property.name, path: shape.path })}; property names must be identifiers or string literals.`; issues.push(issue); entries.push({ issue }); continue; } - const projected = projectProperty(name, property.initializer, sourceFile, relativePath); - if ('issue' in projected) { - issues.push(projected.issue); - entries.push({ issue: projected.issue }); - } else { - properties.push(projected.property); - entries.push({ property: projected.property }); + const projected = projectProperty(name, { node: property.initializer, path: shape.path }, parser); + switch (projected.kind) { + case 'failure': + return { issues, resolution: projected.failure }; + case 'issue': + issues.push(projected.issue); + entries.push({ issue: projected.issue }); + break; + case 'property': + properties.push(projected.property); + entries.push({ property: projected.property }); + break; + default: { + const unreachable: never = projected; + throw new TypeError(`Unhandled property projection ${String(unreachable)}.`); + } } } - return { entries, found: true, issues, properties }; + return { entries, issues, properties }; +}; + +/** + * Parses the shared bounded input-schema grammar. The `inputSchema` + * initializer may be a reference: it is followed through same-module + * `const` aliases and named relative imports (module-scope.ts) to the + * declaring expression, which is then read in its own module's scope, so a + * schema shared through `src/lib` projects exactly as the inline form. Issues + * intentionally retain the existing CLI diagnostic wording; non-CLI + * projection simply ignores them. + */ +export const parseInputSchema = ( + moduleText: string, + relativePath: string, + options: InputSchemaExtractionOptions = {}, +): ParsedInputSchema => { + const resolver = createModuleScopeResolver(options); + const root = resolver.scopeOf(moduleText, relativePath, options.source); + const site = findInputSchemaExport(compilerSourceFile(root.sourceFile)); + if (site === undefined) return { found: false, issues: [] }; + if (site.initializer === undefined) { + return { + found: true, + issues: [ + `CLI route ${relativePath} exports inputSchema through ${site.rejection!}; only a single top-level \`export const inputSchema = z.object({ ... })\` declaration is projected onto argv.`, + ], + }; + } + + const parser: Parser = { relativePath, resolver, root }; + // The declaration site is the end of the alias chain from the export. + const declared = dereference({ node: site.initializer, path: rootReferencePath(root, 'inputSchema') }, parser); + if (declared.kind === 'failure') return { found: true, issues: [], resolution: declared.failure }; + const origin: ResolvedSchemaOrigin = { + binding: declared.located.path.binding, + module: declared.located.path.scope.relativePath, + }; + return { ...parseObjectSchema(declared.located, parser), found: true, origin }; }; const inputSchemaLiteral = (value: unknown): value is RouteInputSchemaLiteral => { @@ -411,13 +631,20 @@ const scalarSchema = (base: ScalarBase): RouteInputArrayItemSchema => { } }; -/** Statically projects a route module without ever importing or executing it. */ +/** + * Statically projects a route module without ever importing or executing it. + * `undefined` when the export is absent, a reference cannot be followed, or + * the schema leaves the grammar: non-CLI routes stay silent either way. + */ export const extractInputSchema = ( moduleText: string, relativePath: string, -): RouteInputSchema | undefined => { - const parsed = parseInputSchema(moduleText, relativePath); - if (!parsed.found || parsed.issues.length > 0 || parsed.properties === undefined) return undefined; + options: InputSchemaExtractionOptions = {}, +): ExtractedInputSchema | undefined => { + const parsed = parseInputSchema(moduleText, relativePath, options); + if (!parsed.found || parsed.issues.length > 0 || parsed.properties === undefined || parsed.origin === undefined) { + return undefined; + } if (parsed.properties.some((property) => property.hasDefault && !inputSchemaLiteral(property.defaultValue))) return undefined; @@ -434,9 +661,12 @@ export const extractInputSchema = ( if (!property.optional && !property.hasDefault) required.push(property.key); } return deepFreeze({ - additionalProperties: false, - properties, - ...(required.length === 0 ? {} : { required }), - type: 'object', + origin: parsed.origin, + schema: { + additionalProperties: false, + properties, + ...(required.length === 0 ? {} : { required }), + type: 'object', + }, }); }; diff --git a/packages/agent-bundle/src/routes/module-scope.ts b/packages/agent-bundle/src/routes/module-scope.ts new file mode 100644 index 000000000..a1264b784 --- /dev/null +++ b/packages/agent-bundle/src/routes/module-scope.ts @@ -0,0 +1,398 @@ +import { dirname } from 'node:path'; + +// Aliased for the same reason as config-extract.ts: this is a parser-only use +// of the TypeScript 5.x compiler API, bundled into the package (#381). +import ts from 'typescript-5'; + +import { isInside, toPosixRelative } from '../core/paths.ts'; +import { isRelativeSpecifier, moduleCandidates, readModuleFromDisk } from './module-candidates.ts'; +import { + hasExportModifier, + unwrapExpression, + type SyntaxNode, + type SyntaxSourceFile, + type SyntaxStatement, +} from './syntax.ts'; + +/** + * The static module-scope model every extractor that follows an identifier + * shares: which top-level bindings a parsed module declares, and a resolver + * that walks a reference from its use site through same-module `const` + * aliases and named relative imports to the expression that declares it. + * Modules are parsed, never executed, so only what the source text states is + * known. Like syntax.ts, the exported signatures name structural node slices + * rather than `ts.*` types: `typescript-5` is a devDependency the package + * bundles and consumers never install, so a shipped declaration must not + * import it. Every slice handed out here is a compiler node; callers narrow + * it back with one documented cast. + */ + +/** The structural slice of a parsed module (`ts.SourceFile`) the scope model exposes. */ +export interface ModuleSourceFile extends SyntaxNode, SyntaxSourceFile { + readonly fileName: string; + readonly statements: readonly SyntaxStatement[]; + readonly text: string; +} + +/** One top-level `const` declaration of a module. */ +export interface ConstBinding { + readonly exported: boolean; + /** The declared initializer; absent for an initializer-less `declare const`. */ + readonly initializer?: SyntaxNode; +} + +/** One `import { name as local } from ''` binding of a module. */ +export interface ImportedBinding { + readonly importedName: string; + readonly specifier: string; +} + +/** The top-level bindings of one parsed module a reference may name. */ +export interface ModuleScope { + /** Top-level `const` declarations by local name. */ + readonly consts: ReadonlyMap; + /** Named value imports by local name; type-only imports are not bindings at run time. */ + readonly imports: ReadonlyMap; + /** + * Local names that are known but never static — bound by `let`/`var`, a + * destructuring pattern, a function, class, enum, or namespace, a default + * import, or a namespace import — each described for the diagnostic. + */ + readonly nonConst: ReadonlyMap; + /** + * How chains, origins, and qualified positions name the module: + * project-relative POSIX when the project root is known and the module lies + * inside it, else the path as read (a caller-supplied relative path for the + * module the extraction started from). + */ + readonly relativePath: string; + /** Absolute module path; absent when only a relative path is known, so relative imports cannot be followed. */ + readonly source?: string; + readonly sourceFile: ModuleSourceFile; +} + +/** + * One point on a reference path: the binding whose initializer is being read, + * the scope that declares it, the printable chain that reached it (the root + * binding first; a later step is `` when declared in the same module + * as the previous one, else ` ()`), and every + * `#` visited on the way — reaching one again is a cycle. + */ +export interface ReferencePath { + readonly binding: string; + readonly chain: readonly string[]; + readonly scope: ModuleScope; + readonly visited: ReadonlySet; +} + +/** Which static boundary stopped a resolution. */ +export type ReferenceBoundary = + | 'bare-specifier' + | 'missing-export' + | 'no-initializer' + | 'no-source' + | 'non-const' + | 'outside-project' + | 'unknown' + | 'unreadable'; + +/** + * Where a reference ends. `resolved` is itself a {@link ReferencePath} for the + * declaring binding, so a caller reading the initializer resolves the + * identifiers inside it from there. `unresolved` names the boundary in a + * phrase that follows the chain's last step (`imported from "x", which ...`, + * `which is ...`, `whose ...`); `cycle` ends the chain with the step visited + * twice. + */ +export type ReferenceResolution = + | (ReferencePath & { readonly initializer: SyntaxNode; readonly kind: 'resolved' }) + | { + readonly boundary: ReferenceBoundary; + readonly chain: readonly string[]; + readonly kind: 'unresolved'; + readonly reason: string; + } + | { readonly chain: readonly string[]; readonly kind: 'cycle' }; + +export interface ModuleScopeResolverOptions { + /** + * Absolute project root. A relative import that resolves outside it is not + * project source and is rejected. Unset means unconstrained (tests). + */ + readonly projectRoot?: string; + /** + * Reads one module's text by absolute path; `undefined` when the path is + * not a readable file. Defaults to a synchronous filesystem read. + */ + readonly readModule?: (path: string) => string | undefined; +} + +/** Scopes modules on demand — each file read and parsed once per extraction — and resolves references through them. */ +export interface ModuleScopeResolver { + /** The scope of the module at an absolute path, read through `readModule`; undefined when unreadable. Misses are cached too. */ + readonly load: (path: string) => ModuleScope | undefined; + /** Resolves `name` as written in the initializer of `from`. */ + readonly resolve: (from: ReferencePath, name: string) => ReferenceResolution; + /** + * Scopes already-read module text. Registered under `source` when known so + * a relative import that leads back to the module shares its scope. + */ + readonly scopeOf: (text: string, relativePath: string, source?: string) => ModuleScope; +} + +const scriptKindOf = (path: string): ts.ScriptKind => { + if (path.endsWith('.tsx')) return ts.ScriptKind.TSX; + if (path.endsWith('.jsx')) return ts.ScriptKind.JSX; + if (path.endsWith('.js') || path.endsWith('.mjs') || path.endsWith('.cjs')) return ts.ScriptKind.JS; + return ts.ScriptKind.TS; +}; + +/** + * Parses one module's text with the script kind its extension implies (TSX + * for `.tsx`, JSX for `.jsx`, JS for `.js`/`.mjs`/`.cjs`, TS otherwise), so a + * `.ts` generic arrow and a `.tsx` element both parse as written. The module + * is never executed. + */ +export const parseModule = (path: string, text: string): ModuleSourceFile => + ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true, scriptKindOf(path)); + +/** Names one rejected construct for a diagnostic (AB4806, AB4838). */ +export const describeExpression = (node: SyntaxNode): string => { + const expression = node as ts.Node; + if (ts.isIdentifier(expression)) { + return expression.text === 'undefined' + ? 'the non-JSON value `undefined`' + : `a reference to the identifier ${JSON.stringify(expression.text)}`; + } + if (ts.isCallExpression(expression)) return 'a call expression'; + if (ts.isTemplateExpression(expression)) return 'a template literal with substitutions'; + if (ts.isArrowFunction(expression) || ts.isFunctionExpression(expression)) return 'a function expression'; + if (ts.isSpreadAssignment(expression) || ts.isSpreadElement(expression)) return 'a spread'; + if (ts.isShorthandPropertyAssignment(expression)) return 'a shorthand property reference'; + if ( + ts.isMethodDeclaration(expression) || + ts.isGetAccessorDeclaration(expression) || + ts.isSetAccessorDeclaration(expression) + ) { + return 'a method or accessor'; + } + if (ts.isComputedPropertyName(expression)) return 'a computed property name'; + if (ts.isOmittedExpression(expression)) return 'an array hole'; + if (expression.kind === ts.SyntaxKind.BigIntLiteral) return 'a bigint literal'; + if (expression.kind === ts.SyntaxKind.RegularExpressionLiteral) return 'a regular expression literal'; + return `a ${ts.SyntaxKind[expression.kind] ?? 'dynamic'} expression`; +}; + +const collectBindingNames = (name: ts.BindingName, description: string, into: Map): void => { + if (ts.isIdentifier(name)) { + into.set(name.text, description); + return; + } + for (const element of name.elements) { + if (!ts.isOmittedExpression(element)) collectBindingNames(element.name, description, into); + } +}; + +const scopeOfSourceFile = (sourceFile: ts.SourceFile, relativePath: string, source: string | undefined): ModuleScope => { + const consts = new Map(); + const imports = new Map(); + const nonConst = new Map(); + for (const statement of sourceFile.statements) { + if (ts.isVariableStatement(statement)) { + const isConst = (statement.declarationList.flags & ts.NodeFlags.Const) !== 0; + const exported = hasExportModifier(statement); + for (const declaration of statement.declarationList.declarations) { + if (isConst && ts.isIdentifier(declaration.name)) { + consts.set(declaration.name.text, { + exported, + ...(declaration.initializer === undefined ? {} : { initializer: declaration.initializer }), + }); + } else { + collectBindingNames( + declaration.name, + isConst ? 'a destructuring declaration' : 'a `let`/`var` declaration', + nonConst, + ); + } + } + continue; + } + if (ts.isImportDeclaration(statement)) { + const clause = statement.importClause; + if (clause === undefined || !ts.isStringLiteral(statement.moduleSpecifier)) continue; + const specifier = statement.moduleSpecifier.text; + if (clause.name !== undefined) nonConst.set(clause.name.text, 'a default import'); + const bindings = clause.namedBindings; + if (bindings === undefined) continue; + if (ts.isNamespaceImport(bindings)) { + nonConst.set(bindings.name.text, 'a namespace import'); + continue; + } + for (const element of bindings.elements) { + if (clause.isTypeOnly || element.isTypeOnly) continue; + const importedName = element.propertyName?.text ?? element.name.text; + imports.set(element.name.text, { importedName, specifier }); + } + continue; + } + if (ts.isFunctionDeclaration(statement) && statement.name !== undefined) { + nonConst.set(statement.name.text, 'a function declaration'); + } else if (ts.isClassDeclaration(statement) && statement.name !== undefined) { + nonConst.set(statement.name.text, 'a class declaration'); + } else if (ts.isEnumDeclaration(statement)) { + nonConst.set(statement.name.text, 'an enum declaration'); + } else if (ts.isModuleDeclaration(statement) && ts.isIdentifier(statement.name)) { + nonConst.set(statement.name.text, 'a namespace declaration'); + } + } + return { + consts, + imports, + nonConst, + relativePath, + ...(source === undefined ? {} : { source }), + sourceFile, + }; +}; + +const bindingKey = (scope: ModuleScope, binding: string): string => `${scope.source ?? scope.relativePath}#${binding}`; + +/** The path a resolution starts from: the root binding (`inputSchema`, `config`) in the module that declares it. */ +export const rootReferencePath = (scope: ModuleScope, binding: string): ReferencePath => ({ + binding, + chain: [binding], + scope, + visited: new Set([bindingKey(scope, binding)]), +}); + +type ImportTarget = + | { readonly scope: ModuleScope } + | { readonly boundary: ReferenceBoundary; readonly reason: string }; + +/** + * One extraction's resolver: every module it reaches is read and parsed once + * (misses included), and every reference path shares that cache. + */ +export const createModuleScopeResolver = (options: ModuleScopeResolverOptions = {}): ModuleScopeResolver => { + const read = options.readModule ?? readModuleFromDisk; + const { projectRoot } = options; + const scopes = new Map(); + + const load = (path: string): ModuleScope | undefined => { + if (scopes.has(path)) return scopes.get(path); + const text = read(path); + const relativePath = projectRoot !== undefined && isInside(projectRoot, path) + ? toPosixRelative(projectRoot, path) + : path; + const scope = text === undefined + ? undefined + : scopeOfSourceFile(parseModule(path, text) as ts.SourceFile, relativePath, path); + scopes.set(path, scope); + return scope; + }; + + const scopeOf = (text: string, relativePath: string, source?: string): ModuleScope => { + const scope = scopeOfSourceFile(parseModule(source ?? relativePath, text) as ts.SourceFile, relativePath, source); + if (source !== undefined) scopes.set(source, scope); + return scope; + }; + + /** + * The module one relative import names, with the shared candidate order, + * rejected when the specifier is not relative, when the importing module's + * location is unknown, when any candidate resolves outside the project, or + * when no candidate reads. + */ + const followImport = (from: ModuleScope, binding: ImportedBinding): ImportTarget => { + const imported = `imported from ${JSON.stringify(binding.specifier)}`; + if (!isRelativeSpecifier(binding.specifier)) { + return { boundary: 'bare-specifier', reason: `${imported}, which is not a relative module path` }; + } + if (from.source === undefined) { + return { + boundary: 'no-source', + reason: `${imported}, which cannot be followed because the importing module's source path is unknown`, + }; + } + for (const candidate of moduleCandidates(dirname(from.source), binding.specifier)) { + if (projectRoot !== undefined && !isInside(projectRoot, candidate)) { + return { boundary: 'outside-project', reason: `${imported}, which resolves outside the project` }; + } + const scope = load(candidate); + if (scope !== undefined) return { scope }; + } + return { + boundary: 'unreadable', + reason: `${imported}, which does not resolve to a module inside the project`, + }; + }; + + const resolve = (from: ReferencePath, name: string): ReferenceResolution => { + const chain = [...from.chain]; + const visited = new Set(from.visited); + let scope = from.scope; + let current = name; + for (;;) { + let declaring = scope; + let binding = current; + let declaration = scope.consts.get(current); + if (declaration === undefined) { + const imported = scope.imports.get(current); + if (imported === undefined) { + const description = scope.nonConst.get(current); + return description === undefined + ? { + boundary: 'unknown', + chain: [...chain, current], + kind: 'unresolved', + reason: 'which is neither a top-level const in this module nor a named import from a relative module', + } + : { + boundary: 'non-const', + chain: [...chain, current], + kind: 'unresolved', + reason: `which is not a top-level \`const\` but ${description}`, + }; + } + const target = followImport(scope, imported); + if (!('scope' in target)) { + return { boundary: target.boundary, chain: [...chain, current], kind: 'unresolved', reason: target.reason }; + } + declaring = target.scope; + binding = imported.importedName; + declaration = declaring.consts.get(binding); + if (declaration === undefined || !declaration.exported) { + return { + boundary: 'missing-export', + chain: [...chain, current], + kind: 'unresolved', + reason: `imported from ${JSON.stringify(imported.specifier)}, which does not declare a top-level \`export const ${binding}\``, + }; + } + } + chain.push(declaring === scope ? binding : `${binding} (${declaring.relativePath})`); + const key = bindingKey(declaring, binding); + if (visited.has(key)) return { chain, kind: 'cycle' }; + visited.add(key); + if (declaration.initializer === undefined) { + return { + boundary: 'no-initializer', + chain, + kind: 'unresolved', + reason: 'whose declaration has no initializer', + }; + } + // An alias (`export const a = b`) hops on; anything else is the + // expression the reference stands for, judged by the caller. + const initializer = unwrapExpression(declaration.initializer) as ts.Node; + if (ts.isIdentifier(initializer) && initializer.text !== 'undefined') { + scope = declaring; + current = initializer.text; + continue; + } + return { binding, chain, initializer: declaration.initializer, kind: 'resolved', scope: declaring, visited }; + } + }; + + return { load, resolve, scopeOf }; +}; diff --git a/packages/agent-bundle/src/routes/types.ts b/packages/agent-bundle/src/routes/types.ts index 77cc8f011..4182d8de0 100644 --- a/packages/agent-bundle/src/routes/types.ts +++ b/packages/agent-bundle/src/routes/types.ts @@ -87,14 +87,48 @@ export interface RouteInputSchema { readonly type: 'object'; } +/** + * Where a contract's schema is declared: the module and the binding whose + * initializer is the schema expression, at the end of any alias chain. + */ +export interface RouteContractOrigin { + /** The declaring binding: `statusInputSchema`; `inputSchema` for a route-local literal. */ + readonly binding: string; + /** Project-relative POSIX path of the declaring module, e.g. `src/lib/protocol-schemas.ts`. */ + readonly module: string; +} + +/** + * One canonical input contract of the Application IR (#592 §1): a route's + * `inputSchema` declaration, normalized once into the bounded JSON Schema + * subset and shared by every route that binds the same declared schema. + * Identity is the declaration site, so two routes importing one binding + * share one contract while a route-local literal is + * `contract:#inputSchema`. Routes reference a contract + * by {@link CompiledAgentRoute.contract}; projections consume it — the + * routed CLI derives its argv grammar from `input`, generated route types + * and the Workbench read it — instead of re-reading the route module. + */ +export interface RouteContract { + /** `contract:#`. */ + readonly id: string; + /** Deep-frozen; the same object as each bound route's {@link CompiledAgentRoute.inputSchema}. */ + readonly input: RouteInputSchema; + readonly origin: RouteContractOrigin; + /** Sorted ids of the graph routes bound to this contract. */ + readonly routes: readonly string[]; +} + /** One conventional route module compiled into the immutable route graph. */ export interface CompiledAgentRoute { /** Statically extracted from the module's `export const config` declaration; {@link emptyRouteConfig} when absent or rejected. */ readonly config: Readonly>; + /** Id of the {@link RouteContract} this route binds; absent when no static contract was extracted. */ + readonly contract?: string; /** Canonical event identity; present only when {@link kind} is `event-route`. */ readonly event?: CanonicalAgentEvent; readonly id: string; - /** Statically projected bounded JSON Schema subset; absent for missing or richer input schemas. */ + /** Statically projected bounded JSON Schema subset — the bound contract's `input` object; absent for missing or richer input schemas. */ readonly inputSchema?: RouteInputSchema; readonly kind: CompiledRouteKind; readonly provenance: RouteProvenance; @@ -240,6 +274,8 @@ export interface CompiledCliSurface { */ export interface CompiledRouteGraph { readonly cli?: CompiledCliSurface; + /** Sorted by id; absent when no route has a static contract, so pre-#593 graphs digest unchanged. */ + readonly contracts?: readonly RouteContract[]; readonly diagnostics: readonly Diagnostic[]; /** sha256 over the graph's project-relative identity. */ readonly digest: string; diff --git a/packages/agent-bundle/tests/cli-routes.test.ts b/packages/agent-bundle/tests/cli-routes.test.ts index 11c929284..710cb5ee9 100644 --- a/packages/agent-bundle/tests/cli-routes.test.ts +++ b/packages/agent-bundle/tests/cli-routes.test.ts @@ -95,7 +95,6 @@ describe('static argv projection (bounded zod grammar)', () => { }); it.each([ - ['a shared schema identifier', 'z.object({ root: pathSchema })', 'pathSchema'], ['a union', 'z.object({ mode: z.union([z.string(), z.number()]) })', 'z.union'], ['a nested object', 'z.object({ nested: z.object({ a: z.string() }) })', 'z.object'], ['a transform', 'z.object({ root: z.string().transform((value) => value) })', '.transform()'], @@ -112,6 +111,53 @@ describe('static argv projection (bounded zod grammar)', () => { expect(extracted.options).toBeUndefined(); }); + it('rejects an unresolvable identifier reference with AB4838 naming the chain and reason', () => { + const extracted = extract('z.object({ root: pathSchema })'); + expect(codesOf(extracted.diagnostics)).toEqual(['AB4838']); + expect(extracted.diagnostics[0]).toMatchObject({ + severity: 'error', + sourcePath: '/project/src/cli/example.ts', + }); + expect(extracted.diagnostics[0]!.message).toContain('inputSchema -> pathSchema'); + expect(extracted.diagnostics[0]!.message).toContain( + 'which is neither a top-level const in this module nor a named import from a relative module', + ); + expect(extracted.diagnostics[0]!.recovery).toContain('relative'); + expect(extracted.diagnostics[0]!.recovery).toContain('export const'); + expect(extracted.diagnostics[0]!.recovery).toContain('inspect again'); + expect(extracted.options).toBeUndefined(); + }); + + it('rejects a cyclic inputSchema reference with AB4839', () => { + const files = new Map([ + ['/project/src/lib/a.ts', "import { y } from './b.js';\nexport const x = y;\n"], + ['/project/src/lib/b.ts', "import { x } from './a.js';\nexport const y = x;\n"], + ]); + const extracted = extractCliArgv( + "import { x } from '../lib/a.js';\nexport const inputSchema = x;\n", + 'src/cli/example.ts', + '/project/src/cli/example.ts', + { + projectRoot: '/project', + readModule: (path) => files.get(path), + source: '/project/src/cli/example.ts', + }, + ); + expect(codesOf(extracted.diagnostics)).toEqual(['AB4839']); + expect(extracted.diagnostics[0]).toMatchObject({ + severity: 'error', + sourcePath: '/project/src/cli/example.ts', + }); + expect(extracted.diagnostics[0]!.message).toContain( + 'inputSchema -> x (src/lib/a.ts) -> y (src/lib/b.ts) -> x (src/lib/a.ts)', + ); + expect(extracted.diagnostics[0]!.message).toContain('is a reference cycle.'); + expect(extracted.diagnostics[0]!.recovery).toContain('relative'); + expect(extracted.diagnostics[0]!.recovery).toContain('export const'); + expect(extracted.diagnostics[0]!.recovery).toContain('inspect again'); + expect(extracted.options).toBeUndefined(); + }); + it('rejects required booleans, reserved options, and kebab-case collisions', () => { const requiredBoolean = extract('z.object({ strict: z.boolean() })'); expect(codesOf(requiredBoolean.diagnostics)).toEqual(['AB4814']); @@ -203,6 +249,42 @@ describe('compiled command graph', () => { expect(Object.isFrozen(graph.cli!.commands)).toBe(true); }); + it('projects an imported schema onto the same CompiledCliOption[] as its inline twin', async () => { + const schema = [ + 'z.object({', + ' limit: z.number().int().min(1).optional(),', + ' name: z.string().min(1),', + '}).strict()', + ].join('\n'); + const commandModule = (inputSchema: string): string => [ + `export const inputSchema = ${inputSchema};`, + 'export const resultSchema = {};', + 'export default async () => undefined;', + '', + ].join('\n'); + const inlineRoot = await createRoot(); + await writeTree(inlineRoot, { + 'src/cli/status.ts': commandModule(schema), + }); + const importedRoot = await createRoot(); + await writeTree(importedRoot, { + 'src/cli/status.ts': commandModule('statusInputSchema').replace( + 'export const inputSchema', + "import { statusInputSchema } from '../lib/protocol-schemas.js';\nexport const inputSchema", + ), + 'src/lib/protocol-schemas.ts': `export const statusInputSchema = ${schema};\n`, + }); + const inline = await compileRouteGraph(inlineRoot, fixtureConfig()); + const imported = await compileRouteGraph(importedRoot, fixtureConfig()); + expect(inline.diagnostics).toEqual([]); + expect(imported.diagnostics).toEqual([]); + expect(imported.cli?.commands?.[0]?.options).toEqual(inline.cli?.commands?.[0]?.options); + expect(imported.cli?.commands?.[0]?.options).toEqual([ + { key: 'limit', kind: 'number', option: 'limit', repeated: false, required: false }, + { key: 'name', kind: 'string', option: 'name', repeated: false, required: true }, + ]); + }); + it('errors with AB4813 when a command path is both a module and a group', async () => { const root = await createRoot(); await writeTree(root, { diff --git a/packages/agent-bundle/tests/input-schema.test.ts b/packages/agent-bundle/tests/input-schema.test.ts index 36d370221..fd77f8ec6 100644 --- a/packages/agent-bundle/tests/input-schema.test.ts +++ b/packages/agent-bundle/tests/input-schema.test.ts @@ -1,11 +1,16 @@ import { expect, it } from '@rstest/core'; -import { extractInputSchema } from '../src/routes/input-schema.ts'; +import { extractInputSchema, parseInputSchema } from '../src/routes/input-schema.ts'; -const extract = (schema: string) => extractInputSchema( - `export const inputSchema = ${schema};\n`, - 'src/mcp/library/tools/inspect.ts', -); +const extract = (schema: string) => { + const extracted = extractInputSchema( + `export const inputSchema = ${schema};\n`, + 'src/mcp/library/tools/inspect.ts', + ); + return extracted !== undefined && typeof extracted === 'object' && 'schema' in extracted + ? extracted.schema + : extracted; +}; it('projects the bounded zod grammar into a sorted frozen JSON Schema subset', () => { const schema = extract([ @@ -61,3 +66,359 @@ it('returns no projection for absent and out-of-grammar schemas without diagnost expect(extract('z.object({ flags: z.array(z.boolean()) })')).toBeUndefined(); expect(extract('z.object({ value: z.string().transform(String) })')).toBeUndefined(); }); + +const routeRelativePath = 'src/cli/library/status.ts'; +const routeSource = `/project/${routeRelativePath}`; + +const nameObjectSchema = 'z.object({ name: z.string() }).strict()'; + +const nameObjectProjection = { + additionalProperties: false, + properties: { + name: { type: 'string' }, + }, + required: ['name'], + type: 'object', +} as const; + +/** An in-memory project tree standing in for the sibling modules a schema reference imports. */ +const virtualProject = (files: Readonly>, source = routeSource) => { + const modules = new Map(Object.entries(files)); + return { + projectRoot: '/project', + readModule: (path: string) => modules.get(path), + source, + }; +}; + +const parse = ( + text: string, + files: Readonly> = {}, + relativePath = routeRelativePath, +) => parseInputSchema(text, relativePath, virtualProject(files, `/project/${relativePath}`)); + +const extractResolved = ( + text: string, + files: Readonly> = {}, + relativePath = routeRelativePath, +) => extractInputSchema(text, relativePath, virtualProject(files, `/project/${relativePath}`)); + +it('records the route-local literal origin as the inputSchema binding', () => { + const text = `export const inputSchema = ${nameObjectSchema};\n`; + const parsed = parse(text); + expect(parsed.found).toBe(true); + expect(parsed.issues).toEqual([]); + expect(parsed.origin).toEqual({ binding: 'inputSchema', module: routeRelativePath }); + expect(parsed.resolution).toBeUndefined(); + expect(extractResolved(text)?.origin).toEqual({ binding: 'inputSchema', module: routeRelativePath }); + expect(extractResolved(text)?.schema).toEqual(nameObjectProjection); +}); + +it('resolves a same-module non-exported const alias and records that binding as the origin', () => { + const text = [ + `const s = ${nameObjectSchema};`, + 'export const inputSchema = s;', + '', + ].join('\n'); + const parsed = parse(text); + expect(parsed.found).toBe(true); + expect(parsed.issues).toEqual([]); + expect(parsed.origin).toEqual({ binding: 's', module: routeRelativePath }); + expect(extractResolved(text)?.schema).toEqual(nameObjectProjection); +}); + +it('resolves one hop through a .js named import onto the .ts sibling and matches the inline twin', () => { + const files = { + '/project/src/lib/protocol-schemas.ts': `export const statusInputSchema = ${nameObjectSchema};\n`, + }; + const imported = [ + "import { statusInputSchema } from '../../lib/protocol-schemas.js';", + 'export const inputSchema = statusInputSchema;', + '', + ].join('\n'); + const inline = `export const inputSchema = ${nameObjectSchema};\n`; + const parsed = parse(imported, files); + expect(parsed.origin).toEqual({ + binding: 'statusInputSchema', + module: 'src/lib/protocol-schemas.ts', + }); + expect(extractResolved(imported, files)?.schema).toEqual(extractResolved(inline)?.schema); + expect(extractResolved(imported, files)?.schema).toEqual(nameObjectProjection); +}); + +it('follows a two-hop alias chain and records the origin at the end of the chain', () => { + const files = { + '/project/src/lib/base.ts': `export const baseSchema = ${nameObjectSchema};\n`, + '/project/src/lib/protocol-schemas.ts': [ + "import { baseSchema } from './base.js';", + 'export const statusInputSchema = baseSchema;', + '', + ].join('\n'), + }; + const text = [ + "import { statusInputSchema } from '../../lib/protocol-schemas.js';", + 'export const inputSchema = statusInputSchema;', + '', + ].join('\n'); + const parsed = parse(text, files); + expect(parsed.origin).toEqual({ binding: 'baseSchema', module: 'src/lib/base.ts' }); + expect(extractResolved(text, files)?.schema).toEqual(nameObjectProjection); +}); + +it('projects the cargo-hauler enum-array shape through an imported as-const and a local non-exported const', () => { + const files = { + '/project/src/daemon/protocol.ts': + "export const requestStatuses = ['queued', 'running', 'succeeded', 'failed'] as const;\n", + '/project/src/lib/protocol-schemas.ts': [ + "import { requestStatuses } from '../daemon/protocol.js';", + 'const requestStatusSchema = z.enum(requestStatuses);', + 'export const statusInputSchema = z.object({', + ' limit: z.number().int().min(1).max(500).optional(),', + ' laneKey: z.string().min(1).optional(),', + ' tickets: z.array(z.string().min(1)).max(100).optional(),', + ' statuses: z.array(requestStatusSchema).max(8).optional(),', + '}).strict();', + '', + ].join('\n'), + }; + const text = [ + "import { statusInputSchema } from '../../lib/protocol-schemas.js';", + 'export const inputSchema = statusInputSchema;', + '', + ].join('\n'); + const parsed = parse(text, files); + expect(parsed.issues).toEqual([]); + expect(parsed.origin).toEqual({ + binding: 'statusInputSchema', + module: 'src/lib/protocol-schemas.ts', + }); + const statuses = parsed.properties?.find((property) => property.key === 'statuses'); + expect(statuses).toMatchObject({ + base: { choices: ['queued', 'running', 'succeeded', 'failed'], kind: 'enum' }, + optional: true, + repeated: true, + }); + expect(extractResolved(text, files)?.schema).toEqual({ + additionalProperties: false, + properties: { + laneKey: { type: 'string' }, + limit: { type: 'number' }, + statuses: { + items: { enum: ['queued', 'running', 'succeeded', 'failed'], type: 'string' }, + type: 'array', + }, + tickets: { items: { type: 'string' }, type: 'array' }, + }, + type: 'object', + }); +}); + +it('applies local chain methods after a resolved chain-root identifier', () => { + const text = [ + 'const shared = z.string().min(1);', + 'export const inputSchema = z.object({', + " name: shared.optional().describe('x'),", + '}).strict();', + '', + ].join('\n'); + const parsed = parse(text); + expect(parsed.issues).toEqual([]); + expect(extractResolved(text)?.schema).toEqual({ + additionalProperties: false, + properties: { + name: { description: 'x', type: 'string' }, + }, + type: 'object', + }); +}); + +it('resolves z.object(shapeConst) and .default of a numeric const', () => { + const text = [ + 'const DEFAULT_LIMIT = 25;', + 'const shapeConst = {', + ' limit: z.number().int().default(DEFAULT_LIMIT),', + '};', + 'export const inputSchema = z.object(shapeConst).strict();', + '', + ].join('\n'); + const parsed = parse(text); + expect(parsed.issues).toEqual([]); + expect(extractResolved(text)?.schema).toEqual({ + additionalProperties: false, + properties: { + limit: { default: 25, type: 'number' }, + }, + type: 'object', + }); +}); + +it('reports a reference cycle with each step once plus the repeated binding and extracts nothing', () => { + const files = { + '/project/src/lib/a.ts': [ + "import { y } from './b.js';", + 'export const x = y;', + '', + ].join('\n'), + '/project/src/lib/b.ts': [ + "import { x } from './a.js';", + 'export const y = x;', + '', + ].join('\n'), + }; + const text = [ + "import { x } from '../../lib/a.js';", + 'export const inputSchema = x;', + '', + ].join('\n'); + const parsed = parse(text, files); + expect(parsed.resolution).toEqual({ + chain: [ + 'inputSchema', + 'x (src/lib/a.ts)', + 'y (src/lib/b.ts)', + 'x (src/lib/a.ts)', + ], + kind: 'cycle', + }); + expect(parsed.properties).toBeUndefined(); + expect(extractResolved(text, files)).toBeUndefined(); +}); + +it.each([ + [ + 'a bare package specifier', + "import { statusInputSchema } from '@shared/protocol';\nexport const inputSchema = statusInputSchema;\n", + {}, + ['inputSchema', 'statusInputSchema'], + 'imported from "@shared/protocol", which is not a relative module path', + ], + [ + 'a module outside the project root', + "import { statusInputSchema } from '../../../../../outside';\nexport const inputSchema = statusInputSchema;\n", + { '/outside.ts': `export const statusInputSchema = ${nameObjectSchema};\n` }, + ['inputSchema', 'statusInputSchema'], + 'imported from "../../../../../outside", which resolves outside the project', + ], + [ + 'a missing sibling module', + "import { statusInputSchema } from './missing';\nexport const inputSchema = statusInputSchema;\n", + {}, + ['inputSchema', 'statusInputSchema'], + 'imported from "./missing", which does not resolve to a module inside the project', + ], + [ + 'a sibling without that export const', + "import { statusInputSchema } from '../../lib/protocol-schemas.js';\nexport const inputSchema = statusInputSchema;\n", + { '/project/src/lib/protocol-schemas.ts': 'export const other = 1;\n' }, + ['inputSchema', 'statusInputSchema'], + 'imported from "../../lib/protocol-schemas.js", which does not declare a top-level `export const statusInputSchema`', + ], + [ + 'a let binding', + `let s = ${nameObjectSchema};\nexport const inputSchema = s;\n`, + {}, + ['inputSchema', 's'], + 'which is not a top-level `const`', + ], + [ + 'a destructured binding', + 'const { s } = source;\nexport const inputSchema = s;\n', + {}, + ['inputSchema', 's'], + 'which is not a top-level `const`', + ], + [ + 'a default import', + "import s from '../../lib/protocol-schemas.js';\nexport const inputSchema = s;\n", + { '/project/src/lib/protocol-schemas.ts': `export default ${nameObjectSchema};\n` }, + ['inputSchema', 's'], + 'which is not a top-level `const`', + ], + [ + 'a namespace import', + "import * as s from '../../lib/protocol-schemas.js';\nexport const inputSchema = s;\n", + { '/project/src/lib/protocol-schemas.ts': `export const statusInputSchema = ${nameObjectSchema};\n` }, + ['inputSchema', 's'], + 'which is not a top-level `const`', + ], + [ + 'a type-only import', + "import type { statusInputSchema } from '../../lib/protocol-schemas.js';\nexport const inputSchema = statusInputSchema;\n", + { '/project/src/lib/protocol-schemas.ts': `export const statusInputSchema = ${nameObjectSchema};\n` }, + ['inputSchema', 'statusInputSchema'], + 'which is neither a top-level const in this module nor a named import from a relative module', + ], + [ + 'a dynamic initializer', + 'export const s = build();\nexport const inputSchema = s;\n', + {}, + ['inputSchema', 's'], + 'whose initializer is a call expression', + ], + [ + 'a template initializer with substitutions', + 'const prefix = "x";\nexport const s = `${prefix}-schema`;\nexport const inputSchema = s;\n', + {}, + ['inputSchema', 's'], + 'whose initializer is a template literal with substitutions', + ], +])('leaves %s unresolved with the named chain and reason fragment', (_name, text, files, chain, fragment) => { + const parsed = parse(text, files); + expect(parsed.found).toBe(true); + expect(parsed.resolution).toMatchObject({ chain, kind: 'unresolved' }); + expect(parsed.resolution && 'reason' in parsed.resolution ? parsed.resolution.reason : '').toContain(fragment); + expect(parsed.properties).toBeUndefined(); + expect(extractResolved(text, files)).toBeUndefined(); +}); + +it('keeps a spread inside an inline shape a grammar issue, not a resolution failure', () => { + const text = 'export const inputSchema = z.object({ ...shared, name: z.string() });\n'; + const parsed = parse(text, {}); + expect(parsed.resolution).toBeUndefined(); + expect(parsed.issues).toEqual([ + expect.stringContaining('use plain `key: z...` property assignments'), + ]); + expect(parsed.properties).toEqual([expect.objectContaining({ key: 'name' })]); + expect(extractResolved(text, {})).toBeUndefined(); +}); + +it('qualifies a grammar violation inside an imported schema with the sibling position and AB4814 wording', () => { + const files = { + '/project/src/lib/protocol-schemas.ts': [ + 'export const statusInputSchema = z.object({', + ' nested: z.object({ value: z.string() }),', + '}).strict();', + '', + ].join('\n'), + }; + const text = [ + "import { statusInputSchema } from '../../lib/protocol-schemas.js';", + 'export const inputSchema = statusInputSchema;', + '', + ].join('\n'); + const parsed = parse(text, files); + expect(parsed.resolution).toBeUndefined(); + expect(parsed.issues[0]).toContain('src/lib/protocol-schemas.ts:2:11'); + expect(parsed.issues[0]).toContain('is outside the bounded argv grammar.'); + expect(parsed.issues[0]).toContain('the zod base z.object'); + expect(extractResolved(text, files)).toBeUndefined(); +}); + +it('rejects an import reference when the source path option is missing', () => { + const text = [ + "import { statusInputSchema } from '../../lib/protocol-schemas.js';", + 'export const inputSchema = statusInputSchema;', + '', + ].join('\n'); + const parsed = parseInputSchema(text, routeRelativePath, { + projectRoot: '/project', + readModule: () => `export const statusInputSchema = ${nameObjectSchema};\n`, + }); + expect(parsed.resolution).toMatchObject({ kind: 'unresolved' }); + expect(parsed.resolution && 'reason' in parsed.resolution ? parsed.resolution.reason : '') + .toMatch(/source path/u); + expect(extractInputSchema(text, routeRelativePath, { + projectRoot: '/project', + readModule: () => `export const statusInputSchema = ${nameObjectSchema};\n`, + })).toBeUndefined(); +}); diff --git a/packages/agent-bundle/tests/route-config-extract.test.ts b/packages/agent-bundle/tests/route-config-extract.test.ts index 2c6a8f5e9..7e75a48b7 100644 --- a/packages/agent-bundle/tests/route-config-extract.test.ts +++ b/packages/agent-bundle/tests/route-config-extract.test.ts @@ -138,6 +138,24 @@ it('resolves an exported const string literal imported from a relative sibling m expect(config).toEqual({ _meta: { ui: { resourceUri: 'ui://notes/panel.html' } }, title: 'Shared' }); }); +it('resolves a string const reached through two relative hops and an export const re-alias', () => { + const project = virtualProject({ + '/project/src/shared/title.ts': "export const SHARED_TITLE = 'Shared' as const;\n", + '/project/src/mcp/notes/constants.ts': [ + "import { SHARED_TITLE } from '../../shared/title.js';", + 'export const TITLE = SHARED_TITLE;', + '', + ].join('\n'), + }); + const { config, diagnostics } = extract([ + "import { TITLE } from '../constants.js';", + 'export const config = { title: TITLE };', + 'export default () => null;', + ].join('\n'), 'src/mcp/notes/tools/search.ts', project); + expect(diagnostics).toEqual([]); + expect(config).toEqual({ title: 'Shared' }); +}); + it.each([ [ 'a bare package specifier', diff --git a/packages/agent-bundle/tests/route-contract-imports.test.ts b/packages/agent-bundle/tests/route-contract-imports.test.ts new file mode 100644 index 000000000..ec9b547ae --- /dev/null +++ b/packages/agent-bundle/tests/route-contract-imports.test.ts @@ -0,0 +1,327 @@ +import { execFile as executeFile } from 'node:child_process'; +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { promisify } from 'node:util'; + +import { afterEach, expect, it } from '@rstest/core'; +import ts from 'typescript-5'; + +import { build, inspect } from '../src/api.ts'; +import { compileRouteGraph } from '../src/routes/graph.ts'; +import type { + CompiledAgentRoute, + CompiledCliCommand, + CompiledRouteGraph, + RouteInputSchema, +} from '../src/routes/types.ts'; + +const execFile = promisify(executeFile); +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +const writeProjectFile = async (root: string, path: string, contents: string): Promise => { + const output = join(root, path); + await mkdir(dirname(output), { recursive: true }); + await writeFile(output, contents); +}; + +type FixtureVariant = 'bare' | 'imported' | 'inline'; + +interface RouteContractView { + readonly id: string; + readonly input: RouteInputSchema; + readonly origin: { + readonly binding: string; + readonly module: string; + }; + readonly routes: readonly string[]; +} + +type RouteWithContract = CompiledAgentRoute & { readonly contract?: string }; +type RouteContractGraph = CompiledRouteGraph & { + readonly contracts?: readonly RouteContractView[]; +}; + +const inputProperties = [ + " laneKey: z.string().min(1).optional(),", + " limit: z.number().int().min(1).max(500).optional().describe('Recent rows'),", + " statuses: z.array(z.enum(['requested', 'queued', 'running', 'done'])).max(8).optional(),", + " tickets: z.array(z.string().min(1)).max(100).optional(),", +]; + +const inlineInputSchema = [ + 'z.object({', + ...inputProperties, + '}).strict()', +].join('\n'); + +const importedProtocolSchemas = (variant: 'bare' | 'imported'): string => [ + "import { z } from 'zod';", + variant === 'bare' + ? "import { requestStatuses } from '@shared/protocol';" + : "import { requestStatuses } from '../daemon/protocol.js';", + '', + 'const requestStatusSchema = z.enum(requestStatuses);', + 'export const statusInputSchema = z.object({', + " laneKey: z.string().min(1).optional(),", + " limit: z.number().int().min(1).max(500).optional().describe('Recent rows'),", + ' statuses: z.array(requestStatusSchema).max(8).optional(),', + ' tickets: z.array(z.string().min(1)).max(100).optional(),', + '}).strict();', + "export const statusResultSchema = z.object({ filters: statusInputSchema, operation: z.literal('status') });", + '', +].join('\n'); + +const cliRoute = (variant: FixtureVariant): string => variant === 'inline' + ? [ + "import { z } from 'zod';", + '', + "export const config = { description: 'Show the queue.' };", + `export const inputSchema = ${inlineInputSchema};`, + "export const resultSchema = z.object({ filters: inputSchema, operation: z.literal('status') });", + "export default async function status({ input }: { input: z.infer }) {", + " return { filters: input, operation: 'status' };", + '}', + '', + ].join('\n') + : [ + "import type { z } from 'zod';", + '', + "import { statusInputSchema, statusResultSchema } from '../lib/protocol-schemas.js';", + '', + "export const config = { description: 'Show the queue.' };", + 'export const inputSchema = statusInputSchema;', + 'export const resultSchema = statusResultSchema;', + "export default async function status({ input }: { input: z.infer }) {", + " return { filters: input, operation: 'status' };", + '}', + '', + ].join('\n'); + +const toolRoute = (variant: FixtureVariant): string => variant === 'inline' + ? [ + "import { Agent } from '@agent-bundle/runtime';", + "import { z } from 'zod';", + '', + "export const config = { description: 'Show the queue.' };", + `export const inputSchema = ${inlineInputSchema};`, + "export const resultSchema = z.object({ filters: inputSchema, operation: z.literal('status') });", + 'export default async function HaulerStatus({ input }: { input: z.infer }) {', + " const result = { filters: input, operation: 'status' };", + ' return Queue status.;', + '}', + '', + ].join('\n') + : [ + "import { Agent } from '@agent-bundle/runtime';", + "import type { z } from 'zod';", + '', + "import { statusInputSchema, statusResultSchema } from '../../../lib/protocol-schemas.js';", + '', + "export const config = { description: 'Show the queue.' };", + 'export const inputSchema = statusInputSchema;', + 'export const resultSchema = statusResultSchema;', + 'export default async function HaulerStatus({ input }: { input: z.infer }) {', + " const result = { filters: input, operation: 'status' };", + ' return Queue status.;', + '}', + '', + ].join('\n'); + +const writeFixture = async (variant: FixtureVariant): Promise => { + const root = await mkdtemp(join(tmpdir(), `agent-bundle-route-contract-${variant}-`)); + roots.push(root); + await symlink( + join(process.cwd(), 'examples', 'audiobook-curator', 'node_modules'), + join(root, 'node_modules'), + 'dir', + ); + const name = `route-contract-${variant}-fixture`; + await Promise.all([ + writeProjectFile(root, 'package.json', JSON.stringify({ + dependencies: { + '@agent-bundle/runtime': 'workspace:*', + react: '19.2.8', + zod: '4.4.3', + }, + name, + type: 'module', + version: '1.0.0', + })), + writeProjectFile(root, 'agent-bundle.config.ts', [ + "import { defineConfig } from 'agent-bundle/config';", + 'export default defineConfig({', + ` plugin: { description: 'Route contract fixture.', name: '${name}', version: '1.0.0' },`, + ' routes: { mcpCommands: true },', + " targets: ['portable'],", + '});', + '', + ].join('\n')), + writeProjectFile( + root, + 'src/daemon/protocol.ts', + "export const requestStatuses = ['requested', 'queued', 'running', 'done'] as const;\n", + ), + ...(variant === 'inline' + ? [] + : [writeProjectFile( + root, + 'src/lib/protocol-schemas.ts', + importedProtocolSchemas(variant), + )]), + writeProjectFile(root, 'src/cli/status.ts', cliRoute(variant)), + writeProjectFile(root, 'src/mcp/hauler/tools/hauler_status.tsx', toolRoute(variant)), + ]); + return root; +}; + +const routeGraph = async (root: string): Promise => { + const graph = await compileRouteGraph(root, { + plugin: { name: 'route-contract-fixture', version: '1.0.0' }, + routes: { mcpCommands: true }, + targets: ['portable'], + }); + expect(graph.diagnostics.map((diagnostic) => ({ + code: diagnostic.code, + message: diagnostic.message, + sourcePath: diagnostic.sourcePath, + }))).toEqual([]); + return graph as RouteContractGraph; +}; + +const routeById = (graph: RouteContractGraph, id: string): RouteWithContract | undefined => [ + ...(graph.cli?.routes ?? []), + ...graph.servers.flatMap((server) => server.routes), +].find((route) => route.id === id); + +const commandById = ( + graph: RouteContractGraph, + id: string, +): CompiledCliCommand | undefined => graph.cli?.commands?.find((command) => command.routeId === id); + +const writeTypeProbe = async (root: string): Promise => { + await writeProjectFile(root, 'types-probe.ts', [ + "import type { RouteInput, RouteResult } from './.agent-bundle/routes.js';", + '', + 'type Equals =', + ' (() => Value extends Left ? 1 : 2) extends', + ' (() => Value extends Right ? 1 : 2) ? true : false;', + "type Status = 'requested' | 'queued' | 'running' | 'done';", + '', + "const _input: Equals, RouteInput<'tool:hauler/hauler_status'>> = true;", + "const _result: Equals, RouteResult<'tool:hauler/hauler_status'>> = true;", + "const _statuses: Equals, 'statuses'>, { statuses?: Status[] | undefined }> = true;", + 'void _input; void _result; void _statuses;', + '', + ].join('\n')); +}; + +const typecheckProbe = (root: string): readonly string[] => { + const program = ts.createProgram( + [join(root, 'types-probe.ts'), join(root, '.agent-bundle', 'routes.d.ts')], + { + exactOptionalPropertyTypes: true, + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + noEmit: true, + skipLibCheck: true, + strict: true, + target: ts.ScriptTarget.ES2022, + }, + ); + return ts.getPreEmitDiagnostics(program) + .map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')); +}; + +it('shares imported route contracts across graph, argv, runtime, and generated types', { timeout: 120_000 }, async () => { + const [bareRoot, importedRoot, inlineRoot] = await Promise.all([ + writeFixture('bare'), + writeFixture('imported'), + writeFixture('inline'), + ]); + + const [importedGraph, inlineGraph] = await Promise.all([ + routeGraph(importedRoot), + routeGraph(inlineRoot), + ]); + expect(importedGraph.contracts).toHaveLength(1); + expect(importedGraph.contracts?.[0]).toMatchObject({ + id: 'contract:src/lib/protocol-schemas.ts#statusInputSchema', + origin: { + binding: 'statusInputSchema', + module: 'src/lib/protocol-schemas.ts', + }, + routes: ['cli:status', 'tool:hauler/hauler_status'], + }); + + const importedCli = routeById(importedGraph, 'cli:status'); + const importedTool = routeById(importedGraph, 'tool:hauler/hauler_status'); + const inlineTool = routeById(inlineGraph, 'tool:hauler/hauler_status'); + expect(importedCli?.contract).toBe('contract:src/lib/protocol-schemas.ts#statusInputSchema'); + expect(importedTool?.contract).toBe('contract:src/lib/protocol-schemas.ts#statusInputSchema'); + expect(commandById(importedGraph, 'cli:status')?.options) + .toEqual(commandById(inlineGraph, 'cli:status')?.options); + expect(importedTool?.inputSchema).toEqual(inlineTool?.inputSchema); + expect(importedCli?.inputSchema).toBe(importedTool?.inputSchema); + + const built = await build({ output: 'artifact', packageOutputs: true, root: importedRoot }); + expect(built.diagnostics).toEqual([]); + const binPath = join(importedRoot, 'dist', 'bin', 'route-contract-imported-fixture.js'); + const help = await execFile(binPath, ['status', '--help']); + for (const option of ['--lane-key', '--limit', '--statuses', '--tickets']) { + expect(help.stdout).toContain(option); + } + for (const choice of ['requested', 'queued', 'running', 'done']) { + expect(help.stdout).toContain(choice); + } + const status = await execFile(binPath, [ + 'status', + '--statuses', 'queued', + '--statuses', 'done', + '--limit', '3', + '--json', + ]); + expect(JSON.parse(status.stdout)).toEqual({ + filters: { + limit: 3, + statuses: ['queued', 'done'], + }, + operation: 'status', + }); + await expect(execFile(binPath, ['status', '--statuses', 'bogus'])) + .rejects.toMatchObject({ code: 2 }); + + const inlineInspection = await inspect({ focus: 'routes', root: inlineRoot }); + expect(inlineInspection.diagnostics).toEqual([]); + await Promise.all([writeTypeProbe(importedRoot), writeTypeProbe(inlineRoot)]); + const importedTypes = typecheckProbe(importedRoot); + const inlineTypes = typecheckProbe(inlineRoot); + expect(importedTypes).toEqual(inlineTypes); + expect(importedTypes).toEqual([]); + + const bareGraph = await compileRouteGraph(bareRoot, { + plugin: { name: 'route-contract-bare-fixture', version: '1.0.0' }, + routes: { mcpCommands: true }, + targets: ['portable'], + }) as RouteContractGraph; + expect(bareGraph.diagnostics).toEqual([ + expect.objectContaining({ + code: 'AB4838', + message: expect.stringContaining( + 'inputSchema -> statusInputSchema (src/lib/protocol-schemas.ts) -> requestStatusSchema -> requestStatuses', + ), + sourcePath: join(bareRoot, 'src', 'cli', 'status.ts'), + }), + ]); + expect(bareGraph.diagnostics[0]?.message).toContain('is not a relative module path'); + const bareCli = routeById(bareGraph, 'cli:status'); + const bareTool = routeById(bareGraph, 'tool:hauler/hauler_status'); + expect(bareCli).not.toHaveProperty('contract'); + expect(bareCli).not.toHaveProperty('inputSchema'); + expect(bareTool).not.toHaveProperty('contract'); + expect(bareTool).not.toHaveProperty('inputSchema'); +}); diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index 6d19fcef3..46253b2a1 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -190,6 +190,126 @@ it('populates bounded input schemas for every route kind and includes them in th await writeFile(inspectPath, (await readFile(inspectPath, 'utf8')).replace('Project root.', 'Workspace root.')); const changed = await compileRouteGraph(changedRoot, fixtureConfig()); expect(changed.digest).not.toBe(graph.digest); + // Pre-#593 pin: an inline-only tree must digest exactly as it does on + // current main. Contract ids for route-local literals do not join identity. + expect(graph.digest).toBe('d4d97709727353b0acf39b9d1b26a507e41c9ba22ba5f897c0a5e9578fd2fb50'); +}); + +it('shares one RouteContract across a CLI route and a tool route that import the same schema', async () => { + const root = await createRoot(); + const schema = [ + 'export const statusInputSchema = z.object({', + ' limit: z.number().int().min(1).max(500).optional(),', + ' laneKey: z.string().min(1).optional(),', + '}).strict();', + '', + ].join('\n'); + const route = (specifier: string): string => [ + `import { statusInputSchema } from '${specifier}';`, + 'export const inputSchema = statusInputSchema;', + 'export const resultSchema = {};', + 'export default async () => undefined;', + '', + ].join('\n'); + await writeTree(root, { + 'src/cli/status.ts': route('../lib/protocol-schemas.js'), + 'src/lib/protocol-schemas.ts': schema, + 'src/mcp/hauler/tools/hauler_status.tsx': route('../../../lib/protocol-schemas.js'), + }); + const graph = await compileRouteGraph(root, fixtureConfig()); + expect(graph.diagnostics).toEqual([]); + expect(graph.contracts).toHaveLength(1); + const [contract] = graph.contracts ?? []; + expect(contract).toMatchObject({ + id: 'contract:src/lib/protocol-schemas.ts#statusInputSchema', + origin: { binding: 'statusInputSchema', module: 'src/lib/protocol-schemas.ts' }, + routes: ['cli:status', 'tool:hauler/hauler_status'], + }); + const cli = graph.cli!.routes.find((entry) => entry.id === 'cli:status')!; + const tool = graph.servers[0]!.routes.find((entry) => entry.id === 'tool:hauler/hauler_status')!; + expect(cli.contract).toBe(contract!.id); + expect(tool.contract).toBe(contract!.id); + expect(contract!.input).toBe(cli.inputSchema); + expect(contract!.input).toBe(tool.inputSchema); +}); + +it('assigns a route-local literal the contract id of its own module and lists it in contracts', async () => { + const root = await createRoot(); + await writeTree(root, { + 'src/cli/audit.ts': [ + 'export const inputSchema = z.object({ strict: z.boolean().optional() }).strict();', + 'export const resultSchema = {};', + 'export default async () => undefined;', + '', + ].join('\n'), + }); + const graph = await compileRouteGraph(root, fixtureConfig()); + expect(graph.diagnostics).toEqual([]); + expect(graph.contracts).toEqual([ + expect.objectContaining({ + id: 'contract:src/cli/audit.ts#inputSchema', + origin: { binding: 'inputSchema', module: 'src/cli/audit.ts' }, + routes: ['cli:audit'], + }), + ]); + expect(graph.cli!.routes[0]!.contract).toBe('contract:src/cli/audit.ts#inputSchema'); + expect(graph.contracts![0]!.input).toBe(graph.cli!.routes[0]!.inputSchema); +}); + +it('omits contract on a route with no static schema', async () => { + const root = await createRoot(); + await writeTree(root, { + 'src/scripts/rebuild.ts': moduleSource, + }); + const graph = await compileRouteGraph(root, fixtureConfig()); + expect(graph.diagnostics).toEqual([]); + expect(graph.scripts[0]!.contract).toBeUndefined(); + expect(graph.contracts).toBeUndefined(); +}); + +it('does not list routes of a custom-mode server on any contract', async () => { + const root = await createRoot(); + await writeTree(root, { + 'src/mcp/curator.ts': moduleSource, + 'src/mcp/curator/tools/inspect.ts': [ + 'export const inputSchema = z.object({ root: z.string() }).strict();', + 'export const resultSchema = {};', + 'export default async () => undefined;', + '', + ].join('\n'), + }); + const graph = await compileRouteGraph(root, fixtureConfig({ + mcp: { servers: { curator: { entry: './src/mcp/curator.ts' } } }, + routes: { servers: { curator: 'custom' } }, + })); + expect(graph.diagnostics).toEqual([]); + expect(graph.servers[0]).toMatchObject({ mode: 'custom', routes: [] }); + expect(graph.contracts).toBeUndefined(); +}); + +it('changes the graph digest when a route imports its schema from another module', async () => { + const schema = 'z.object({ name: z.string() }).strict()'; + const command = (inputSchema: string): string => [ + `export const inputSchema = ${inputSchema};`, + 'export const resultSchema = {};', + 'export default async () => undefined;', + '', + ].join('\n'); + const inlineRoot = await createRoot(); + await writeTree(inlineRoot, { 'src/cli/status.ts': command(schema) }); + const importedRoot = await createRoot(); + await writeTree(importedRoot, { + 'src/cli/status.ts': command('statusInputSchema').replace( + 'export const inputSchema', + "import { statusInputSchema } from '../lib/protocol-schemas.js';\nexport const inputSchema", + ), + 'src/lib/protocol-schemas.ts': `export const statusInputSchema = ${schema};\n`, + }); + const inline = await compileRouteGraph(inlineRoot, fixtureConfig()); + const imported = await compileRouteGraph(importedRoot, fixtureConfig()); + expect(inline.diagnostics).toEqual([]); + expect(imported.diagnostics).toEqual([]); + expect(imported.digest).not.toBe(inline.digest); }); it('skips ignored paths, private segments, and declaration files', async () => { diff --git a/packages/agent-bundle/tests/route-manifest-routes.test.ts b/packages/agent-bundle/tests/route-manifest-routes.test.ts index 207a205e4..115fb93f8 100644 --- a/packages/agent-bundle/tests/route-manifest-routes.test.ts +++ b/packages/agent-bundle/tests/route-manifest-routes.test.ts @@ -313,6 +313,69 @@ it('passes the bounded input schema through as the optional manifest wire field' expect(Object.isFrozen(manifest.scripts[0]?.inputSchema)).toBe(true); }); +it('projects shared route contracts and omits them from contract-free graphs', () => { + const input = Object.freeze({ + additionalProperties: false as const, + properties: Object.freeze({ + statuses: Object.freeze({ + items: Object.freeze({ enum: Object.freeze(['queued', 'running']), type: 'string' as const }), + type: 'array' as const, + }), + }), + type: 'object' as const, + }); + const contractId = 'contract:src/lib/protocol-schemas.ts#statusInputSchema'; + const cliRoute = { + config: {}, + contract: contractId, + id: 'cli:status', + inputSchema: input, + kind: 'cli' as const, + provenance: { kind: 'conventional' as const, relativePath: 'src/cli/status.ts' }, + source: '/project/src/cli/status.ts', + }; + const toolRoute = { + config: {}, + contract: contractId, + id: 'tool:hauler/hauler_status', + inputSchema: input, + kind: 'tool' as const, + provenance: { kind: 'conventional' as const, relativePath: 'src/mcp/hauler/tools/hauler_status.ts' }, + serverId: 'mcp:hauler', + source: '/project/src/mcp/hauler/tools/hauler_status.ts', + }; + const graph: CompiledRouteGraph = { + ...emptyCompiledRouteGraph, + cli: { mode: 'generated', routes: [cliRoute] }, + contracts: [{ + id: contractId, + input, + origin: { binding: 'statusInputSchema', module: 'src/lib/protocol-schemas.ts' }, + routes: ['cli:status', 'tool:hauler/hauler_status'], + }], + digest: 'c'.repeat(64), + servers: [{ + id: 'mcp:hauler', + mode: 'generated', + name: 'hauler', + routes: [toolRoute], + }], + }; + + const manifest = routeManifestFor(graph, revision); + + expect(manifest.contracts).toEqual([{ + id: contractId, + input, + origin: { binding: 'statusInputSchema', module: 'src/lib/protocol-schemas.ts' }, + routes: ['cli:status', 'tool:hauler/hauler_status'], + }]); + expect(manifest.cli?.routes[0]?.contract).toBe(contractId); + expect(manifest.servers[0]?.routes[0]?.contract).toBe(contractId); + expect(Object.isFrozen(manifest.contracts)).toBe(true); + expect(routeManifestFor(emptyCompiledRouteGraph, revision)).not.toHaveProperty('contracts'); +}); + it('summarizes an extracted route config without leaking non-scalar shapes', async () => { const graph = await compileRouteGraph(resolve(import.meta.dirname, '../fixtures/route-harness'), { targets: ['claude'] } as never); const manifest = routeManifestFor(graph, revision); diff --git a/packages/workbench/src/routes/route-manifest-client.ts b/packages/workbench/src/routes/route-manifest-client.ts index 5b51ce795..fc46087b3 100644 --- a/packages/workbench/src/routes/route-manifest-client.ts +++ b/packages/workbench/src/routes/route-manifest-client.ts @@ -87,7 +87,20 @@ const inputSchema: z.ZodType = z.strictObject({ type: z.literal('object'), }); +type RouteManifestContract = NonNullable[number]; + +const contractSchema: z.ZodType = z.strictObject({ + id: z.string(), + input: inputSchema, + origin: z.strictObject({ + binding: z.string(), + module: z.string(), + }), + routes: z.array(z.string()), +}); + const routeSchema: z.ZodType = z.strictObject({ + contract: z.string().optional(), config: z.array(configEntrySchema), description: z.string().optional(), event: z.string().optional(), @@ -181,6 +194,7 @@ const stateSchema: z.ZodType = z.strictObject({ const manifestSchema: z.ZodType = z.strictObject({ cli: cliSchema.optional(), + contracts: z.array(contractSchema).optional(), diagnostics: z.array(diagnosticSchema), digest: z.string(), events: z.array(routeSchema), diff --git a/packages/workbench/src/routes/routes-model.ts b/packages/workbench/src/routes/routes-model.ts index e3b62b403..a614ec5ad 100644 --- a/packages/workbench/src/routes/routes-model.ts +++ b/packages/workbench/src/routes/routes-model.ts @@ -34,6 +34,14 @@ export const routeCatalogKinds = Object.freeze([ export interface RouteCatalogEntry { readonly command?: RouteManifestCliCommand; readonly config: readonly RouteManifestConfigEntry[]; + readonly contract?: { + readonly id: string; + readonly origin: { + readonly binding: string; + readonly module: string; + }; + readonly sharedWith: readonly string[]; + }; readonly description?: string; readonly event?: string; readonly id: string; @@ -132,17 +140,33 @@ export const routeKindLabel = (kind: RouteManifestKind): string => kindLabels[ki const byId = (left: RouteCatalogEntry, right: RouteCatalogEntry): number => left.id.localeCompare(right.id); -const entryFor = (route: RouteManifestRoute, command?: RouteManifestCliCommand): RouteCatalogEntry => Object.freeze({ - ...(command === undefined ? {} : { command }), - config: route.config, - ...(route.description === undefined ? {} : { description: route.description }), - ...(route.event === undefined ? {} : { event: route.event }), - id: route.id, - ...(route.inputSchema === undefined ? {} : { inputSchema: route.inputSchema }), - kind: route.kind, - provenance: route.provenance.kind, - source: route.source, -}); +type ManifestContract = NonNullable[number]; + +const entryFor = ( + route: RouteManifestRoute, + contracts: ReadonlyMap, + command?: RouteManifestCliCommand, +): RouteCatalogEntry => { + const contract = route.contract === undefined ? undefined : contracts.get(route.contract); + return Object.freeze({ + ...(command === undefined ? {} : { command }), + config: route.config, + ...(contract === undefined ? {} : { + contract: Object.freeze({ + id: contract.id, + origin: Object.freeze({ ...contract.origin }), + sharedWith: Object.freeze(contract.routes.filter((routeId) => routeId !== route.id)), + }), + }), + ...(route.description === undefined ? {} : { description: route.description }), + ...(route.event === undefined ? {} : { event: route.event }), + id: route.id, + ...(route.inputSchema === undefined ? {} : { inputSchema: route.inputSchema }), + kind: route.kind, + provenance: route.provenance.kind, + source: route.source, + }); +}; const groupFor = ( kind: RouteManifestKind, @@ -155,30 +179,46 @@ const groupFor = ( ...(server === undefined ? {} : { mode: server.mode, server: server.name, serverId: server.id }), }); -const serverGroups = (manifest: RouteManifest): readonly RouteCatalogGroup[] => +const serverGroups = ( + manifest: RouteManifest, + contracts: ReadonlyMap, +): readonly RouteCatalogGroup[] => [...manifest.servers] .sort((left, right) => left.name.localeCompare(right.name)) .flatMap((server) => routeCatalogKinds - .map((kind) => Object.freeze({ entries: server.routes.filter((route) => route.kind === kind).map((route) => entryFor(route)), kind })) + .map((kind) => Object.freeze({ + entries: server.routes.filter((route) => route.kind === kind).map((route) => entryFor(route, contracts)), + kind, + })) .filter((group) => group.entries.length > 0) .map((group) => groupFor(group.kind, group.entries, { id: server.id, mode: server.mode, name: server.name }))); -const cliGroups = (manifest: RouteManifest): readonly RouteCatalogGroup[] => { +const cliGroups = ( + manifest: RouteManifest, + contracts: ReadonlyMap, +): readonly RouteCatalogGroup[] => { const cli = manifest.cli; if (cli === undefined || cli.routes.length === 0) return []; const commands = new Map((cli.commands ?? []).map((command) => [command.routeId, command])); return [Object.freeze({ - entries: Object.freeze(cli.routes.map((route) => entryFor(route, commands.get(route.id))).sort(byId)), + entries: Object.freeze(cli.routes.map((route) => entryFor(route, contracts, commands.get(route.id))).sort(byId)), kind: 'cli' as const, label: kindLabels.cli, mode: cli.mode, })]; }; -const projectGroups = (manifest: RouteManifest): readonly RouteCatalogGroup[] => [ - ...(manifest.events.length === 0 ? [] : [groupFor('event-route', manifest.events.map((route) => entryFor(route)))]), - ...cliGroups(manifest), - ...(manifest.scripts.length === 0 ? [] : [groupFor('script', manifest.scripts.map((route) => entryFor(route)))]), +const projectGroups = ( + manifest: RouteManifest, + contracts: ReadonlyMap, +): readonly RouteCatalogGroup[] => [ + ...(manifest.events.length === 0 + ? [] + : [groupFor('event-route', manifest.events.map((route) => entryFor(route, contracts)))]), + ...cliGroups(manifest, contracts), + ...(manifest.scripts.length === 0 + ? [] + : [groupFor('script', manifest.scripts.map((route) => entryFor(route, contracts)))]), ]; /** @@ -190,7 +230,11 @@ export const routeCatalogFor = ( manifest: RouteManifest, epochSourceRevision?: string, ): RouteCatalog => { - const groups = Object.freeze([...serverGroups(manifest), ...projectGroups(manifest)]); + const contracts = new Map((manifest.contracts ?? []).map((contract) => [contract.id, contract])); + const groups = Object.freeze([ + ...serverGroups(manifest, contracts), + ...projectGroups(manifest, contracts), + ]); return Object.freeze({ diagnostics: manifest.diagnostics, digest: manifest.digest, diff --git a/packages/workbench/src/routes/routes-page.tsx b/packages/workbench/src/routes/routes-page.tsx index 70e16a795..b342274c5 100644 --- a/packages/workbench/src/routes/routes-page.tsx +++ b/packages/workbench/src/routes/routes-page.tsx @@ -128,6 +128,21 @@ const StatePanel = ({ state }: { readonly state?: RouteManifestState }) => `route-input-${routeId}-${key}`.replace(/[^a-zA-Z0-9_-]/gu, '-'); +const contractSummary = (entry: RouteCatalogEntry): string | undefined => { + const contract = entry.contract; + if (contract === undefined) return undefined; + // Route-local contracts use a stable declaration label instead of repeating + // the route source already shown in the adjacent table cell. + const origin = contract.origin.module === entry.source + ? 'declared in this module' + : contract.origin.module; + return [ + `Contract ${contract.origin.binding}`, + origin, + ...(contract.sharedWith.length === 0 ? [] : [`shared with ${contract.sharedWith.join(', ')}`]), + ].join(' · '); +}; + const scalarControl = ( routeId: string, key: string, @@ -207,8 +222,10 @@ const RouteInputEditor = ({ digest, entry, group, onOpenMcp }: { if (prefill !== undefined) onOpenMcp(prefill); }; + const contract = contractSummary(entry); return

{schema === undefined ? 'Raw JSON input' : 'Generated input editor'}

+ {contract === undefined ? undefined :

{contract}

} {schema === undefined ?