diff --git a/.changeset/446-route-contract-reexported-default.md b/.changeset/446-route-contract-reexported-default.md new file mode 100644 index 000000000..113b7f07b --- /dev/null +++ b/.changeset/446-route-contract-reexported-default.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Accept a re-exported default component in the route contract check (`AB4810`): `agent-bundle validate`, `inspect`, and `build` now follow `export { default } from '../shared.tsx'` and `export { Page as default } from` through relative modules (including `.js` specifiers for `.ts`/`.tsx` sources and re-export chains) and judge the default export in the module that declares it, so one tool can be placed on two generated MCP servers with a second route module that carries only its own `config` and re-exports the component and schemas from the first. A sync component behind the re-export is still `AB4810`, and the message now names the re-exported module; a default re-exported from a package the check cannot read is accepted and verified when the route loads. The same resolution applies to the layout (`AB4830`), provider (`AB4940`), event-route, routed-CLI, and bin-shared rendered-script (`AB4737`) contract checks. Fixes #446 (#524) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 353136545..93d5526b4 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -740,7 +740,7 @@ schema constants), unions, nested objects, transforms, coercions — raises | `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. | -| `AB4810` | error | A generated MCP route is missing named `inputSchema`/`resultSchema` exports or its default export is not an async function component. | +| `AB4810` | error | A generated MCP route is missing named `inputSchema`/`resultSchema` exports or its default export is not an async function component. A default re-exported from a relative module (`export { default } from '../shared.tsx'`, `export { Page as default } from`) is judged in the module that declares it and the message names that module; one re-exported from a package the check cannot read is accepted and verified when the route loads. | | `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. | diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index 6cff4035a..9a6fe1560 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -2011,7 +2011,7 @@ const scriptEntryExports = (source: string): EntryExportScan | undefined => { */ const renderedScriptExports = (source: string, relativePath: string): RouteModuleExports | undefined => { try { - return scanRouteModuleExports(readFileSync(source, 'utf8'), relativePath); + return scanRouteModuleExports(readFileSync(source, 'utf8'), relativePath, { source }); } catch { return undefined; } @@ -2062,11 +2062,13 @@ const validateConventionalScripts = ( // type-only exports); the component by the route compiler's scan (an // async default function, not mere default-export presence, since // `export default {}` would build and fail at run time). A default - // re-exported from another module (`export { default } from`) cannot - // be judged statically and is accepted; the worker still verifies it. + // re-exported from a relative module (`export { default } from`) is + // judged in that module; one the scan cannot read is accepted and the + // worker still verifies it. const hasMain = scriptEntryExports(route.source)?.hasMainExport === true; const routeExports = renderedScriptExports(route.source, relativePath); - const hasComponent = routeExports?.asyncDefault === true || routeExports?.named.has('default') === true; + const hasComponent = routeExports?.asyncDefault === true + || routeExports?.defaultReExport?.resolution === 'unresolved'; if (hasMain && hasComponent) break; const missing = !hasMain && !hasComponent ? 'neither an async default Server Component nor a named main' diff --git a/packages/agent-bundle/src/routes/cli-commands.ts b/packages/agent-bundle/src/routes/cli-commands.ts index d2d40e4ca..6abc89b60 100644 --- a/packages/agent-bundle/src/routes/cli-commands.ts +++ b/packages/agent-bundle/src/routes/cli-commands.ts @@ -350,16 +350,19 @@ export const compileCliCommands = async ( const config = routeCliConfig(route); diagnostics.push(...config.diagnostics); - const exports = scanRouteModuleExports(moduleText, relativePath); + const exports = scanRouteModuleExports(moduleText, relativePath, { source: route.source }); + // 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 missing = [ ...(argv.found ? [] : ['inputSchema']), ...(exports.named.has('resultSchema') ? [] : ['resultSchema']), ]; - if (missing.length > 0 || !exports.asyncDefault) { + if (missing.length > 0 || !asyncDefault) { const details = [ ...(missing.length === 0 ? [] : [`missing named ${missing.join(' and ')}`]), - ...(exports.asyncDefault ? [] : ['default export is not an async function']), + ...(asyncDefault ? [] : ['default export is not an async function']), ]; diagnostics.push(contractError( `CLI route ${relativePath} does not satisfy the routed command contract: ${details.join('; ')}.`, diff --git a/packages/agent-bundle/src/routes/config-extract.ts b/packages/agent-bundle/src/routes/config-extract.ts index 0c3fbf3bd..decee4f3f 100644 --- a/packages/agent-bundle/src/routes/config-extract.ts +++ b/packages/agent-bundle/src/routes/config-extract.ts @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs'; import { dirname, extname, isAbsolute, relative, resolve } from 'node:path'; // Aliased: the workspace toolchain is typescript@7 (native compiler, no @@ -10,6 +9,7 @@ import ts from 'typescript-5'; import type { Diagnostic } from '../core/diagnostics.ts'; import { deepFreeze } from '../core/freeze.ts'; import { hasExportModifier, positionOf, unwrapExpression } from './input-schema.ts'; +import { isRelativeSpecifier, moduleCandidates, readModuleFromDisk } from './module-candidates.ts'; import { emptyRouteConfig } from './types.ts'; /** The package subpath route modules import compile-time authoring helpers from. */ @@ -240,46 +240,6 @@ const scriptKindOf = (relativePath: string): ts.ScriptKind => { const parseModule = (path: string, text: string): ts.SourceFile => ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true, scriptKindOf(path)); -const readModuleFromDisk = (path: string): string | undefined => { - try { - return readFileSync(path, 'utf8'); - } catch { - // Missing, unreadable, or a directory: the specifier names no module. - return undefined; - } -}; - -const moduleExtensions: Readonly> = { - '.cjs': ['.cts', '.cjs'], - '.cts': ['.cts'], - '.js': ['.ts', '.tsx', '.js'], - '.jsx': ['.tsx', '.jsx'], - '.mjs': ['.mts', '.mjs'], - '.mts': ['.mts'], - '.ts': ['.ts'], - '.tsx': ['.tsx'], -}; - -/** - * The on-disk candidates one relative specifier may name, in TypeScript - * resolution order: an explicit `.ts`/`.tsx` extension is exact, a `.js`-style - * extension maps onto its TypeScript source, and an extensionless specifier - * probes `.ts`, `.tsx`, and an index module. - */ -const moduleCandidates = (fromDirectory: string, specifier: string): readonly string[] => { - const base = resolve(fromDirectory, specifier); - const extension = extname(specifier).toLowerCase(); - const mapped = moduleExtensions[extension]; - if (mapped !== undefined) { - const stem = base.slice(0, -extension.length); - return mapped.map((candidate) => `${stem}${candidate}`); - } - return [`${base}.ts`, `${base}.tsx`, resolve(base, 'index.ts'), resolve(base, 'index.tsx')]; -}; - -const isRelativeSpecifier = (specifier: string): boolean => - specifier.startsWith('./') || specifier.startsWith('../'); - const insideProject = (projectRoot: string | undefined, path: string): boolean => { if (projectRoot === undefined) return true; const relativePath = relative(projectRoot, path); diff --git a/packages/agent-bundle/src/routes/contract.ts b/packages/agent-bundle/src/routes/contract.ts index 7b9b58a6f..db0a30e68 100644 --- a/packages/agent-bundle/src/routes/contract.ts +++ b/packages/agent-bundle/src/routes/contract.ts @@ -1,6 +1,9 @@ +import { dirname } from 'node:path'; + import ts from 'typescript-5'; import type { Diagnostic } from '../core/diagnostics.ts'; +import { isRelativeSpecifier, moduleCandidates, readModuleFromDisk } from './module-candidates.ts'; const modifier = (node: ts.Node, kind: ts.SyntaxKind): boolean => ts.canHaveModifiers(node) && (ts.getModifiers(node)?.some((item) => item.kind === kind) ?? false); @@ -28,31 +31,119 @@ const diagnostic = ( recovery: string, ): Diagnostic => ({ code, message, recovery, severity: 'error', sourcePath }); +/** + * A default export the module re-exports from another module + * (`export { default } from './shared.tsx'`, `export { Page as default } from '../page.tsx'`). + */ +export interface RouteDefaultReExport { + /** The binding named in the target module (`default` for `export { default } from`). */ + readonly name: string; + /** + * `followed`: the target module was read and its binding judged, so + * `asyncDefault`/`defaultFunction` describe that binding. `unresolved`: the + * target is a bare specifier, unreadable, or part of a re-export cycle, so + * the default export's shape is unknown statically and the worker judges it + * at run time. + */ + readonly resolution: 'followed' | 'unresolved'; + readonly specifier: string; +} + /** The statically scanned export surface of one route module. */ export interface RouteModuleExports { /** True when the default export is an async function or arrow function. */ readonly asyncDefault: boolean; /** True when the default export is a function or arrow function. */ readonly defaultFunction: boolean; + /** Set when the default export is re-exported from another module. */ + readonly defaultReExport?: RouteDefaultReExport; readonly named: ReadonlySet; + /** Exported names bound to an async function or arrow function. */ + readonly namedAsyncFunctions: ReadonlySet; + /** Exported names bound to a function or arrow function. */ + readonly namedFunctions: ReadonlySet; /** True when the module exports `execute` or `render` (the retired split contract). */ readonly splitExport: boolean; } +/** Where the scanned module lives, so relative re-exports can be followed. */ +export interface ScanRouteModuleOptions { + /** + * Reads one re-export target's source text by absolute path; undefined when + * the file is unreadable. Defaults to a synchronous file read. + */ + readonly readModule?: (absolutePath: string) => string | undefined; + /** The scanned module's absolute path; relative re-exports resolve against its directory. */ + readonly source?: string; +} + +interface PendingReExport { + readonly name: string; + readonly specifier: string; +} + /** Scans one route module's top-level export surface without evaluating it. */ export const scanRouteModuleExports = ( moduleText: string, relativePath: string, + options: ScanRouteModuleOptions = {}, ): RouteModuleExports => { + const { unresolvedNamed: _unresolvedNamed, ...exports } = scanModuleExports(moduleText, relativePath, options, new Set()); + return Object.freeze(exports); +}; + +/** The scan plus the named re-exports whose shape stayed unknown, so a chain propagates "unknown" rather than "not a function". */ +interface ScannedModuleExports extends RouteModuleExports { + readonly unresolvedNamed: ReadonlySet; +} + +/** What one binding of a scanned module is known to be. */ +interface BindingShape { + readonly asyncFunction: boolean; + readonly function: boolean; + /** True when the binding is a re-export the scan could not follow. */ + readonly unresolved: boolean; +} + +const bindingShape = (exports: ScannedModuleExports, name: string): BindingShape => name === 'default' + ? { + asyncFunction: exports.asyncDefault, + function: exports.defaultFunction, + unresolved: exports.defaultReExport?.resolution === 'unresolved', + } + : { + asyncFunction: exports.namedAsyncFunctions.has(name), + function: exports.namedFunctions.has(name), + unresolved: exports.unresolvedNamed.has(name), + }; + +const scanModuleExports = ( + moduleText: string, + relativePath: string, + options: ScanRouteModuleOptions, + visited: ReadonlySet, +): ScannedModuleExports => { const sourceFile = ts.createSourceFile(relativePath, moduleText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); const asyncFunctionBindings = new Set(); const functionBindings = new Set(); const named = new Set(); + const namedAsyncFunctions = new Set(); + const namedFunctions = new Set(); + // Exported names aliasing a local binding (`export { Foo as bar }`), judged + // once every declaration is seen, and names re-exported from other modules. + const namedAliases = new Map(); + const namedReExports = new Map(); let asyncDefault = false; let defaultFunction = false; let defaultIdentifier: string | undefined; + let defaultReExport: PendingReExport | undefined; let splitExport = false; + const addNamed = (name: string): void => { + named.add(name); + if (name === 'execute' || name === 'render') splitExport = true; + }; + for (const statement of sourceFile.statements) { if (ts.isVariableStatement(statement)) { for (const declaration of statement.declarationList.declarations) { @@ -63,8 +154,8 @@ export const scanRouteModuleExports = ( if (asynchronous(initializer)) asyncFunctionBindings.add(declaration.name.text); } if (exported(statement)) { - named.add(declaration.name.text); - if (declaration.name.text === 'execute' || declaration.name.text === 'render') splitExport = true; + addNamed(declaration.name.text); + namedAliases.set(declaration.name.text, declaration.name.text); } } continue; @@ -77,9 +168,11 @@ export const scanRouteModuleExports = ( if (exported(statement) && modifier(statement, ts.SyntaxKind.DefaultKeyword)) { defaultFunction = true; asyncDefault = asynchronous(statement); + defaultIdentifier = undefined; + defaultReExport = undefined; } else if (exported(statement) && statement.name !== undefined) { - named.add(statement.name.text); - if (statement.name.text === 'execute' || statement.name.text === 'render') splitExport = true; + addNamed(statement.name.text); + namedAliases.set(statement.name.text, statement.name.text); } continue; } @@ -87,22 +180,36 @@ export const scanRouteModuleExports = ( const expression = unwrappedExpression(statement.expression); defaultFunction = ts.isArrowFunction(expression) || ts.isFunctionExpression(expression); asyncDefault = defaultFunction && asynchronous(expression); - if (ts.isIdentifier(expression)) defaultIdentifier = expression.text; + defaultIdentifier = ts.isIdentifier(expression) ? expression.text : undefined; + defaultReExport = undefined; continue; } if (ts.isExportDeclaration(statement) && statement.exportClause !== undefined && ts.isNamedExports(statement.exportClause)) { // Type-only exports (`export type { X }`, `export { type X as default }`) // emit no JavaScript binding, so they satisfy no runtime contract. if (statement.isTypeOnly) continue; + const specifier = statement.moduleSpecifier !== undefined && ts.isStringLiteral(statement.moduleSpecifier) + ? statement.moduleSpecifier.text + : undefined; for (const element of statement.exportClause.elements) { if (element.isTypeOnly) continue; const name = element.name.text; - if (name === 'default' && statement.moduleSpecifier === undefined) { - defaultIdentifier = element.propertyName?.text ?? name; + const propertyName = element.propertyName?.text ?? name; + if (name === 'default') { + if (specifier === undefined) { + defaultIdentifier = propertyName; + defaultReExport = undefined; + } else { + // `export { default } from` / `export { Page as default } from`: the + // default export lives in another module and is judged there. + defaultIdentifier = undefined; + defaultReExport = { name: propertyName, specifier }; + } continue; } - named.add(name); - if (name === 'execute' || name === 'render') splitExport = true; + addNamed(name); + if (specifier === undefined) namedAliases.set(name, propertyName); + else namedReExports.set(name, { name: propertyName, specifier }); } } } @@ -111,23 +218,107 @@ export const scanRouteModuleExports = ( defaultFunction = functionBindings.has(defaultIdentifier); asyncDefault = asyncFunctionBindings.has(defaultIdentifier); } + for (const [name, local] of namedAliases) { + if (functionBindings.has(local)) namedFunctions.add(name); + if (asyncFunctionBindings.has(local)) namedAsyncFunctions.add(name); + } - return Object.freeze({ asyncDefault, defaultFunction, named, splitExport }); + // Re-exports are followed lazily and once per target module: a placement + // that re-exports its component and schemas from one shared route reads + // that route a single time. + const targets = new Map(); + const shapeOf = ({ name, specifier }: PendingReExport): BindingShape => { + if (!targets.has(specifier)) targets.set(specifier, followReExport(specifier, options, visited)); + const exports = targets.get(specifier); + return exports === undefined + ? { asyncFunction: false, function: false, unresolved: true } + : bindingShape(exports, name); + }; + let resolvedDefaultReExport: RouteDefaultReExport | undefined; + if (defaultReExport !== undefined) { + const shape = shapeOf(defaultReExport); + resolvedDefaultReExport = { ...defaultReExport, resolution: shape.unresolved ? 'unresolved' : 'followed' }; + defaultFunction = shape.function; + asyncDefault = shape.asyncFunction; + } + const unresolvedNamed = new Set(); + for (const [name, reExport] of namedReExports) { + const shape = shapeOf(reExport); + if (shape.function) namedFunctions.add(name); + if (shape.asyncFunction) namedAsyncFunctions.add(name); + if (shape.unresolved) unresolvedNamed.add(name); + } + + return { + asyncDefault, + defaultFunction, + ...(resolvedDefaultReExport === undefined ? {} : { defaultReExport: Object.freeze(resolvedDefaultReExport) }), + named, + namedAsyncFunctions, + namedFunctions, + splitExport, + unresolvedNamed, + }; }; +/** + * Scans the module one relative re-export names. Undefined when the target + * cannot be judged statically: a bare specifier, no readable candidate file, + * no `source` to resolve against, or a re-export cycle. + */ +const followReExport = ( + specifier: string, + options: ScanRouteModuleOptions, + visited: ReadonlySet, +): ScannedModuleExports | undefined => { + if (options.source === undefined || !isRelativeSpecifier(specifier)) return undefined; + const read = options.readModule ?? readModuleFromDisk; + const seen = new Set([...visited, options.source]); + // The same candidate order the config extractor uses for imported + // constants, so both static scans name one file for one specifier. + for (const candidate of moduleCandidates(dirname(options.source), specifier)) { + if (seen.has(candidate)) return undefined; + const text = read(candidate); + if (text === undefined) continue; + return scanModuleExports(text, candidate, { ...options, source: candidate }, seen); + } + return undefined; +}; + +/** + * Whether the scanned default export is judged an async function component. + * A default re-exported from a module the scan could not read (a bare + * specifier, for example) cannot be judged statically and is accepted here; + * the worker still verifies the component when it loads the route. + */ +const acceptsAsyncDefault = ({ asyncDefault, defaultReExport }: RouteModuleExports): boolean => + asyncDefault || defaultReExport?.resolution === 'unresolved'; + +/** Same acceptance for the sync-or-async function contracts (layouts, providers). */ +const acceptsDefaultFunction = ({ defaultFunction, defaultReExport }: RouteModuleExports): boolean => + defaultFunction || defaultReExport?.resolution === 'unresolved'; + +/** Names the offending default export: the local one, or the binding a followed re-export resolved to. */ +const defaultExportDetail = ({ defaultReExport }: RouteModuleExports, expectation: string): string => + defaultReExport === undefined + ? `default export is not ${expectation}` + : `default export re-exported from ${JSON.stringify(defaultReExport.specifier)} (${defaultReExport.name}) is not ${expectation}`; + /** Validates G8's one executable MCP route contract without evaluating the module. */ export const validateRouteModuleContract = ( moduleText: string, relativePath: string, sourcePath: string, ): readonly Diagnostic[] => { - const { asyncDefault, named, splitExport } = scanRouteModuleExports(moduleText, relativePath); + const exports = scanRouteModuleExports(moduleText, relativePath, { source: sourcePath }); + const { named, splitExport } = exports; + const asyncDefault = acceptsAsyncDefault(exports); const missing = ['inputSchema', 'resultSchema'].filter((name) => !named.has(name)); const diagnostics: Diagnostic[] = []; if (missing.length > 0 || !asyncDefault) { const details = [ ...(missing.length === 0 ? [] : [`missing named ${missing.join(' and ')}`]), - ...(asyncDefault ? [] : ['default export is not an async function component']), + ...(asyncDefault ? [] : [defaultExportDetail(exports, 'an async function component')]), ]; diagnostics.push(diagnostic( 'AB4810', @@ -153,12 +344,13 @@ export const validateEventRouteModuleContract = ( relativePath: string, sourcePath: string, ): readonly Diagnostic[] => { - const { asyncDefault, splitExport } = scanRouteModuleExports(moduleText, relativePath); + const exports = scanRouteModuleExports(moduleText, relativePath, { source: sourcePath }); + const { splitExport } = exports; const diagnostics: Diagnostic[] = []; - if (!asyncDefault) { + if (!acceptsAsyncDefault(exports)) { diagnostics.push(diagnostic( 'AB4810', - `Event route module ${relativePath} does not satisfy the public route contract: default export is not an async function component.`, + `Event route module ${relativePath} does not satisfy the public route contract: ${defaultExportDetail(exports, 'an async function component')}.`, sourcePath, 'Export one async default Server Component receiving { canonical, native, signal }.', )); @@ -186,10 +378,11 @@ export const validateLayoutModuleContract = ( relativePath: string, sourcePath: string, ): readonly Diagnostic[] => { - const { defaultFunction, named, splitExport } = scanRouteModuleExports(moduleText, relativePath); + const exports = scanRouteModuleExports(moduleText, relativePath, { source: sourcePath }); + const { named, splitExport } = exports; const routeExports = ['config', 'inputSchema', 'resultSchema'].filter((name) => named.has(name)); const details = [ - ...(defaultFunction ? [] : ['default export is not a function component']), + ...(acceptsDefaultFunction(exports) ? [] : [defaultExportDetail(exports, 'a function component')]), ...(routeExports.length === 0 ? [] : [`exports route-only ${routeExports.join(', ')}`]), ...(splitExport ? ['exports execute or render'] : []), ]; @@ -208,11 +401,11 @@ export const validateProviderModuleContract = ( relativePath: string, sourcePath: string, ): readonly Diagnostic[] => { - const { defaultFunction } = scanRouteModuleExports(moduleText, relativePath); - if (defaultFunction) return Object.freeze([]); + const exports = scanRouteModuleExports(moduleText, relativePath, { source: sourcePath }); + if (acceptsDefaultFunction(exports)) return Object.freeze([]); return Object.freeze([diagnostic( 'AB4940', - `Provider module ${relativePath} does not satisfy the public provider contract: default export is not a function.`, + `Provider module ${relativePath} does not satisfy the public provider contract: ${defaultExportDetail(exports, 'a function')}.`, sourcePath, 'Default-export a provider factory receiving { invocation, signal }.', )]); diff --git a/packages/agent-bundle/src/routes/module-candidates.ts b/packages/agent-bundle/src/routes/module-candidates.ts new file mode 100644 index 000000000..e275590f6 --- /dev/null +++ b/packages/agent-bundle/src/routes/module-candidates.ts @@ -0,0 +1,50 @@ +import { readFileSync } from 'node:fs'; +import { extname, resolve } from 'node:path'; + +/** + * How one relative specifier's extension maps onto the TypeScript sources it + * may name, in resolution order: the emitted extension probes its source + * first, then the emitted file itself. + */ +const moduleExtensions: Readonly> = { + '.cjs': ['.cts', '.cjs'], + '.cts': ['.cts'], + '.js': ['.ts', '.tsx', '.js'], + '.jsx': ['.tsx', '.jsx'], + '.mjs': ['.mts', '.mjs'], + '.mts': ['.mts'], + '.ts': ['.ts'], + '.tsx': ['.tsx'], +}; + +/** + * The on-disk candidates one relative specifier may name, in TypeScript + * resolution order: an explicit `.ts`/`.tsx` extension is exact, a `.js`-style + * extension maps onto its TypeScript source, and an extensionless specifier + * probes `.ts`, `.tsx`, and an index module. Shared by every static scan that + * follows a route module's relative imports or re-exports without evaluating + * it, so they all agree on which file a specifier names. + */ +export const moduleCandidates = (fromDirectory: string, specifier: string): readonly string[] => { + const base = resolve(fromDirectory, specifier); + const extension = extname(specifier).toLowerCase(); + const mapped = moduleExtensions[extension]; + if (mapped !== undefined) { + const stem = base.slice(0, -extension.length); + return mapped.map((candidate) => `${stem}${candidate}`); + } + return [`${base}.ts`, `${base}.tsx`, resolve(base, 'index.ts'), resolve(base, 'index.tsx')]; +}; + +/** True for a specifier that names a module by path relative to the importing module. */ +export const isRelativeSpecifier = (specifier: string): boolean => + specifier.startsWith('./') || specifier.startsWith('../'); + +/** Reads one candidate module's text; undefined when missing, unreadable, or a directory. */ +export const readModuleFromDisk = (path: string): string | undefined => { + try { + return readFileSync(path, 'utf8'); + } catch { + return undefined; + } +}; diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index 089fb853a..9ee91a7cd 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -311,8 +311,8 @@ it('gates a bin-claimed rendered script with AB4737 only when it exports no main '};', '', ].join('\n'), - // A default re-exported from a private sibling cannot be judged - // statically; it is accepted (the worker still verifies it at run time). + // A default re-exported from a private sibling is judged in that sibling + // (#446): an async component there satisfies the rendered surface here. 'src/scripts/_component.tsx': 'export default async () => undefined;\n', 'src/scripts/render-reexport.tsx': [ 'export const main = async (argv: readonly string[]): Promise => argv.length;', @@ -1504,6 +1504,173 @@ it('validates the single async route-module authoring contract statically', asyn ]); }); +it('follows a re-exported default to the module that declares it when one tool is placed on two servers (#446)', async () => { + const root = await createRoot(); + await writeTree(root, { + // The primary placement: a full route module. + 'src/mcp/public/tools/search.tsx': [ + "export const config = { description: 'Search.' };", + 'export const inputSchema = {};', + 'export const resultSchema = {};', + 'export default async function Search() { return undefined; }', + '', + ].join('\n'), + // The second placement carries its own config and re-exports the rest. + 'src/mcp/library/tools/search.tsx': [ + "export const config = { description: 'Search from the widget server.' };", + "export { default, inputSchema, resultSchema } from '../../public/tools/search.tsx';", + '', + ].join('\n'), + // A named component aliased to default, through a shared page module. + 'src/pages/download.tsx': 'export async function DownloadPage() { return undefined; }\n', + 'src/mcp/library/tools/download.tsx': [ + "export const config = { description: 'Download.' };", + "export { DownloadPage as default } from '../../../pages/download.tsx';", + "export { inputSchema, resultSchema } from '../../public/tools/search.tsx';", + '', + ].join('\n'), + // A chain: the shared module itself re-exports its default, and a + // `.js` specifier names the emitted extension of a `.tsx` source. + 'src/pages/_delete-impl.tsx': 'export default async () => undefined;\n', + 'src/pages/delete.tsx': "export { default } from './_delete-impl.js';\n", + 'src/mcp/library/tools/delete.tsx': [ + "export const config = { description: 'Delete.' };", + "export { default } from '../../../pages/delete.tsx';", + "export { inputSchema, resultSchema } from '../../public/tools/search.tsx';", + '', + ].join('\n'), + // A default from a package the scan cannot read is verified at run time. + 'src/mcp/library/tools/external.tsx': [ + "export const config = { description: 'External.' };", + 'export const inputSchema = {};', + 'export const resultSchema = {};', + "export { default } from '@shared/routes/external';", + '', + ].join('\n'), + // The re-exported default is judged where it is declared: a sync + // function component there is still AB4810 here, naming the target. + 'src/pages/sync.tsx': 'export default function SyncPage() { return undefined; }\n', + 'src/mcp/library/tools/sync.tsx': [ + "export const config = { description: 'Sync.' };", + "export { default } from '../../../pages/sync.tsx';", + "export { inputSchema, resultSchema } from '../../public/tools/search.tsx';", + '', + ].join('\n'), + // A type-only default re-export emits no binding and never satisfies the contract. + 'src/mcp/library/tools/typed.tsx': [ + "export const config = { description: 'Typed.' };", + "export { type default } from '../../public/tools/search.tsx';", + "export { inputSchema, resultSchema } from '../../public/tools/search.tsx';", + '', + ].join('\n'), + }); + + const graph = await compileRouteGraph(root, fixtureConfig()); + + expect(graph.diagnostics.map(({ code, message, sourcePath }) => ({ + code, + message, + source: sourcePath?.slice(root.length + 1).replaceAll('\\', '/'), + }))).toEqual([ + { + code: 'AB4810', + message: 'Route module src/mcp/library/tools/sync.tsx does not satisfy the public route contract: default export re-exported from "../../../pages/sync.tsx" (default) is not an async function component.', + source: 'src/mcp/library/tools/sync.tsx', + }, + { + code: 'AB4810', + message: 'Route module src/mcp/library/tools/typed.tsx does not satisfy the public route contract: default export is not an async function component.', + source: 'src/mcp/library/tools/typed.tsx', + }, + ]); + expect(graph.servers.map((server) => [server.name, server.routes.map((route) => route.id)])).toEqual([ + ['library', [ + 'tool:library/delete', + 'tool:library/download', + 'tool:library/external', + 'tool:library/search', + 'tool:library/sync', + 'tool:library/typed', + ]], + ['public', ['tool:public/search']], + ]); +}); + +it('reports the scanned export surface of a re-exporting module', () => { + const modules = new Map([ + ['/project/src/shared/page.tsx', [ + 'export const helper = () => 1;', + 'export async function Page() { return undefined; }', + 'export { Page as Alias };', + 'export default Page;', + '', + ].join('\n')], + ['/project/src/shared/cycle-a.tsx', "export { default } from './cycle-b.tsx';\n"], + ['/project/src/shared/cycle-b.tsx', "export { default } from './cycle-a.tsx';\n"], + // An emitted `.js` beside its `.tsx` source: TypeScript resolution order + // names the source first, so the async component is judged, not the + // stale sync emit. + ['/project/src/shared/dual.js', 'export default function Dual() { return undefined; }\n'], + ['/project/src/shared/dual.tsx', 'export default async function Dual() { return undefined; }\n'], + ['/project/src/shared/dir/index.tsx', 'export default async () => undefined;\n'], + ['/project/src/shared/legacy.cts', 'export default async function Legacy() { return undefined; }\n'], + ]); + const readModule = (path: string): string | undefined => modules.get(path); + const scan = (text: string, source: string): routesModule.RouteModuleExports => + routesModule.scanRouteModuleExports(text, source.slice('/project/'.length), { readModule, source }); + + // The same TypeScript candidate order the config extractor uses. + expect(scan("export { default } from '../shared/dual.js';\n", '/project/src/mcp/dual.tsx').asyncDefault).toBe(true); + expect(scan("export { default } from '../shared/dir';\n", '/project/src/mcp/dir.tsx').asyncDefault).toBe(true); + expect(scan("export { default } from '../shared/legacy.cjs';\n", '/project/src/mcp/legacy.tsx').asyncDefault).toBe(true); + expect(scan("export { default } from '../shared/dual.ts';\n", '/project/src/mcp/exact.tsx').defaultReExport?.resolution).toBe('unresolved'); + + const followed = routesModule.scanRouteModuleExports( + "export { default, helper, Alias as Component } from '../shared/page.tsx';\n", + 'src/mcp/a/tools/x.tsx', + { readModule, source: '/project/src/mcp/x.tsx' }, + ); + expect(followed.asyncDefault).toBe(true); + expect(followed.defaultFunction).toBe(true); + expect(followed.defaultReExport).toEqual({ name: 'default', resolution: 'followed', specifier: '../shared/page.tsx' }); + expect([...followed.named].sort()).toEqual(['Component', 'helper']); + expect([...followed.namedFunctions].sort()).toEqual(['Component', 'helper']); + expect([...followed.namedAsyncFunctions]).toEqual(['Component']); + + const aliased = routesModule.scanRouteModuleExports( + "export { Page as default } from '../shared/page.tsx';\n", + 'src/mcp/a/tools/y.tsx', + { readModule, source: '/project/src/mcp/y.tsx' }, + ); + expect(aliased.asyncDefault).toBe(true); + expect(aliased.defaultReExport?.name).toBe('Page'); + + // Without a source there is nothing to resolve against. + const sourceless = routesModule.scanRouteModuleExports( + "export { default } from '../shared/page.tsx';\n", + 'src/mcp/a/tools/z.tsx', + { readModule }, + ); + expect(sourceless.asyncDefault).toBe(false); + expect(sourceless.defaultReExport?.resolution).toBe('unresolved'); + + const cyclic = routesModule.scanRouteModuleExports( + modules.get('/project/src/shared/cycle-a.tsx')!, + 'src/shared/cycle-a.tsx', + { readModule, source: '/project/src/shared/cycle-a.tsx' }, + ); + expect(cyclic.asyncDefault).toBe(false); + expect(cyclic.defaultReExport).toEqual({ name: 'default', resolution: 'unresolved', specifier: './cycle-b.tsx' }); + + // A relative target no candidate file satisfies cannot be judged either. + const missing = routesModule.scanRouteModuleExports( + "export { default } from './missing.tsx';\n", + 'src/mcp/a/tools/m.tsx', + { readModule, source: '/project/src/mcp/m.tsx' }, + ); + expect(missing.defaultReExport?.resolution).toBe('unresolved'); +}); + it('validates provider default factories with AB4940', async () => { const root = await createRoot(); await writeTree(root, { diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index 8f5b56379..2bd89c9a6 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -59,6 +59,28 @@ call renders through a warm internal Flight dispatcher and lowers the final Agen legal MCP output. Flight is an implementation transport inside the generated runtime — never a public host wire protocol, and raw Flight bytes never cross the MCP wire. +A route may re-export its component and schemas from another module. This is how one tool is +placed on two generated servers when only `config` differs between the placements — an MCP App +`tools/call`, for example, reaches the server that served the widget: + +```tsx +// src/mcp/widgets/tools/status.tsx +import type { ToolConfig } from 'agent-bundle'; + +export const config = { + _meta: { ui: { resourceUri: 'ui://widgets/status.html' } }, + annotations: { readOnlyHint: true }, + description: 'Read runtime status.', +} satisfies ToolConfig; + +export { default, inputSchema, resultSchema } from '../../curator/tools/status.tsx'; +``` + +The route contract check (`AB4810`) follows relative re-exports (`export { default } from`, +`export { Page as default } from`) and judges the default export in the module that declares it, +so a sync component behind the re-export is still reported here. A default re-exported from a +package the check cannot read is accepted and verified when the route loads. + `ToolConfig` and `ToolRouteProps` are public types: ```ts twoslash diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx index 2f2d0dc93..027044f3c 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -55,6 +55,26 @@ export default async function Status({ input, signal }: ToolRouteProps