From f15feb07a61c5439b1ae24828f07567139c059f3 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 06:00:20 +0000 Subject: [PATCH] feat: statically extract route config exports into the route-graph IR (#93, PR-2) Each discovered route module's `export const config = ` declaration is parsed with the TypeScript compiler (never executed) into the compiled route. The accepted grammar is documented in docs/diagnostics.md; rejected declaration shapes raise AB4805 and dynamic initializers raise AB4806 naming the offending construct and position, with the route compiling on the shared empty config. The graph digest now covers extracted configs. The parser ships as the aliased typescript-5 dependency so the workspace's typescript@7 toolchain resolution (rslib declaration generation) stays untouched. --- .changeset/route-config-extractor.md | 9 + docs/diagnostics.md | 17 +- packages/agent-bundle/package.json | 1 + .../agent-bundle/src/routes/config-extract.ts | 284 ++++++++++++++++++ packages/agent-bundle/src/routes/graph.ts | 32 +- packages/agent-bundle/src/routes/index.ts | 2 + packages/agent-bundle/src/routes/types.ts | 7 +- .../tests/route-config-extract.test.ts | 92 ++++++ .../agent-bundle/tests/route-graph.test.ts | 51 ++++ pnpm-lock.yaml | 3 + 10 files changed, 491 insertions(+), 7 deletions(-) create mode 100644 .changeset/route-config-extractor.md create mode 100644 packages/agent-bundle/src/routes/config-extract.ts create mode 100644 packages/agent-bundle/tests/route-config-extract.test.ts diff --git a/.changeset/route-config-extractor.md b/.changeset/route-config-extractor.md new file mode 100644 index 000000000..1317d4279 --- /dev/null +++ b/.changeset/route-config-extractor.md @@ -0,0 +1,9 @@ +--- +"agent-bundle": minor +--- + +Statically extract each route module's `config` export into the route-graph IR (#93, PR-2). + +- Extraction is a real TS/TSX parse (TypeScript compiler, module never executed) of a single top-level `export const config = ` declaration. The accepted grammar: object literals with identifier/string/numeric property names, array literals without spreads or holes, string and substitution-free template literals, numeric literals with optional unary `+`/`-`, `true`/`false`/`null`, and `as`/`satisfies`/non-null/parenthesis wrappers. The grammar is documented in `docs/diagnostics.md`. +- Rejections are named errors beside the compiled route, never silent choices: `AB4805` for a rejected declaration shape (`let`/`var`, destructuring, indirect `export { config }`, function/class, missing initializer, non-object value) and `AB4806` for a dynamic initializer, naming the offending construct and position. The route compiles with the shared empty config in both cases; a module without a config export compiles silently. +- The graph digest now covers extracted configs, and `agent-bundle inspect --routes` surfaces them per route. Still consumer-invisible: no public authoring surface changes. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index b8fb36665..77d682d6b 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -118,7 +118,7 @@ simply not been built yet is a validation **warning** that only | `AB4749` | error (build) | A payload directory overlaps the artifact `--output` root. | | `AB4750` | info | A payload is older than the newest project source file and may be stale; rerun the project's own build if so. | -## Route graph (`AB4800`–`AB4804`) +## Route graph (`AB4800`–`AB4806`) The route-graph compiler discovers conventional route modules (`src/mcp//{tools,resources,prompts,apps}/*`, `src/events/*/*`, @@ -129,6 +129,19 @@ and the compiler never silently picks a side. Modules that explicit claimed by that declaration and never become routes — config always wins. `agent-bundle inspect --routes` dumps the compiled graph. +Each route's `config` export is extracted statically — the module is parsed +with the TypeScript compiler, never executed — from a single top-level +`export const config = ` declaration. The accepted expression +grammar is: object literals whose property names are identifiers, string +literals, or numeric literals (no computed names, spreads, shorthand +references, methods, or accessors); array literals without spreads or holes; +string literals and substitution-free template literals; numeric literals, +optionally wrapped in unary `+`/`-`; `true`, `false`, and `null`; and +`as`/`satisfies` casts, non-null assertions, and parentheses around any +accepted form. Anything else is dynamic: the route compiles with an empty +config beside a named `AB4806` error. A module without a `config` export +compiles silently with an empty config. + | Code | Severity | Trigger | | --- | --- | --- | | `AB4800` | error | An MCP server has both discovered route modules under `src/mcp//` and an existing entry claim (the conventional `src/mcp/.ts` module, or a declared `entry`/`command`/`url`) without an explicit `routes.servers.` mode. | @@ -136,6 +149,8 @@ claimed by that declaration and never become routes — config always wins. | `AB4802` | error | Two route modules derive the same route id (for example `.ts` and `.tsx` siblings with one stem). | | `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. | ## Development package build (`AB7103`) diff --git a/packages/agent-bundle/package.json b/packages/agent-bundle/package.json index 9d773619c..23e2f0749 100644 --- a/packages/agent-bundle/package.json +++ b/packages/agent-bundle/package.json @@ -81,6 +81,7 @@ "ignore": "7.0.6", "jiti": "2.7.0", "open": "11.0.1", + "typescript-5": "npm:typescript@5.6.1-rc", "ws": "8.21.3", "yaml": "2.9.0" }, diff --git a/packages/agent-bundle/src/routes/config-extract.ts b/packages/agent-bundle/src/routes/config-extract.ts new file mode 100644 index 000000000..dc514c34b --- /dev/null +++ b/packages/agent-bundle/src/routes/config-extract.ts @@ -0,0 +1,284 @@ +// Aliased: the workspace toolchain is typescript@7 (native compiler, no +// single-file parse API), and a plain `typescript` dependency here would +// shadow it for rslib's declaration generation. The alias ships the 5.x +// compiler API for parsing only. +import ts from 'typescript-5'; + +import type { Diagnostic } from '../core/diagnostics.ts'; +import { deepFreeze } from '../core/freeze.ts'; +import { emptyRouteConfig } from './types.ts'; + +/** + * The statically extracted `config` export of one route module, plus the + * named diagnostics extraction raised. `config` is {@link emptyRouteConfig} + * whenever the module exports no config, the declaration is not the accepted + * `export const config = ` form (AB4805), or the expression + * leaves the accepted grammar (AB4806). + */ +export interface ExtractedRouteConfig { + readonly config: Readonly>; + readonly diagnostics: readonly Diagnostic[]; +} + +/** + * The accepted route-config expression grammar. Extraction is fully static — + * the module is parsed, never executed — so the initializer must be built + * from these forms only: + * + * - object literals whose property names are identifiers, string literals, + * or numeric literals (no computed names, spreads, shorthand references, + * methods, or accessors); + * - array literals without spreads or holes; + * - string literals and substitution-free template literals; + * - numeric literals, optionally wrapped in unary `+`/`-`; + * - `true`, `false`, and `null`; + * - `as`/`satisfies` casts, non-null assertions, and parentheses around any + * accepted form (they unwrap to their inner expression). + * + * Everything else — identifier references, calls, functions, templates with + * substitutions, `undefined`, bigints, regular expressions — is dynamic and + * raises AB4806 naming the offending construct. + */ +export const routeConfigGrammar = 'object/array/string/number/boolean/null literals, with as-const, satisfies, non-null, and parenthesis wrappers'; + +const emptyExtraction: ExtractedRouteConfig = deepFreeze({ + config: emptyRouteConfig, + diagnostics: [], +}); + +const declarationRecovery = 'Export the route config as a single top-level `export const config = { ... }` object literal, then inspect again.'; +const grammarRecovery = `Restrict the config initializer to the static grammar (${routeConfigGrammar}), then inspect again.`; + +const routeConfigError = ( + code: 'AB4805' | 'AB4806', + message: string, + recovery: string, + sourcePath: string, +): Diagnostic => ({ code, message, recovery, severity: 'error', sourcePath }); + +interface DynamicNode { + readonly description: string; + readonly node: ts.Node; +} + +type Extraction = + | { readonly kind: 'value'; readonly value: unknown } + | { readonly kind: 'dynamic'; readonly dynamic: DynamicNode }; + +const dynamic = (description: string, node: ts.Node): Extraction => + ({ dynamic: { description, node }, kind: 'dynamic' }); + +/** 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`; +}; + +/** Casts, assertions, and parentheses carry no runtime value; unwrap them. */ +const unwrapExpression = (expression: ts.Expression): ts.Expression => { + let current = expression; + while ( + ts.isParenthesizedExpression(current) || + ts.isAsExpression(current) || + ts.isSatisfiesExpression(current) || + ts.isNonNullExpression(current) || + ts.isTypeAssertionExpression(current) + ) { + current = current.expression; + } + return current; +}; + +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 extractExpression = (expression: ts.Expression): Extraction => { + const node = unwrapExpression(expression); + switch (node.kind) { + case ts.SyntaxKind.TrueKeyword: + return { kind: 'value', value: true }; + case ts.SyntaxKind.FalseKeyword: + return { kind: 'value', value: false }; + case ts.SyntaxKind.NullKeyword: + return { kind: 'value', value: null }; + default: + break; + } + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + return { kind: 'value', value: node.text }; + } + if (ts.isNumericLiteral(node)) return { kind: 'value', value: Number(node.text) }; + if (ts.isPrefixUnaryExpression(node)) { + const operand = unwrapExpression(node.operand); + if ( + ts.isNumericLiteral(operand) && + (node.operator === ts.SyntaxKind.MinusToken || node.operator === ts.SyntaxKind.PlusToken) + ) { + const magnitude = Number(operand.text); + return { kind: 'value', value: node.operator === ts.SyntaxKind.MinusToken ? -magnitude : magnitude }; + } + return dynamic(describeExpression(node), node); + } + if (ts.isArrayLiteralExpression(node)) { + const values: unknown[] = []; + for (const element of node.elements) { + if (ts.isSpreadElement(element) || ts.isOmittedExpression(element)) { + return dynamic(describeExpression(element), element); + } + const extracted = extractExpression(element); + if (extracted.kind === 'dynamic') return extracted; + values.push(extracted.value); + } + return { kind: 'value', value: values }; + } + if (ts.isObjectLiteralExpression(node)) { + const value: Record = {}; + for (const property of node.properties) { + if (!ts.isPropertyAssignment(property)) return dynamic(describeExpression(property), property); + const name = literalPropertyName(property.name); + if (name === undefined) return dynamic(describeExpression(property.name), property.name); + const extracted = extractExpression(property.initializer); + if (extracted.kind === 'dynamic') return extracted; + value[name] = extracted.value; + } + return { kind: 'value', value }; + } + return dynamic(describeExpression(node), node); +}; + +const hasExportModifier = (statement: ts.Statement): boolean => + ts.canHaveModifiers(statement) && + (ts.getModifiers(statement) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword); + +/** The named binding this pattern would introduce for `config`, if any. */ +const bindsConfigName = (name: ts.BindingName): boolean => { + if (ts.isIdentifier(name)) return name.text === 'config'; + return name.elements.some((element) => + !ts.isOmittedExpression(element) && bindsConfigName(element.name)); +}; + +interface ConfigExportSite { + /** The accepted-form initializer; absent for every rejected declaration shape. */ + readonly initializer?: ts.Expression; + readonly rejection?: string; +} + +/** Finds the first top-level statement that exports a `config` binding. */ +const findConfigExport = (sourceFile: ts.SourceFile): ConfigExportSite | undefined => { + for (const statement of sourceFile.statements) { + if (ts.isVariableStatement(statement) && hasExportModifier(statement)) { + const declaration = statement.declarationList.declarations + .find((candidate) => bindsConfigName(candidate.name)); + if (declaration === undefined) continue; + if (!ts.isIdentifier(declaration.name)) { + return { rejection: 'a destructuring declaration' }; + } + if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) { + return { rejection: 'a mutable `let`/`var` declaration' }; + } + if (declaration.initializer === undefined) { + return { rejection: 'a declaration without an initializer' }; + } + return { initializer: declaration.initializer }; + } + if (ts.isExportDeclaration(statement) && statement.exportClause !== undefined && + ts.isNamedExports(statement.exportClause)) { + const named = statement.exportClause.elements + .find((element) => element.name.text === 'config'); + if (named !== undefined) return { rejection: 'an indirect `export { config }` clause' }; + } + if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && + hasExportModifier(statement) && statement.name?.text === 'config') { + return { rejection: 'a function or class declaration' }; + } + } + return undefined; +}; + +const scriptKindOf = (relativePath: string): ts.ScriptKind => + relativePath.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS; + +const positionOf = (sourceFile: ts.SourceFile, node: ts.Node): string => { + const { character, line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); + return `${line + 1}:${character + 1}`; +}; + +/** + * Statically extracts the `export const config = ` declaration of + * one route module. The module is parsed with the TypeScript compiler and + * never executed, so only the accepted grammar (see + * {@link routeConfigGrammar}) produces a value; a module without a config + * export extracts silently to {@link emptyRouteConfig}. + */ +export const extractRouteConfig = ( + moduleText: string, + relativePath: string, + sourcePath: string, +): ExtractedRouteConfig => { + const sourceFile = ts.createSourceFile( + relativePath, + moduleText, + ts.ScriptTarget.Latest, + true, + scriptKindOf(relativePath), + ); + const site = findConfigExport(sourceFile); + if (site === undefined) return emptyExtraction; + if (site.initializer === undefined) { + return deepFreeze({ + config: emptyRouteConfig, + diagnostics: [routeConfigError( + 'AB4805', + `Route module ${relativePath} exports config through ${site.rejection!}; only a single top-level \`export const config = \` declaration is extracted.`, + declarationRecovery, + sourcePath, + )], + }); + } + const extracted = extractExpression(site.initializer); + if (extracted.kind === 'dynamic') { + return deepFreeze({ + config: emptyRouteConfig, + diagnostics: [routeConfigError( + 'AB4806', + `Route module ${relativePath} has a dynamic config: ${extracted.dynamic.description} at ${positionOf(sourceFile, extracted.dynamic.node)} is outside the static route-config grammar.`, + grammarRecovery, + sourcePath, + )], + }); + } + if (typeof extracted.value !== 'object' || extracted.value === null || Array.isArray(extracted.value)) { + return deepFreeze({ + config: emptyRouteConfig, + diagnostics: [routeConfigError( + 'AB4805', + `Route module ${relativePath} exports a ${extracted.value === null ? 'null' : Array.isArray(extracted.value) ? 'array' : typeof extracted.value} config; the config export must be an object literal.`, + declarationRecovery, + sourcePath, + )], + }); + } + return deepFreeze({ + config: extracted.value as Record, + diagnostics: [], + }); +}; diff --git a/packages/agent-bundle/src/routes/graph.ts b/packages/agent-bundle/src/routes/graph.ts index 877c2a73d..b11808049 100644 --- a/packages/agent-bundle/src/routes/graph.ts +++ b/packages/agent-bundle/src/routes/graph.ts @@ -1,9 +1,11 @@ import { existsSync, statSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; import { extname, relative, resolve } from 'node:path'; import fastGlob from 'fast-glob'; import { isProjectPathIgnored, readProjectIgnoreRules, toPosixPath } from '../config/ignore.ts'; +import { extractRouteConfig } from './config-extract.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { digest } from '../core/digest.ts'; import { deepFreeze } from '../core/freeze.ts'; @@ -292,8 +294,11 @@ const declaredMcpServer = ( return isRecord(server) ? server : undefined; }; -const compiledRoute = (module: DiscoveredRouteModule): CompiledAgentRoute => ({ - config: emptyRouteConfig, +const compiledRoute = ( + module: DiscoveredRouteModule, + config: Readonly>, +): CompiledAgentRoute => ({ + config, id: module.id, kind: module.kind, provenance: { kind: 'conventional', relativePath: module.relativePath }, @@ -301,7 +306,28 @@ const compiledRoute = (module: DiscoveredRouteModule): CompiledAgentRoute => ({ source: module.source, }); +/** + * Statically extracts one route module's `config` export from disk. A module + * a racing deletion removed simply has no config; extraction diagnostics + * (AB4805/AB4806) accumulate beside the discovery diagnostics. + */ +const extractedModuleConfig = async ( + module: DiscoveredRouteModule, + diagnostics: Diagnostic[], +): Promise>> => { + let moduleText: string; + try { + moduleText = await readFile(module.source, 'utf8'); + } catch { + return emptyRouteConfig; + } + const extracted = extractRouteConfig(moduleText, module.relativePath, module.source); + diagnostics.push(...extracted.diagnostics); + return extracted.config; +}; + const routeIdentity = (route: CompiledAgentRoute): Readonly> => ({ + config: route.config, id: route.id, kind: route.kind, relativePath: route.provenance.relativePath, @@ -402,7 +428,7 @@ export const compileRouteGraph = async ( }); continue; } - const route = compiledRoute(module); + const route = compiledRoute(module, await extractedModuleConfig(module, diagnostics)); switch (route.kind) { case 'tool': case 'resource': diff --git a/packages/agent-bundle/src/routes/index.ts b/packages/agent-bundle/src/routes/index.ts index df89df87f..42b447c06 100644 --- a/packages/agent-bundle/src/routes/index.ts +++ b/packages/agent-bundle/src/routes/index.ts @@ -1,4 +1,6 @@ export { compileRouteGraph, emptyCompiledRouteGraph, isEmptyRouteGraph } from './graph.ts'; +export { extractRouteConfig, routeConfigGrammar } from './config-extract.ts'; +export type { ExtractedRouteConfig } from './config-extract.ts'; export { inspectRouteGraph } from './inspect.ts'; export type { RouteGraphInspection } from './inspect.ts'; export { emptyRouteConfig } from './types.ts'; diff --git a/packages/agent-bundle/src/routes/types.ts b/packages/agent-bundle/src/routes/types.ts index 051cc5691..b9c3af733 100644 --- a/packages/agent-bundle/src/routes/types.ts +++ b/packages/agent-bundle/src/routes/types.ts @@ -50,14 +50,15 @@ export type CapabilityState = | { readonly state: 'prohibited'; readonly reason: string }; /** - * The route `config` export is extracted by a later release; until then every - * compiled route carries this shared frozen empty object. + * The config of a route module without an extractable `config` export: the + * module exports none, the declaration is not the accepted form (AB4805), or + * the initializer leaves the static grammar (AB4806). */ export const emptyRouteConfig: Readonly> = Object.freeze({}); /** One conventional route module compiled into the immutable route graph. */ export interface CompiledAgentRoute { - /** Static route metadata. Always {@link emptyRouteConfig} in this release. */ + /** Statically extracted from the module's `export const config` declaration; {@link emptyRouteConfig} when absent or rejected. */ readonly config: Readonly>; readonly id: string; readonly kind: CompiledRouteKind; diff --git a/packages/agent-bundle/tests/route-config-extract.test.ts b/packages/agent-bundle/tests/route-config-extract.test.ts new file mode 100644 index 000000000..311ba848d --- /dev/null +++ b/packages/agent-bundle/tests/route-config-extract.test.ts @@ -0,0 +1,92 @@ +import { expect, it } from '@rstest/core'; + +import { extractRouteConfig } from '../src/routes/config-extract.ts'; +import { emptyRouteConfig } from '../src/routes/types.ts'; + +const extract = (text: string, relativePath = 'src/mcp/notes/tools/search.ts') => + extractRouteConfig(text, relativePath, `/project/${relativePath}`); + +it('extracts the accepted literal grammar into a frozen config', () => { + const { config, diagnostics } = extract([ + 'export const config = {', + " title: 'Search notes',", + ' "annotations": { readOnlyHint: true, priority: 0.5 },', + " tags: ['notes', `search`],", + ' limits: { depth: -2, offset: +3, 42: null },', + ' flag: false,', + '} as const;', + 'export default () => null;', + ].join('\n')); + expect(diagnostics).toEqual([]); + expect(config).toEqual({ + annotations: { priority: 0.5, readOnlyHint: true }, + flag: false, + limits: { 42: null, depth: -2, offset: 3 }, + tags: ['notes', 'search'], + title: 'Search notes', + }); + expect(Object.isFrozen(config)).toBe(true); + expect(Object.isFrozen((config as { annotations: object }).annotations)).toBe(true); +}); + +it('unwraps satisfies, parentheses, and non-null wrappers', () => { + const { config, diagnostics } = extract( + "export const config = (({ mode: 'fast' }) satisfies Record)!;", + ); + expect(diagnostics).toEqual([]); + expect(config).toEqual({ mode: 'fast' }); +}); + +it('parses TSX modules whose bodies contain JSX', () => { + const { config, diagnostics } = extract([ + "export const config = { title: 'App' };", + 'export default function App() { return
hi
; }', + ].join('\n'), 'src/mcp/notes/apps/panel.tsx'); + expect(diagnostics).toEqual([]); + expect(config).toEqual({ title: 'App' }); +}); + +it('extracts silently to the empty config when no config export exists', () => { + const { config, diagnostics } = extract('export default () => null;\nconst config = { hidden: true };'); + expect(diagnostics).toEqual([]); + expect(config).toBe(emptyRouteConfig); +}); + +it.each([ + ['identifier reference', "const base = {};\nexport const config = base;", 'AB4806', 'reference to the identifier "base"'], + ['call expression', 'export const config = make();', 'AB4806', 'a call expression'], + ['template substitution', 'export const config = { title: `v${1}` };', 'AB4806', 'a template literal with substitutions'], + ['object spread', 'export const config = { ...rest };', 'AB4806', 'a spread'], + ['shorthand property', 'const title = 1;\nexport const config = { title };', 'AB4806', 'a shorthand property reference'], + ['computed name', "export const config = { ['k']: 1 };", 'AB4806', 'a computed property name'], + ['method', 'export const config = { run() { return 1; } };', 'AB4806', 'a method or accessor'], + ['array spread', 'export const config = { tags: [...list] };', 'AB4806', 'a spread'], + ['undefined value', 'export const config = { title: undefined };', 'AB4806', 'the non-JSON value `undefined`'], + ['bigint literal', 'export const config = { big: 1n };', 'AB4806', 'a bigint literal'], + ['let declaration', 'export let config = {};', 'AB4805', 'a mutable `let`/`var` declaration'], + ['destructuring', 'export const { config } = source;', 'AB4805', 'a destructuring declaration'], + ['indirect export', 'const config = {};\nexport { config };', 'AB4805', 'an indirect `export { config }` clause'], + ['function declaration', 'export function config() { return {}; }', 'AB4805', 'a function or class declaration'], + ['missing initializer', 'export declare const config: object;', 'AB4805', 'a declaration without an initializer'], + ['non-object value', "export const config = 'title';", 'AB4805', 'exports a string config'], +])('rejects %s with a named diagnostic', (_name, text, code, fragment) => { + const { config, diagnostics } = extract(text); + expect(config).toBe(emptyRouteConfig); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toMatchObject({ + code, + severity: 'error', + sourcePath: '/project/src/mcp/notes/tools/search.ts', + }); + expect(diagnostics[0]!.message).toContain(fragment); +}); + +it('names the position of the first dynamic construct', () => { + const { diagnostics } = extract([ + 'export const config = {', + " ok: 'yes',", + ' bad: compute(),', + '};', + ].join('\n')); + expect(diagnostics[0]!.message).toContain('3:8'); +}); diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index 28d638540..a34ae6adc 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -383,3 +383,54 @@ it('evaluates a stateful config factory once when inspecting the routes focus', discovered.routeGraph!.servers[0]!.routes.map((route) => route.id), ); }); + +it('extracts each route module static config export into the graph', async () => { + const root = await createRoot(); + await writeTree(root, { + 'src/mcp/curator/tools/search.ts': [ + "export const config = { annotations: { readOnlyHint: true }, title: 'Search' } as const;", + moduleSource, + ].join('\n'), + 'src/scripts/rebuild.ts': moduleSource, + }); + + const graph = await compileRouteGraph(root, fixtureConfig()); + expect(codesOf(graph.diagnostics)).toEqual([]); + const tool = graph.servers[0]!.routes[0]!; + expect(tool.config).toEqual({ annotations: { readOnlyHint: true }, title: 'Search' }); + expect(Object.isFrozen(tool.config)).toBe(true); + expect(graph.scripts[0]!.config).toBe(emptyRouteConfig); +}); + +it('compiles dynamic-config routes with an empty config beside the named error', async () => { + const root = await createRoot(); + await writeTree(root, { + 'src/mcp/curator/tools/search.ts': [ + 'const title = process.env.TITLE;', + 'export const config = { title };', + moduleSource, + ].join('\n'), + }); + + const graph = await compileRouteGraph(root, fixtureConfig()); + expect(codesOf(graph.diagnostics)).toEqual(['AB4806']); + expect(graph.diagnostics[0]!.sourcePath).toBe(join(root, 'src/mcp/curator/tools/search.ts')); + expect(graph.servers[0]!.routes[0]!.config).toBe(emptyRouteConfig); +}); + +it('covers the route config in the graph digest', async () => { + const withTitle = async (title: string): Promise => { + const root = await createRoot(); + await writeTree(root, { + 'src/scripts/rebuild.ts': [ + `export const config = { title: '${title}' };`, + moduleSource, + ].join('\n'), + }); + return (await compileRouteGraph(root, fixtureConfig())).digest; + }; + + const [left, sameAsLeft, right] = await Promise.all([withTitle('a'), withTitle('a'), withTitle('b')]); + expect(left).toBe(sameAsLeft); + expect(left).not.toBe(right); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b5806197..6188f559c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -238,6 +238,9 @@ importers: open: specifier: 11.0.1 version: 11.0.1 + typescript-5: + specifier: npm:typescript@5.6.1-rc + version: typescript@5.6.1-rc ws: specifier: 8.21.3 version: 8.21.3