Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/593-route-contract-imports.md
Original file line number Diff line number Diff line change
@@ -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:<module>#<binding>`, `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)
95 changes: 79 additions & 16 deletions docs/diagnostics.md

Large diffs are not rendered by default.

13 changes: 12 additions & 1 deletion docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -821,7 +821,7 @@ export default async function inspect({ input, signal }: CliRouteProps<typeof in

The compiler statically projects `inputSchema` onto argv (the bounded grammar
and every policy rule are documented in
[Diagnostics](diagnostics.md#route-graph-state-layout-and-provider-conventions-ab4800ab4832-ab4940ab4942)), generates nested
[Diagnostics](diagnostics.md#route-graph-state-layout-and-provider-conventions-ab4800ab4839-ab4940ab4942)), generates nested
help (`--help` at every level, `--version` at the root), and emits
`dist/bin/<plugin-name>.js` with the shebang and executable bit through the
same Rslib synthesis as every other bin. At run time the shell resolves the
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-bundle/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ export type {
CompiledRouteKind,
CompiledServerMode,
CompiledServerSurface,
RouteContract,
RouteContractOrigin,
RouteProvenance,
} from './routes/types.ts';
export type { BuildResult } from './build/build.ts';
Expand Down
1 change: 1 addition & 0 deletions packages/agent-bundle/src/contracts/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type {
RouteManifestCliOption,
RouteManifestCliSurface,
RouteManifestConfigEntry,
RouteManifestContract,
RouteManifestKind,
RouteManifestProvenance,
RouteManifestProvider,
Expand Down
1 change: 1 addition & 0 deletions packages/agent-bundle/src/dev/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export type {
RouteManifestCliOption,
RouteManifestCliSurface,
RouteManifestConfigEntry,
RouteManifestContract,
RouteManifestProvider,
RouteManifestResponse,
RouteManifestRoute,
Expand Down
24 changes: 24 additions & 0 deletions packages/agent-bundle/src/dev/routes/route-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -185,6 +200,7 @@ const description = (config: Readonly<Record<string, unknown>>): 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 }),
Expand Down Expand Up @@ -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),
Expand Down
149 changes: 122 additions & 27 deletions packages/agent-bundle/src/routes/cli-argv.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -25,12 +38,14 @@ export const reservedCliOptionNames: ReadonlySet<string> = 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.`;
Expand All @@ -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')
Expand All @@ -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,
Expand Down Expand Up @@ -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<string, string>();
for (const entry of parsed.entries) {
for (const entry of entries) {
if ('issue' in entry) {
diagnostics.push(argvError(entry.issue, sourcePath));
continue;
Expand All @@ -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 });
};
32 changes: 30 additions & 2 deletions packages/agent-bundle/src/routes/cli-commands.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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
Expand All @@ -360,6 +387,7 @@ export const compileCliCommands = async (
routes: readonly CompiledAgentRoute[],
readModuleText: (route: CompiledAgentRoute) => Promise<string | undefined>,
projected: CompiledMcpCliCommandSurface = { commands: [], diagnostics: [], routes: [] },
compileOptions: CompileCliCommandsOptions = {},
): Promise<CompiledCliCommandSurface> => {
const diagnostics: Diagnostic[] = [...projected.diagnostics];
const commands: CompiledCliCommand[] = [...projected.commands];
Expand All @@ -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']),
Expand Down
Loading
Loading