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