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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/446-route-contract-reexported-default.md
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
10 changes: 6 additions & 4 deletions packages/agent-bundle/src/config/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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'
Expand Down
9 changes: 6 additions & 3 deletions packages/agent-bundle/src/routes/cli-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('; ')}.`,
Expand Down
42 changes: 1 addition & 41 deletions packages/agent-bundle/src/routes/config-extract.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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. */
Expand Down Expand Up @@ -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<Record<string, readonly string[]>> = {
'.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);
Expand Down
Loading
Loading