From 92a1cf732ded9809faa3e8a397e46b7355bde03f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 08:30:56 +0000 Subject: [PATCH 01/12] fix(routes): keep bin- and lib-claimed src/scripts modules in script discovery (#389) A bin (or lib) entry referencing a conventional src/scripts/.ts module used to remove it from route discovery, so the artifact silently lost scripts/.mjs while inspect still listed the bin. The package build writes to dist/, disjoint from every artifact output, so the same entry now ships on both surfaces. scripts, hooks, and mcp entries still claim the module they reference. A bin pointing at a rendered .tsx script is the new AB4737 error: the Server Component cannot double as a bin's main. --- .changeset/389-bin-claim-script-discovery.md | 5 ++ docs/diagnostics.md | 36 +++++++++++++-- docs/entry-conventions.md | 29 +++++++++++- packages/agent-bundle/README.md | 6 ++- packages/agent-bundle/src/config/validate.ts | 36 ++++++++++++++- packages/agent-bundle/src/routes/graph.ts | 46 ++++++++++++++----- .../agent-bundle/tests/normalization.test.ts | 32 +++++++++++++ .../agent-bundle/tests/package-build.test.ts | 39 ++++++++++++++++ .../agent-bundle/tests/route-graph.test.ts | 23 ++++++++++ 9 files changed, 232 insertions(+), 20 deletions(-) create mode 100644 .changeset/389-bin-claim-script-discovery.md diff --git a/.changeset/389-bin-claim-script-discovery.md b/.changeset/389-bin-claim-script-discovery.md new file mode 100644 index 000000000..d26337ee1 --- /dev/null +++ b/.changeset/389-bin-claim-script-discovery.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +A `bin` (or `lib`) entry that references a conventional `src/scripts/.ts` module no longer removes it from script discovery. The npm bin (`dist/bin/.js`) and the artifact script (`scripts/.mjs`) are disjoint outputs, so the same entry now ships on both surfaces and `inspect` lists it under both `packageBuild.bins` and `scripts`; previously the artifact script silently disappeared. Explicit `scripts`, `hooks`, and `mcp` entries still claim the module they reference. A `bin` entry pointing at a rendered `src/scripts/.tsx` script is the new `AB4737` error, because a rendered script's Server Component cannot double as a bin's `main`. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 1c134e17a..ce1b9b98e 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -195,16 +195,25 @@ development-only fallback can never produce a release artifact, so | `AB4011` | warning | `package.json` is unusable — unparsable, not a JSON object, or symlinked outside the project root. | | `AB4013` | error (build) | `agent-bundle build` refuses a project with no release version: `plugin.version` is omitted and `package.json` declares no valid semantic version. | -## Migration nudges (`AB4730`–`AB4736`) +## Migration nudges and convention claims (`AB4730`–`AB4737`) The entry conventions and the framework-owned stdio lifecycle shell (RFC #50) replaced patterns consumers previously wrote by hand. When `validate`, `inspect`, `build`, or `dev` prepares project source and finds one of those pre-convention patterns, it reports a migration diagnostic. `AB4730`–`AB4735` -are **informational** nudges and never block anything. `AB4736` is an error: -the removed top-level authored-document locations are no longer discovered, -so the compiler refuses to omit them silently. The CLI prints these in human -`validate` output and includes them in every `--json` diagnostics array. +are **informational** nudges and never block anything. `AB4736` and `AB4737` +are errors: the removed top-level authored-document locations are no longer +discovered, and a rendered script cannot double as a package bin, so the +compiler refuses to omit or misbuild them silently. The CLI prints these in +human `validate` output and includes them in every `--json` diagnostics array. + +Which explicit config keys *claim* a conventional module out of discovery is +tabulated in `docs/entry-conventions.md` ("Which config keys claim a +conventional module"). In short: `scripts`, `hooks`, and `mcp` entries claim +the module they reference; `bin` and `lib` entries claim every conventional +module **except** one under `src/scripts/`, which keeps shipping as an +artifact script beside the package output because the two outputs are +disjoint. That dual-surface shape is intentional and raises no diagnostic. ### `AB4730` — self-connecting stdio MCP entry @@ -276,6 +285,23 @@ Recover: move the document under `src/skills/`, `src/commands/`, or `src/rules/`. Explicit `skills` paths remain valid anywhere. Published artifact paths remain `skills/`, `commands/`, and `rules/`. +### `AB4737` — rendered script claimed as a package bin entry + +An explicit `bin` entry references a conventional rendered script +(`src/scripts/.tsx` or `.jsx`). A plain `src/scripts/.ts` module +ships happily on both surfaces — the npm bin envelope calls its `main(argv)` +and the artifact script is the same bundle — but a rendered script's default +export is an async Server Component the Agent renderer drives with +`{ argv, signal }` props. The bin envelope would call that component as +`main(argv)` and produce a bin that renders nothing, so the compiler refuses +the pair instead of emitting a broken executable beside a working script. +The message names every `bin` entry referencing the module. + +Recover: point the `bin` entry at a plain module that exports `main`; rename +the script to `.ts` so one plain module ships as both the bin and the +artifact script; or prefix a path segment with `_` (`src/scripts/_name.tsx`) +to keep the module out of script discovery and bin-only. + ## Prebuilt payloads (`AB4740`–`AB4750`) The `payload` block and `{ prebuilt: ... }` entries (see diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 006154c55..ae59d9fd4 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -77,8 +77,8 @@ entries carry `provenance.kind: 'conventional'` in the normalized model. | `src/mcp/.ts` | Stdio entry for the declared MCP server `` that names no `entry`, `command`, or `url`. | Declare `entry` explicitly | | `src/mcp//{tools,resources,prompts}/*.{ts,tsx}` | Generated MCP server routes; path supplies identity and each executable module supplies static `config`, schemas, and one async default Server Component. | Set `routes.servers.` to `custom`, `command`, or `remote` | | `src/mcp//apps/*.{ts,tsx}` | Browser MCP App entry compiled to self-contained HTML and registered on the generated server; static `config.resourceUri` is required. An optional `config.template` HTML shell resolves relative to the route module like its imports (`'./dashboard.html'`); the legacy project-root-relative form is accepted only while unambiguous (`AB4827` otherwise). Tools, resources, and prompts reference the App from their own static `config` with `appResourceUri('')` from `agent-bundle/routes` or a shared `const` string literal instead of repeating the `ui://` literal. | Use a custom server or prefix the file with `_` | -| `src/scripts/.ts` | Plain script compiled to `scripts/.mjs` in every selected target artifact — the same pipeline explicit `scripts` entries use, with ordinary Node stdout/stderr semantics. A `scripts` entry that references the file claims it. Nested modules are hard errors (`AB4808`). | Prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | -| `src/scripts/.tsx` | Rendered script: the async default component receives `{ argv, signal }` and renders through the Agent renderer with the CLI output contract (`--json`, `--ndjson`, TTY progress, piped Markdown). Compiles to `scripts/.mjs` plus a `scripts/-flight.mjs` react-server worker. The extension is the explicit, visible contract — plain `.ts` scripts are never wrapped in React behavior, and explicit `scripts` config entries stay plain regardless of extension. | Rename to `.ts`, prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | +| `src/scripts/.ts` | Plain script compiled to `scripts/.mjs` in every selected target artifact — the same pipeline explicit `scripts` entries use, with ordinary Node stdout/stderr semantics. A `scripts` entry that references the file claims it. Nested modules are hard errors (`AB4808`). A `bin` (or `lib`) entry that references the file does **not** claim it: the module ships as both the npm bin and the artifact script (see [Which config keys claim a conventional module](#which-config-keys-claim-a-conventional-module)). | Prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | +| `src/scripts/.tsx` | Rendered script: the async default component receives `{ argv, signal }` and renders through the Agent renderer with the CLI output contract (`--json`, `--ndjson`, TTY progress, piped Markdown). Compiles to `scripts/.mjs` plus a `scripts/-flight.mjs` react-server worker. The extension is the explicit, visible contract — plain `.ts` scripts are never wrapped in React behavior, and explicit `scripts` config entries stay plain regardless of extension. A `bin` entry that references a rendered script is `AB4737`: the component cannot double as a bin's `main`. | Rename to `.ts`, prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | | `src/cli/**/*.{ts,tsx}` | Routed CLI commands compiled into one collision-checked command graph and one generated package executable named after `plugin.name` (superseding the `src/cli.ts` bin convention for the project). Nesting is identity: `src/cli/library/audit.ts` runs as ` library audit`. Plain `.ts` commands execute directly and print one canonical JSON line; `.tsx` commands render through the dispatcher with the four output modes. | `bin: false`, `routes.cli: 'conventional'`, or prefix a path segment with `_` | | `src/events//.{ts,tsx}`, `src/events/stop.{ts,tsx}` | Semantic event route: the path is the canonical event family (`src/events/tool/after.tsx` is `tool/after`; `stop` is the one top-level family) and must be one of the admitted `canonicalAgentEvents`. The optional static `config` (`AgentEventRouteConfig`: `targets`, `tools`, `runtime: 'shared' \| 'standalone'`, `fallback`, `delivery`, `timeoutMs`) restricts hosts and selects the execution mode; the async default Server Component receives `AgentEventRouteProps` (`{ canonical, native, signal }`) and returns `Agent.*` output that the selected host adapter encodes into its native hook envelope. Application code never branches on host JSON or emits native hook documents; per-host support is a capability state (`supported`/`degraded`/`unavailable`/`prohibited`) surfaced by `inspect` and enforced at build time (`AB4817`, `AB4823`–`AB4825`). | Restrict `config.targets`, or prefix a path segment with `_` | | `src/state.ts` | Project state definition: default-exports `defineState({ ... })`; generated MCP, routed-CLI, and rendered-script request scopes mount `(await agent()).state` and `.notices`. | `state: false`, or rename the file to `_state.ts` | @@ -87,6 +87,31 @@ entries carry `provenance.kind: 'conventional'` in the normalized model. Route and package entry conventions match `.ts` and `.tsx` files exactly; the state convention is specifically `src/state.ts`. +### Which config keys claim a conventional module + +An explicit config entry that references a module under a conventional route +directory *claims* it: the module belongs to that declaration and leaves +conventional discovery. Claims are decided by the module path the entry +resolves to, so nothing is ever compiled twice into one artifact output. The +exception is the package build: a `bin` or `lib` entry compiles to `dist/`, +which is disjoint from every artifact output, so a scripts-directory module +those keys reference stays a conventional script and ships on both surfaces. +`inspect` shows such a module under both `packageBuild.bins` (or +`packageBuild.lib`) and `scripts`; no diagnostic fires, because that is the +intended "same entry, npm bin + hook target" shape. + +| Config key | Claims a module under | Effect on a `src/scripts/.ts` module it references | +| --- | --- | --- | +| `scripts.` | every route directory | Claimed: the explicit entry ships it as `scripts/.mjs`; the convention no longer applies. A *different* module under `src/scripts/` sharing the configured `` is `AB4809`. | +| `hooks.[].handler` | every route directory | Claimed: the module is a hook handler compiled under `hooks/`, not an artifact script. | +| `mcp.servers..entry`, `mcp.servers..apps..entry` / `.template` | every route directory | Claimed: the module is the server or App entry compiled under `mcp/` or `mcp-apps/`. | +| `bin.` | `src/cli/**`, `src/events/**`, `src/mcp/**`, `src/providers/*` — **not** `src/scripts/*` | Not claimed: the module ships as both `dist/bin/.js` and `scripts/.mjs`. A rendered `src/scripts/.tsx` referenced by `bin` is `AB4737`. | +| `lib.entry` | `src/cli/**`, `src/events/**`, `src/mcp/**`, `src/providers/*` — **not** `src/scripts/*` | Not claimed: the module ships as both `dist/.js` (with declarations) and `scripts/.mjs`. | + +To ship a `src/scripts/` module as a bin only, prefix a path segment with `_` +(`src/scripts/_hauler.ts`): private segments opt the module out of discovery +while the `bin` entry still references it explicitly. + ### Config beside a route-generated MCP server A `mcp.servers.` block whose `` the route graph compiles in diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index b46f56646..4f8ccefc5 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -85,7 +85,11 @@ agent-bundle also owns the npm-facing package build: `bin` entries become self-e `dist/bin/.js` bundles (shebang, executable bit, generated `main(argv)` envelope) and the optional `lib` entry becomes `dist/.js` with declarations (resolving `typescript` from the project). The `src/cli.ts`, `src/index.ts`, and `src/mcp/.ts` conventions fill these in -when the config is silent; config always wins and `bin: false` / `lib: false` opt out. MCP server +when the config is silent; config always wins and `bin: false` / `lib: false` opt out. An explicit +`scripts`, `hooks`, or `mcp` entry claims the module it references out of conventional route +discovery, but a `bin` or `lib` entry does not claim a `src/scripts/.ts` module: the same file +ships as both `dist/bin/.js` and the artifact `scripts/.mjs` (a rendered `.tsx` script +referenced by `bin` is `AB4737`; prefix a path segment with `_` for a bin-only module). MCP server entries that default-export a server factory are wrapped in the framework stdio lifecycle shell (console-to-stderr guard with raw stdout restored for protocol frames, SIGINT 130 / SIGTERM 143, stdin-EOF exit 0, bounded shutdown, heartbeat), also available directly from diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index 69e524103..6b17097e9 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -1865,21 +1865,55 @@ const validatePackageIdentity = (loaded: LoadedConfig, release: boolean): Diagno * resolution. Discovery is not a packaging choice - a route never * disappears silently. */ +/** The explicit `bin` names claiming each absolute entry source, tolerant of malformed config shapes. */ +const explicitBinNamesBySource = (loaded: LoadedConfig): ReadonlyMap => { + const bin = loaded.config.bin; + const names = new Map(); + if (!isRecord(bin)) return names; + for (const [name, declaration] of Object.entries(bin)) { + const entry = typeof declaration === 'string' + ? declaration + : isRecord(declaration) ? (declaration as AgentBundleBinEntry).entry : undefined; + if (!nonemptyString(entry)) continue; + const source = resolve(loaded.context.projectRoot, entry); + names.set(source, [...(names.get(source) ?? []), name]); + } + return names; +}; + const validateConventionalScripts = ( loaded: LoadedConfig, discovered: DiscoveredProject, ): Diagnostic[] => { const diagnostics: Diagnostic[] = []; const configured = configuredScriptNames(loaded.config); + const binNamesBySource = explicitBinNamesBySource(loaded); for (const route of discovered.routeGraph?.scripts ?? []) { const relativePath = route.provenance.relativePath; const judgment = judgeScriptRoute(route, configured); switch (judgment) { case 'shippable': + // A plain script a `bin` entry also names ships as both surfaces: + // the npm bin and the artifact script are disjoint outputs (#389). + break; // Rendered scripts ship through the Agent renderer pipeline (#102 // stage 3); AB4807 is retired and never reused. - case 'rendered': + case 'rendered': { + // The rendered-script default export is a Server Component the + // renderer drives; the bin envelope would call it as `main(argv)`, so + // the two surfaces cannot share one module. + const binNames = binNamesBySource.get(route.source); + if (binNames !== undefined) { + diagnostics.push({ + code: 'AB4737', + message: `Rendered script ${relativePath} is also the entry of bin ${binNames.map((name) => JSON.stringify(name)).join(', ')}; a rendered script's default Server Component cannot serve as a package bin entry.`, + recovery: 'Point the bin entry at a plain module that exports main, rename the script to .ts so one plain module ships as both the bin and the artifact script, or prefix a path segment with "_" to keep the module bin-only.', + severity: 'error', + sourcePath: route.source, + }); + } break; + } case 'nested': diagnostics.push({ code: 'AB4808', diff --git a/packages/agent-bundle/src/routes/graph.ts b/packages/agent-bundle/src/routes/graph.ts index 73dbcb2b0..970e5c0f2 100644 --- a/packages/agent-bundle/src/routes/graph.ts +++ b/packages/agent-bundle/src/routes/graph.ts @@ -203,23 +203,43 @@ const claimedModuleEntry = (value: unknown): string | undefined => { return undefined; }; +interface ConfigClaimedSources { + /** + * Modules an artifact-side declaration (`scripts`, `hooks`, `mcp`) + * references. They belong to that declaration and never become + * conventional routes. + */ + readonly artifact: ReadonlySet; + /** + * Modules the package build (`bin`, `lib`) compiles. They leave route + * discovery too, except under `src/scripts/`: `dist/bin/.js` and + * `/scripts/.mjs` are disjoint outputs, so one entry ships as + * both an npm bin and an artifact script instead of silently losing the + * script (#389). + */ + readonly packageBuild: ReadonlySet; +} + /** * Absolute module paths explicit configuration already claims. Config always * wins — the rule the entry conventions established — so a module an explicit - * `scripts`, `hooks`, `bin`, `lib`, or `mcp` declaration references belongs - * to that declaration and never becomes a conventional route. Two shipped - * examples declare `scripts` entries under `src/scripts/`; this rule keeps - * their layouts route-free without a migration. + * `scripts`, `hooks`, or `mcp` declaration references belongs to that + * declaration and never becomes a conventional route. Two shipped examples + * declare `scripts` entries under `src/scripts/`; this rule keeps their + * layouts route-free without a migration. Package-build claims (`bin`, `lib`) + * are reported separately because they coexist with a conventional script. */ const configClaimedSources = ( projectRoot: string, config: Readonly, -): ReadonlySet => { - const claimed = new Set(); - const claim = (value: unknown): void => { +): ConfigClaimedSources => { + const artifact = new Set(); + const packageBuild = new Set(); + const claimInto = (claimed: Set, value: unknown): void => { const entry = claimedModuleEntry(value); if (entry !== undefined && entry.trim().length > 0) claimed.add(resolve(projectRoot, entry)); }; + const claim = (value: unknown): void => claimInto(artifact, value); const scripts = configValue(config, 'scripts'); if (isRecord(scripts)) { for (const value of Object.values(scripts)) claim(value); @@ -234,9 +254,9 @@ const configClaimedSources = ( } const bin = configValue(config, 'bin'); if (isRecord(bin)) { - for (const value of Object.values(bin)) claim(value); + for (const value of Object.values(bin)) claimInto(packageBuild, value); } - claim(configValue(config, 'lib')); + claimInto(packageBuild, configValue(config, 'lib')); const mcp = configValue(config, 'mcp'); const servers = isRecord(mcp) && isRecord(mcp.servers) ? mcp.servers : undefined; for (const server of Object.values(servers ?? {})) { @@ -249,9 +269,12 @@ const configClaimedSources = ( claim(app.template); } } - return claimed; + return { artifact, packageBuild }; }; +/** True for a module under the conventional scripts root, before privacy or identity checks. */ +const isScriptsDirectoryPath = (relativePath: string): boolean => relativePath.startsWith('src/scripts/'); + interface RouteModeOverrides { readonly cli?: 'generated' | 'conventional'; readonly mcpCommands?: McpCommandSelection; @@ -530,8 +553,9 @@ export const compileRouteGraph = async ( const modulesById = new Map(); const providerModulesByKey = new Map(); for (const source of sources) { - if (claimed.has(source)) continue; + if (claimed.artifact.has(source)) continue; const relativePath = toPosixPath(relative(projectRoot, source)); + if (claimed.packageBuild.has(source) && !isScriptsDirectoryPath(relativePath)) continue; if (isPrivateRoutePath(relativePath) || isProjectPathIgnored(rules, projectRoot, source)) continue; const module = classifyModule(source, relativePath); if ( diff --git a/packages/agent-bundle/tests/normalization.test.ts b/packages/agent-bundle/tests/normalization.test.ts index 9102676f5..090c5daf6 100644 --- a/packages/agent-bundle/tests/normalization.test.ts +++ b/packages/agent-bundle/tests/normalization.test.ts @@ -1185,6 +1185,38 @@ it('gates nested and conflicting conventional script routes as AB4808/AB4809 and ]); }); +it('ships a bin-claimed plain conventional script as both surfaces and refuses a bin-claimed rendered one with AB4737 (#389)', async () => { + const root = '/workspace/project'; + const loaded = loadedProject({ + bin: { + hauler: './src/scripts/hauler.ts', + notes: { entry: './src/scripts/render-notes.tsx' }, + 'notes-again': './src/scripts/render-notes.tsx', + }, + plugin: { name: 'review-tools', version: '1.0.0' }, + }); + const discovered: DiscoveredProject = { + routeGraph: routeGraphWithScripts(root, ['src/scripts/hauler.ts', 'src/scripts/render-notes.tsx']), + skills: [], + }; + + const gate = validateSource(loaded, discovered, registry).filter(({ code }) => code === 'AB4737'); + expect(gate).toEqual([ + { + code: 'AB4737', + message: 'Rendered script src/scripts/render-notes.tsx is also the entry of bin "notes", "notes-again"; a rendered script\'s default Server Component cannot serve as a package bin entry.', + recovery: 'Point the bin entry at a plain module that exports main, rename the script to .ts so one plain module ships as both the bin and the artifact script, or prefix a path segment with "_" to keep the module bin-only.', + severity: 'error', + sourcePath: `${root}/src/scripts/render-notes.tsx`, + }, + ]); + + // The plain script stays in the model beside its bin; nothing about it is gated. + const model = await normalizeProject(loaded, discovered, registry); + expect(model.scripts.map((script) => script.name)).toEqual(['hauler', 'render-notes']); + expect(model.packageBuild?.bins.map((bin) => bin.name)).toEqual(['hauler', 'notes', 'notes-again']); +}); + it('normalizes rendered conventional script routes onto the renderer pipeline (#102 stage 3)', async () => { const root = '/workspace/project'; const model = await normalizeProject( diff --git a/packages/agent-bundle/tests/package-build.test.ts b/packages/agent-bundle/tests/package-build.test.ts index ad6fbb6f7..541aac033 100644 --- a/packages/agent-bundle/tests/package-build.test.ts +++ b/packages/agent-bundle/tests/package-build.test.ts @@ -115,6 +115,45 @@ describe('framework-owned package build', () => { expect(rebuilt.packageBuild?.files).toEqual(packageBuild!.files); }, 120_000); + it('ships a bin-claimed src/scripts module as both the npm bin and the artifact script (#389)', async () => { + const root = await fixtureRoot({ + 'agent-bundle.config.ts': [ + 'export default {', + " bin: { hauler: './src/scripts/hauler.ts' },", + " plugin: { name: 'package-build-fixture', version: '1.0.0' },", + " targets: ['portable'],", + '};', + '', + ].join('\n'), + 'package.json': '{"name":"package-build-fixture","type":"module","private":true}\n', + 'src/scripts/hauler.ts': [ + 'export const main = async (argv: readonly string[]): Promise => {', + " process.stdout.write(`hauled:${argv.join(',')}\\n`);", + ' return 0;', + '};', + '', + ].join('\n'), + }); + const result = await build({ output: 'artifact', packageOutputs: true, root }); + + // The same entry is visible on both surfaces of the inspect model, and + // the claim itself raises no diagnostic: this is the intended shape. + expect(result.diagnostics).toEqual([]); + expect(result.model.packageBuild).toMatchObject({ + bins: [{ name: 'hauler', provenance: { kind: 'config' }, source: join(root, 'src/scripts/hauler.ts') }], + }); + expect(result.model.scripts).toMatchObject([ + { name: 'hauler', provenance: { kind: 'conventional' }, source: join(root, 'src/scripts/hauler.ts') }, + ]); + + // Both outputs exist and run. + expect(result.packageBuild?.files.map((file) => file.path)).toContain('bin/hauler.js'); + await expect(execFile(join(root, 'dist', 'bin', 'hauler.js'), ['alpha'])).resolves.toMatchObject({ stdout: 'hauled:alpha\n' }); + expect(result.build.outputProvenance.map((record) => record.path)).toContain('portable/scripts/hauler.mjs'); + const script = join(root, 'artifact', 'portable', 'scripts', 'hauler.mjs'); + await expect(execFile(process.execPath, [script, 'beta'])).resolves.toMatchObject({ stdout: 'hauled:beta\n' }); + }, 120_000); + it('wraps factory-exporting MCP entries in the lifecycle shell and leaves self-connecting entries alone', async () => { const root = await fixtureRoot({ ...conventionFixture(), diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index f302afa02..9371b4421 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -258,6 +258,29 @@ it('never compiles a module explicit configuration claims: config always wins', expect('routeGraph' in discovered).toBe(false); }); +it('keeps a bin- or lib-claimed src/scripts module in script discovery (#389)', async () => { + const root = await createRoot(); + await writeTree(root, { + 'src/cli/doctor.ts': moduleSource, + 'src/cli.ts': moduleSource, + 'src/index.ts': moduleSource, + 'src/scripts/hauler.ts': moduleSource, + 'src/scripts/shared.ts': moduleSource, + }); + const graph = await compileRouteGraph(root, fixtureConfig({ + // The #389 shape: one entry is the npm bin and the artifact hook target. + bin: { doctor: './src/cli/doctor.ts', hauler: './src/scripts/hauler.ts', main: './src/cli.ts' }, + lib: { entry: './src/scripts/shared.ts' }, + })); + + expect(graph.diagnostics).toEqual([]); + // Package-build claims never remove a scripts-directory module: the bin and + // the artifact script are disjoint outputs, so both surfaces ship. + expect(graph.scripts.map((route) => route.id)).toEqual(['script:hauler', 'script:shared']); + // Every other route kind still belongs to the claiming declaration. + expect(graph.cli).toBeUndefined(); +}); + it('errors with AB4800 when a declared entry, command, or url claims a routed server', async () => { const root = await createRoot(); await writeTree(root, { 'src/mcp/curator/tools/inspect.ts': moduleSource }); From 8b157a40f7200d3f9e430f07af2193ba972c7b1d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 08:59:14 +0000 Subject: [PATCH 02/12] fix(routes): limit the bin/lib dual-surface exception to direct src/scripts children A nested module a package-build entry names stays claimed; discovering it would only turn a valid package-only configuration into AB4808. Rewrite the changeset summary in the imperative user-facing format ending with the PR reference. --- .changeset/389-bin-claim-script-discovery.md | 2 +- packages/agent-bundle/src/routes/graph.ts | 20 ++++++++++++------- .../agent-bundle/tests/route-graph.test.ts | 14 ++++++++++--- 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/.changeset/389-bin-claim-script-discovery.md b/.changeset/389-bin-claim-script-discovery.md index d26337ee1..1a3b1a5e9 100644 --- a/.changeset/389-bin-claim-script-discovery.md +++ b/.changeset/389-bin-claim-script-discovery.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -A `bin` (or `lib`) entry that references a conventional `src/scripts/.ts` module no longer removes it from script discovery. The npm bin (`dist/bin/.js`) and the artifact script (`scripts/.mjs`) are disjoint outputs, so the same entry now ships on both surfaces and `inspect` lists it under both `packageBuild.bins` and `scripts`; previously the artifact script silently disappeared. Explicit `scripts`, `hooks`, and `mcp` entries still claim the module they reference. A `bin` entry pointing at a rendered `src/scripts/.tsx` script is the new `AB4737` error, because a rendered script's Server Component cannot double as a bin's `main`. +Keep a conventional `src/scripts/.ts` module in script discovery when a `bin` or `lib` config entry also references it, so `agent-bundle build` emits both `dist/bin/.js` and the artifact `scripts/.mjs` and `agent-bundle inspect --json` lists the module under both `packageBuild.bins` and `scripts` instead of silently dropping the script. Explicit `scripts`, `hooks`, and `mcp` entries still claim the module they reference. Report `AB4737` when a `bin` entry points at a rendered `src/scripts/.tsx` script, whose Server Component cannot double as the bin's `main`. (#413) diff --git a/packages/agent-bundle/src/routes/graph.ts b/packages/agent-bundle/src/routes/graph.ts index 970e5c0f2..dfe03398a 100644 --- a/packages/agent-bundle/src/routes/graph.ts +++ b/packages/agent-bundle/src/routes/graph.ts @@ -212,10 +212,10 @@ interface ConfigClaimedSources { readonly artifact: ReadonlySet; /** * Modules the package build (`bin`, `lib`) compiles. They leave route - * discovery too, except under `src/scripts/`: `dist/bin/.js` and - * `/scripts/.mjs` are disjoint outputs, so one entry ships as - * both an npm bin and an artifact script instead of silently losing the - * script (#389). + * discovery too, except a direct `src/scripts/` child: + * `dist/bin/.js` and `/scripts/.mjs` are disjoint + * outputs, so one entry ships as both an npm bin and an artifact script + * instead of silently losing the script (#389). */ readonly packageBuild: ReadonlySet; } @@ -272,8 +272,14 @@ const configClaimedSources = ( return { artifact, packageBuild }; }; -/** True for a module under the conventional scripts root, before privacy or identity checks. */ -const isScriptsDirectoryPath = (relativePath: string): boolean => relativePath.startsWith('src/scripts/'); +/** + * True for a direct child of the conventional scripts root — the only shape + * the flat scripts artifact can ship. A nested module a package-build entry + * names stays claimed: keeping it discovered would only turn a valid + * package-only configuration into an AB4808 error. + */ +const isConventionalScriptPath = (relativePath: string): boolean => + /^src\/scripts\/[^/]+$/u.test(relativePath); interface RouteModeOverrides { readonly cli?: 'generated' | 'conventional'; @@ -555,7 +561,7 @@ export const compileRouteGraph = async ( for (const source of sources) { if (claimed.artifact.has(source)) continue; const relativePath = toPosixPath(relative(projectRoot, source)); - if (claimed.packageBuild.has(source) && !isScriptsDirectoryPath(relativePath)) continue; + if (claimed.packageBuild.has(source) && !isConventionalScriptPath(relativePath)) continue; if (isPrivateRoutePath(relativePath) || isProjectPathIgnored(rules, projectRoot, source)) continue; const module = classifyModule(source, relativePath); if ( diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index 9371b4421..1c07cbdbb 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -265,17 +265,25 @@ it('keeps a bin- or lib-claimed src/scripts module in script discovery (#389)', 'src/cli.ts': moduleSource, 'src/index.ts': moduleSource, 'src/scripts/hauler.ts': moduleSource, + 'src/scripts/internal/tool.ts': moduleSource, 'src/scripts/shared.ts': moduleSource, }); const graph = await compileRouteGraph(root, fixtureConfig({ // The #389 shape: one entry is the npm bin and the artifact hook target. - bin: { doctor: './src/cli/doctor.ts', hauler: './src/scripts/hauler.ts', main: './src/cli.ts' }, + bin: { + doctor: './src/cli/doctor.ts', + hauler: './src/scripts/hauler.ts', + main: './src/cli.ts', + tool: './src/scripts/internal/tool.ts', + }, lib: { entry: './src/scripts/shared.ts' }, })); expect(graph.diagnostics).toEqual([]); - // Package-build claims never remove a scripts-directory module: the bin and - // the artifact script are disjoint outputs, so both surfaces ship. + // Package-build claims never remove a direct src/scripts/ child: the + // bin and the artifact script are disjoint outputs, so both surfaces ship. + // The nested module stays claimed — discovering it would only turn a valid + // package-only configuration into AB4808. expect(graph.scripts.map((route) => route.id)).toEqual(['script:hauler', 'script:shared']); // Every other route kind still belongs to the claiming declaration. expect(graph.cli).toBeUndefined(); From 743be67945cc1d6998bc75d673921a7260d86222 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 09:07:07 +0000 Subject: [PATCH 03/12] fix(validate): gate AB4737 only when the bin-claimed rendered script exports no main The bin envelope prefers a named main export and only falls back to the default export, so a rendered script that also exports main serves both surfaces. Reuse the build's export scan so the gate and the envelope agree. --- .changeset/389-bin-claim-script-discovery.md | 2 +- docs/diagnostics.md | 37 +++++++++++-------- docs/entry-conventions.md | 13 ++++--- packages/agent-bundle/README.md | 3 +- packages/agent-bundle/src/config/validate.ts | 22 ++++++++--- .../agent-bundle/tests/normalization.test.ts | 5 ++- .../agent-bundle/tests/route-graph.test.ts | 36 ++++++++++++++++++ 7 files changed, 88 insertions(+), 30 deletions(-) diff --git a/.changeset/389-bin-claim-script-discovery.md b/.changeset/389-bin-claim-script-discovery.md index 1a3b1a5e9..3057d4bfe 100644 --- a/.changeset/389-bin-claim-script-discovery.md +++ b/.changeset/389-bin-claim-script-discovery.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Keep a conventional `src/scripts/.ts` module in script discovery when a `bin` or `lib` config entry also references it, so `agent-bundle build` emits both `dist/bin/.js` and the artifact `scripts/.mjs` and `agent-bundle inspect --json` lists the module under both `packageBuild.bins` and `scripts` instead of silently dropping the script. Explicit `scripts`, `hooks`, and `mcp` entries still claim the module they reference. Report `AB4737` when a `bin` entry points at a rendered `src/scripts/.tsx` script, whose Server Component cannot double as the bin's `main`. (#413) +Keep a conventional `src/scripts/.ts` module in script discovery when a `bin` or `lib` config entry also references it, so `agent-bundle build` emits both `dist/bin/.js` and the artifact `scripts/.mjs` and `agent-bundle inspect --json` lists the module under both `packageBuild.bins` and `scripts` instead of silently dropping the script. Explicit `scripts`, `hooks`, and `mcp` entries still claim the module they reference. Report `AB4737` when a `bin` entry points at a rendered `src/scripts/.tsx` script that exports no named `main`, because its Server Component cannot double as the bin's `main`. (#413) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index ce1b9b98e..83daf2a69 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -203,8 +203,8 @@ replaced patterns consumers previously wrote by hand. When `validate`, pre-convention patterns, it reports a migration diagnostic. `AB4730`–`AB4735` are **informational** nudges and never block anything. `AB4736` and `AB4737` are errors: the removed top-level authored-document locations are no longer -discovered, and a rendered script cannot double as a package bin, so the -compiler refuses to omit or misbuild them silently. The CLI prints these in +discovered, and a rendered script without a `main` export cannot double as a +package bin, so the compiler refuses to omit or misbuild them silently. The CLI prints these in human `validate` output and includes them in every `--json` diagnostics array. Which explicit config keys *claim* a conventional module out of discovery is @@ -285,22 +285,27 @@ Recover: move the document under `src/skills/`, `src/commands/`, or `src/rules/`. Explicit `skills` paths remain valid anywhere. Published artifact paths remain `skills/`, `commands/`, and `rules/`. -### `AB4737` — rendered script claimed as a package bin entry +### `AB4737` — rendered script claimed as a package bin entry without `main` An explicit `bin` entry references a conventional rendered script -(`src/scripts/.tsx` or `.jsx`). A plain `src/scripts/.ts` module -ships happily on both surfaces — the npm bin envelope calls its `main(argv)` -and the artifact script is the same bundle — but a rendered script's default -export is an async Server Component the Agent renderer drives with -`{ argv, signal }` props. The bin envelope would call that component as -`main(argv)` and produce a bin that renders nothing, so the compiler refuses -the pair instead of emitting a broken executable beside a working script. -The message names every `bin` entry referencing the module. - -Recover: point the `bin` entry at a plain module that exports `main`; rename -the script to `.ts` so one plain module ships as both the bin and the -artifact script; or prefix a path segment with `_` (`src/scripts/_name.tsx`) -to keep the module out of script discovery and bin-only. +(`src/scripts/.tsx` or `.jsx`) that exports no named `main`. A plain +`src/scripts/.ts` module ships happily on both surfaces — the npm bin +envelope calls its `main(argv)` and the artifact script is the same bundle — +but a rendered script's default export is an async Server Component the +Agent renderer drives with `{ argv, signal }` props. The bin envelope prefers +a named `main` export and only falls back to the default export, so without +`main` it would call that component as `main(argv)` and produce a bin that +renders nothing; the compiler refuses the pair instead of emitting a broken +executable beside a working script. A rendered script that also exports +`main` serves both surfaces and is not gated. The detection is the same +static export scan the package build uses, so the gate and the envelope +always agree. The message names every `bin` entry referencing the module. + +Recover: export a named `main(argv)` from the module for the bin surface; +point the `bin` entry at a plain module that exports `main`; rename the +script to `.ts` so one plain module ships as both the bin and the artifact +script; or prefix a path segment with `_` (`src/scripts/_name.tsx`) to keep +the module out of script discovery and bin-only. ## Prebuilt payloads (`AB4740`–`AB4750`) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index ae59d9fd4..780ab7c0b 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -78,7 +78,7 @@ entries carry `provenance.kind: 'conventional'` in the normalized model. | `src/mcp//{tools,resources,prompts}/*.{ts,tsx}` | Generated MCP server routes; path supplies identity and each executable module supplies static `config`, schemas, and one async default Server Component. | Set `routes.servers.` to `custom`, `command`, or `remote` | | `src/mcp//apps/*.{ts,tsx}` | Browser MCP App entry compiled to self-contained HTML and registered on the generated server; static `config.resourceUri` is required. An optional `config.template` HTML shell resolves relative to the route module like its imports (`'./dashboard.html'`); the legacy project-root-relative form is accepted only while unambiguous (`AB4827` otherwise). Tools, resources, and prompts reference the App from their own static `config` with `appResourceUri('')` from `agent-bundle/routes` or a shared `const` string literal instead of repeating the `ui://` literal. | Use a custom server or prefix the file with `_` | | `src/scripts/.ts` | Plain script compiled to `scripts/.mjs` in every selected target artifact — the same pipeline explicit `scripts` entries use, with ordinary Node stdout/stderr semantics. A `scripts` entry that references the file claims it. Nested modules are hard errors (`AB4808`). A `bin` (or `lib`) entry that references the file does **not** claim it: the module ships as both the npm bin and the artifact script (see [Which config keys claim a conventional module](#which-config-keys-claim-a-conventional-module)). | Prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | -| `src/scripts/.tsx` | Rendered script: the async default component receives `{ argv, signal }` and renders through the Agent renderer with the CLI output contract (`--json`, `--ndjson`, TTY progress, piped Markdown). Compiles to `scripts/.mjs` plus a `scripts/-flight.mjs` react-server worker. The extension is the explicit, visible contract — plain `.ts` scripts are never wrapped in React behavior, and explicit `scripts` config entries stay plain regardless of extension. A `bin` entry that references a rendered script is `AB4737`: the component cannot double as a bin's `main`. | Rename to `.ts`, prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | +| `src/scripts/.tsx` | Rendered script: the async default component receives `{ argv, signal }` and renders through the Agent renderer with the CLI output contract (`--json`, `--ndjson`, TTY progress, piped Markdown). Compiles to `scripts/.mjs` plus a `scripts/-flight.mjs` react-server worker. The extension is the explicit, visible contract — plain `.ts` scripts are never wrapped in React behavior, and explicit `scripts` config entries stay plain regardless of extension. A `bin` entry that references a rendered script exporting no named `main` is `AB4737`: the component cannot double as the bin's `main`; export `main` and the module serves both surfaces. | Rename to `.ts`, prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | | `src/cli/**/*.{ts,tsx}` | Routed CLI commands compiled into one collision-checked command graph and one generated package executable named after `plugin.name` (superseding the `src/cli.ts` bin convention for the project). Nesting is identity: `src/cli/library/audit.ts` runs as ` library audit`. Plain `.ts` commands execute directly and print one canonical JSON line; `.tsx` commands render through the dispatcher with the four output modes. | `bin: false`, `routes.cli: 'conventional'`, or prefix a path segment with `_` | | `src/events//.{ts,tsx}`, `src/events/stop.{ts,tsx}` | Semantic event route: the path is the canonical event family (`src/events/tool/after.tsx` is `tool/after`; `stop` is the one top-level family) and must be one of the admitted `canonicalAgentEvents`. The optional static `config` (`AgentEventRouteConfig`: `targets`, `tools`, `runtime: 'shared' \| 'standalone'`, `fallback`, `delivery`, `timeoutMs`) restricts hosts and selects the execution mode; the async default Server Component receives `AgentEventRouteProps` (`{ canonical, native, signal }`) and returns `Agent.*` output that the selected host adapter encodes into its native hook envelope. Application code never branches on host JSON or emits native hook documents; per-host support is a capability state (`supported`/`degraded`/`unavailable`/`prohibited`) surfaced by `inspect` and enforced at build time (`AB4817`, `AB4823`–`AB4825`). | Restrict `config.targets`, or prefix a path segment with `_` | | `src/state.ts` | Project state definition: default-exports `defineState({ ... })`; generated MCP, routed-CLI, and rendered-script request scopes mount `(await agent()).state` and `.notices`. | `state: false`, or rename the file to `_state.ts` | @@ -94,8 +94,11 @@ directory *claims* it: the module belongs to that declaration and leaves conventional discovery. Claims are decided by the module path the entry resolves to, so nothing is ever compiled twice into one artifact output. The exception is the package build: a `bin` or `lib` entry compiles to `dist/`, -which is disjoint from every artifact output, so a scripts-directory module -those keys reference stays a conventional script and ships on both surfaces. +which is disjoint from every artifact output, so a direct `src/scripts/` +child those keys reference stays a conventional script and ships on both +surfaces. (A nested `src/scripts//` module — which the flat scripts +artifact could not ship anyway — stays claimed, so a package-only entry there +never turns into `AB4808`.) `inspect` shows such a module under both `packageBuild.bins` (or `packageBuild.lib`) and `scripts`; no diagnostic fires, because that is the intended "same entry, npm bin + hook target" shape. @@ -105,8 +108,8 @@ intended "same entry, npm bin + hook target" shape. | `scripts.` | every route directory | Claimed: the explicit entry ships it as `scripts/.mjs`; the convention no longer applies. A *different* module under `src/scripts/` sharing the configured `` is `AB4809`. | | `hooks.[].handler` | every route directory | Claimed: the module is a hook handler compiled under `hooks/`, not an artifact script. | | `mcp.servers..entry`, `mcp.servers..apps..entry` / `.template` | every route directory | Claimed: the module is the server or App entry compiled under `mcp/` or `mcp-apps/`. | -| `bin.` | `src/cli/**`, `src/events/**`, `src/mcp/**`, `src/providers/*` — **not** `src/scripts/*` | Not claimed: the module ships as both `dist/bin/.js` and `scripts/.mjs`. A rendered `src/scripts/.tsx` referenced by `bin` is `AB4737`. | -| `lib.entry` | `src/cli/**`, `src/events/**`, `src/mcp/**`, `src/providers/*` — **not** `src/scripts/*` | Not claimed: the module ships as both `dist/.js` (with declarations) and `scripts/.mjs`. | +| `bin.` | `src/cli/**`, `src/events/**`, `src/mcp/**`, `src/providers/*`, and nested `src/scripts/**/*` — **not** a direct `src/scripts/` child | Not claimed: the module ships as both `dist/bin/.js` and `scripts/.mjs`. A rendered `src/scripts/.tsx` referenced by `bin` must also export `main`, otherwise `AB4737`. | +| `lib.entry` | `src/cli/**`, `src/events/**`, `src/mcp/**`, `src/providers/*`, and nested `src/scripts/**/*` — **not** a direct `src/scripts/` child | Not claimed: the module ships as both `dist/.js` (with declarations) and `scripts/.mjs`. | To ship a `src/scripts/` module as a bin only, prefix a path segment with `_` (`src/scripts/_hauler.ts`): private segments opt the module out of discovery diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 4f8ccefc5..0d9b729fa 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -89,7 +89,8 @@ when the config is silent; config always wins and `bin: false` / `lib: false` op `scripts`, `hooks`, or `mcp` entry claims the module it references out of conventional route discovery, but a `bin` or `lib` entry does not claim a `src/scripts/.ts` module: the same file ships as both `dist/bin/.js` and the artifact `scripts/.mjs` (a rendered `.tsx` script -referenced by `bin` is `AB4737`; prefix a path segment with `_` for a bin-only module). MCP server +referenced by `bin` must also export `main`, otherwise `AB4737`; prefix a path segment with `_` for a +bin-only module). MCP server entries that default-export a server factory are wrapped in the framework stdio lifecycle shell (console-to-stderr guard with raw stdout restored for protocol frames, SIGINT 130 / SIGTERM 143, stdin-EOF exit 0, bounded shutdown, heartbeat), also available directly from diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index 6b17097e9..af1fdd099 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -1881,6 +1881,16 @@ const explicitBinNamesBySource = (loaded: LoadedConfig): ReadonlyMap { + try { + return scanEntryExportsSource(readFileSync(source, 'utf8')).hasMainExport; + } catch { + // An unreadable module cannot satisfy the bin surface either. + return false; + } +}; + const validateConventionalScripts = ( loaded: LoadedConfig, discovered: DiscoveredProject, @@ -1900,14 +1910,16 @@ const validateConventionalScripts = ( // stage 3); AB4807 is retired and never reused. case 'rendered': { // The rendered-script default export is a Server Component the - // renderer drives; the bin envelope would call it as `main(argv)`, so - // the two surfaces cannot share one module. + // renderer drives. The bin envelope prefers a named `main` export and + // only falls back to the default export, so the two surfaces can share + // one module exactly when it exports `main`; the detection is the + // build's own export scan, so the gate and the envelope always agree. const binNames = binNamesBySource.get(route.source); - if (binNames !== undefined) { + if (binNames !== undefined && !renderedScriptExportsMain(route.source)) { diagnostics.push({ code: 'AB4737', - message: `Rendered script ${relativePath} is also the entry of bin ${binNames.map((name) => JSON.stringify(name)).join(', ')}; a rendered script's default Server Component cannot serve as a package bin entry.`, - recovery: 'Point the bin entry at a plain module that exports main, rename the script to .ts so one plain module ships as both the bin and the artifact script, or prefix a path segment with "_" to keep the module bin-only.', + message: `Rendered script ${relativePath} is also the entry of bin ${binNames.map((name) => JSON.stringify(name)).join(', ')} but exports no main; the bin envelope would call its default Server Component as main(argv).`, + recovery: 'Export a named main(argv) from the module for the bin surface, point the bin entry at a plain module that exports main, rename the script to .ts so one plain module ships as both the bin and the artifact script, or prefix a path segment with "_" to keep the module bin-only.', severity: 'error', sourcePath: route.source, }); diff --git a/packages/agent-bundle/tests/normalization.test.ts b/packages/agent-bundle/tests/normalization.test.ts index 090c5daf6..cdcf0649d 100644 --- a/packages/agent-bundle/tests/normalization.test.ts +++ b/packages/agent-bundle/tests/normalization.test.ts @@ -1200,12 +1200,13 @@ it('ships a bin-claimed plain conventional script as both surfaces and refuses a skills: [], }; + // The fixture root has no files, so the export scan finds no main and the gate fires. const gate = validateSource(loaded, discovered, registry).filter(({ code }) => code === 'AB4737'); expect(gate).toEqual([ { code: 'AB4737', - message: 'Rendered script src/scripts/render-notes.tsx is also the entry of bin "notes", "notes-again"; a rendered script\'s default Server Component cannot serve as a package bin entry.', - recovery: 'Point the bin entry at a plain module that exports main, rename the script to .ts so one plain module ships as both the bin and the artifact script, or prefix a path segment with "_" to keep the module bin-only.', + message: 'Rendered script src/scripts/render-notes.tsx is also the entry of bin "notes", "notes-again" but exports no main; the bin envelope would call its default Server Component as main(argv).', + recovery: 'Export a named main(argv) from the module for the bin surface, point the bin entry at a plain module that exports main, rename the script to .ts so one plain module ships as both the bin and the artifact script, or prefix a path segment with "_" to keep the module bin-only.', severity: 'error', sourcePath: `${root}/src/scripts/render-notes.tsx`, }, diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index 1c07cbdbb..248a8f0cd 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -289,6 +289,42 @@ it('keeps a bin- or lib-claimed src/scripts module in script discovery (#389)', expect(graph.cli).toBeUndefined(); }); +it('gates a bin-claimed rendered script with AB4737 only when it exports no main (#389)', async () => { + const project = await createInspectProject({ + 'agent-bundle.config.ts': [ + 'export default {', + " bin: { notes: './src/scripts/render-notes.tsx', poster: './src/scripts/render-poster.tsx' },", + " plugin: { name: 'routes-fixture', version: '1.0.0' },", + " targets: ['portable'],", + '};', + '', + ].join('\n'), + // Exports the named main the bin envelope selects first, so the module + // serves both surfaces: main(argv) for the bin, the component for the script. + 'src/scripts/render-notes.tsx': [ + 'export const main = async (argv: readonly string[]): Promise => argv.length;', + 'export default async () => undefined;', + '', + ].join('\n'), + 'src/scripts/render-poster.tsx': 'export default async () => undefined;\n', + }); + + const result = await validate({ root: project }); + const gate = result.diagnostics.filter(({ code }) => code === 'AB4737'); + expect(gate).toHaveLength(1); + expect(gate[0]).toMatchObject({ + message: expect.stringContaining('render-poster.tsx is also the entry of bin "poster" but exports no main'), + severity: 'error', + sourcePath: join(project, 'src/scripts/render-poster.tsx'), + }); + // Both rendered scripts stay discovered beside their bins: the gate names + // the conflict instead of dropping a route. + const graph = await compileRouteGraph(project, fixtureConfig({ + bin: { notes: './src/scripts/render-notes.tsx', poster: './src/scripts/render-poster.tsx' }, + })); + expect(graph.scripts.map((route) => route.id)).toEqual(['script:render-notes', 'script:render-poster']); +}); + it('errors with AB4800 when a declared entry, command, or url claims a routed server', async () => { const root = await createRoot(); await writeTree(root, { 'src/mcp/curator/tools/inspect.ts': moduleSource }); From a964e640dc7cd1f770c677ee2684b68a1c2d876d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 09:14:17 +0000 Subject: [PATCH 04/12] fix(routes): keep unsafely named src/scripts modules claimed by package entries A direct child whose stem is not a safe route identity cannot become an artifact script, so a bin or lib entry naming it stays claimed instead of surfacing AB4803 for a package-only configuration that built before. --- docs/entry-conventions.md | 11 ++++++----- packages/agent-bundle/src/routes/graph.ts | 18 ++++++++++++------ .../agent-bundle/tests/route-graph.test.ts | 11 +++++++---- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 780ab7c0b..01af13b57 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -96,9 +96,10 @@ resolves to, so nothing is ever compiled twice into one artifact output. The exception is the package build: a `bin` or `lib` entry compiles to `dist/`, which is disjoint from every artifact output, so a direct `src/scripts/` child those keys reference stays a conventional script and ships on both -surfaces. (A nested `src/scripts//` module — which the flat scripts -artifact could not ship anyway — stays claimed, so a package-only entry there -never turns into `AB4808`.) +surfaces. (A nested `src/scripts//` module or one whose stem is not +a safe route identity — which the flat scripts artifact could not ship anyway +— stays claimed, so a package-only entry there never turns into `AB4808` or +`AB4803`.) `inspect` shows such a module under both `packageBuild.bins` (or `packageBuild.lib`) and `scripts`; no diagnostic fires, because that is the intended "same entry, npm bin + hook target" shape. @@ -108,8 +109,8 @@ intended "same entry, npm bin + hook target" shape. | `scripts.` | every route directory | Claimed: the explicit entry ships it as `scripts/.mjs`; the convention no longer applies. A *different* module under `src/scripts/` sharing the configured `` is `AB4809`. | | `hooks.[].handler` | every route directory | Claimed: the module is a hook handler compiled under `hooks/`, not an artifact script. | | `mcp.servers..entry`, `mcp.servers..apps..entry` / `.template` | every route directory | Claimed: the module is the server or App entry compiled under `mcp/` or `mcp-apps/`. | -| `bin.` | `src/cli/**`, `src/events/**`, `src/mcp/**`, `src/providers/*`, and nested `src/scripts/**/*` — **not** a direct `src/scripts/` child | Not claimed: the module ships as both `dist/bin/.js` and `scripts/.mjs`. A rendered `src/scripts/.tsx` referenced by `bin` must also export `main`, otherwise `AB4737`. | -| `lib.entry` | `src/cli/**`, `src/events/**`, `src/mcp/**`, `src/providers/*`, and nested `src/scripts/**/*` — **not** a direct `src/scripts/` child | Not claimed: the module ships as both `dist/.js` (with declarations) and `scripts/.mjs`. | +| `bin.` | `src/cli/**`, `src/events/**`, `src/mcp/**`, `src/providers/*`, and any `src/scripts/**` module that is nested or unsafely named — **not** a safely named direct `src/scripts/` child | Not claimed: the module ships as both `dist/bin/.js` and `scripts/.mjs`. A rendered `src/scripts/.tsx` referenced by `bin` must also export `main`, otherwise `AB4737`. | +| `lib.entry` | `src/cli/**`, `src/events/**`, `src/mcp/**`, `src/providers/*`, and any `src/scripts/**` module that is nested or unsafely named — **not** a safely named direct `src/scripts/` child | Not claimed: the module ships as both `dist/.js` (with declarations) and `scripts/.mjs`. | To ship a `src/scripts/` module as a bin only, prefix a path segment with `_` (`src/scripts/_hauler.ts`): private segments opt the module out of discovery diff --git a/packages/agent-bundle/src/routes/graph.ts b/packages/agent-bundle/src/routes/graph.ts index dfe03398a..f75af3cbf 100644 --- a/packages/agent-bundle/src/routes/graph.ts +++ b/packages/agent-bundle/src/routes/graph.ts @@ -273,13 +273,19 @@ const configClaimedSources = ( }; /** - * True for a direct child of the conventional scripts root — the only shape - * the flat scripts artifact can ship. A nested module a package-build entry - * names stays claimed: keeping it discovered would only turn a valid - * package-only configuration into an AB4808 error. + * True for a direct child of the conventional scripts root whose stem is a + * safe route identity — the only shape the flat scripts artifact can ship. A + * nested or unsafely named module a package-build entry names stays claimed: + * keeping it discovered would only turn a valid package-only configuration + * into an AB4808 or AB4803 error. */ -const isConventionalScriptPath = (relativePath: string): boolean => - /^src\/scripts\/[^/]+$/u.test(relativePath); +const isConventionalScriptPath = (relativePath: string): boolean => { + const segments = relativePath.split('/'); + return segments.length === 3 + && segments[0] === 'src' + && segments[1] === 'scripts' + && safeIdentitySegment.test(stemOf(segments[2]!)); +}; interface RouteModeOverrides { readonly cli?: 'generated' | 'conventional'; diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index 248a8f0cd..a091ac7f3 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -266,6 +266,7 @@ it('keeps a bin- or lib-claimed src/scripts module in script discovery (#389)', 'src/index.ts': moduleSource, 'src/scripts/hauler.ts': moduleSource, 'src/scripts/internal/tool.ts': moduleSource, + 'src/scripts/my tool.ts': moduleSource, 'src/scripts/shared.ts': moduleSource, }); const graph = await compileRouteGraph(root, fixtureConfig({ @@ -274,16 +275,18 @@ it('keeps a bin- or lib-claimed src/scripts module in script discovery (#389)', doctor: './src/cli/doctor.ts', hauler: './src/scripts/hauler.ts', main: './src/cli.ts', + spaced: './src/scripts/my tool.ts', tool: './src/scripts/internal/tool.ts', }, lib: { entry: './src/scripts/shared.ts' }, })); expect(graph.diagnostics).toEqual([]); - // Package-build claims never remove a direct src/scripts/ child: the - // bin and the artifact script are disjoint outputs, so both surfaces ship. - // The nested module stays claimed — discovering it would only turn a valid - // package-only configuration into AB4808. + // Package-build claims never remove a safely named direct src/scripts/ + // child: the bin and the artifact script are disjoint outputs, so both + // surfaces ship. The nested and the unsafely named modules stay claimed — + // discovering them would only turn a valid package-only configuration into + // AB4808 or AB4803. expect(graph.scripts.map((route) => route.id)).toEqual(['script:hauler', 'script:shared']); // Every other route kind still belongs to the claiming declaration. expect(graph.cli).toBeUndefined(); From 6c3612cfcf2c9c9aa43e9861fe5643fe08619c37 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 09:26:35 +0000 Subject: [PATCH 05/12] fix(validate): report AB4738 when a bin-claimed plain script would ship inert Both envelopes wrap a main export and bundle self-executing modules alike, but only the bin envelope falls back to a default export; the artifact script would merely define it. Gate that shape instead of publishing an inert scripts/.mjs beside a working bin. --- .changeset/389-bin-claim-script-discovery.md | 2 +- docs/diagnostics.md | 29 ++++++++++--- docs/entry-conventions.md | 4 +- packages/agent-bundle/README.md | 6 +-- packages/agent-bundle/src/config/validate.ts | 43 +++++++++++++------ .../agent-bundle/tests/route-graph.test.ts | 34 +++++++++++++++ 6 files changed, 95 insertions(+), 23 deletions(-) diff --git a/.changeset/389-bin-claim-script-discovery.md b/.changeset/389-bin-claim-script-discovery.md index 3057d4bfe..e1c788b1d 100644 --- a/.changeset/389-bin-claim-script-discovery.md +++ b/.changeset/389-bin-claim-script-discovery.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Keep a conventional `src/scripts/.ts` module in script discovery when a `bin` or `lib` config entry also references it, so `agent-bundle build` emits both `dist/bin/.js` and the artifact `scripts/.mjs` and `agent-bundle inspect --json` lists the module under both `packageBuild.bins` and `scripts` instead of silently dropping the script. Explicit `scripts`, `hooks`, and `mcp` entries still claim the module they reference. Report `AB4737` when a `bin` entry points at a rendered `src/scripts/.tsx` script that exports no named `main`, because its Server Component cannot double as the bin's `main`. (#413) +Keep a conventional `src/scripts/.ts` module in script discovery when a `bin` or `lib` config entry also references it, so `agent-bundle build` emits both `dist/bin/.js` and the artifact `scripts/.mjs` and `agent-bundle inspect --json` lists the module under both `packageBuild.bins` and `scripts` instead of silently dropping the script. Explicit `scripts`, `hooks`, and `mcp` entries still claim the module they reference. Report `AB4737` when a `bin` entry points at a rendered `src/scripts/.tsx` script that exports no named `main`, because its Server Component cannot double as the bin's `main`, and `AB4738` when it points at a plain `src/scripts/.ts` script that exports a `default` but no `main`, because the artifact script would ship inert while the bin runs the default export. (#413) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 83daf2a69..8ee89cf9a 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -195,16 +195,17 @@ development-only fallback can never produce a release artifact, so | `AB4011` | warning | `package.json` is unusable — unparsable, not a JSON object, or symlinked outside the project root. | | `AB4013` | error (build) | `agent-bundle build` refuses a project with no release version: `plugin.version` is omitted and `package.json` declares no valid semantic version. | -## Migration nudges and convention claims (`AB4730`–`AB4737`) +## Migration nudges and convention claims (`AB4730`–`AB4738`) The entry conventions and the framework-owned stdio lifecycle shell (RFC #50) replaced patterns consumers previously wrote by hand. When `validate`, `inspect`, `build`, or `dev` prepares project source and finds one of those pre-convention patterns, it reports a migration diagnostic. `AB4730`–`AB4735` -are **informational** nudges and never block anything. `AB4736` and `AB4737` -are errors: the removed top-level authored-document locations are no longer -discovered, and a rendered script without a `main` export cannot double as a -package bin, so the compiler refuses to omit or misbuild them silently. The CLI prints these in +are **informational** nudges and never block anything. `AB4736`–`AB4738` are +errors: the removed top-level authored-document locations are no longer +discovered, and a conventional script whose `bin` entry would run an export +the artifact script ignores cannot ship on both surfaces, so the compiler +refuses to omit or misbuild them silently. The CLI prints these in human `validate` output and includes them in every `--json` diagnostics array. Which explicit config keys *claim* a conventional module out of discovery is @@ -307,6 +308,24 @@ script to `.ts` so one plain module ships as both the bin and the artifact script; or prefix a path segment with `_` (`src/scripts/_name.tsx`) to keep the module out of script discovery and bin-only. +### `AB4738` — plain script claimed as a package bin entry runs only as the bin + +An explicit `bin` entry references a conventional plain script +(`src/scripts/.ts`) that exports a `default` but no named `main`. Both +the bin envelope and the artifact-script envelope wrap a `main(argv)` export +and bundle a self-executing module (no `main`, no `default`) byte for byte, +so those shapes run identically on both surfaces. Only the bin envelope falls +back to invoking a default export: the artifact `scripts/.mjs` would +merely define the function and exit, so a successful build would publish an +inert script beside a working bin. The detection is the same static export +scan the package build uses. The message names every `bin` entry referencing +the module. + +Recover: export a named `main(argv)` so both surfaces run the same entry; +make the module self-executing (drop the default export and run at top +level); or prefix a path segment with `_` (`src/scripts/_name.ts`) to keep +the module out of script discovery and bin-only. + ## Prebuilt payloads (`AB4740`–`AB4750`) The `payload` block and `{ prebuilt: ... }` entries (see diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 01af13b57..71294b422 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -77,7 +77,7 @@ entries carry `provenance.kind: 'conventional'` in the normalized model. | `src/mcp/.ts` | Stdio entry for the declared MCP server `` that names no `entry`, `command`, or `url`. | Declare `entry` explicitly | | `src/mcp//{tools,resources,prompts}/*.{ts,tsx}` | Generated MCP server routes; path supplies identity and each executable module supplies static `config`, schemas, and one async default Server Component. | Set `routes.servers.` to `custom`, `command`, or `remote` | | `src/mcp//apps/*.{ts,tsx}` | Browser MCP App entry compiled to self-contained HTML and registered on the generated server; static `config.resourceUri` is required. An optional `config.template` HTML shell resolves relative to the route module like its imports (`'./dashboard.html'`); the legacy project-root-relative form is accepted only while unambiguous (`AB4827` otherwise). Tools, resources, and prompts reference the App from their own static `config` with `appResourceUri('')` from `agent-bundle/routes` or a shared `const` string literal instead of repeating the `ui://` literal. | Use a custom server or prefix the file with `_` | -| `src/scripts/.ts` | Plain script compiled to `scripts/.mjs` in every selected target artifact — the same pipeline explicit `scripts` entries use, with ordinary Node stdout/stderr semantics. A `scripts` entry that references the file claims it. Nested modules are hard errors (`AB4808`). A `bin` (or `lib`) entry that references the file does **not** claim it: the module ships as both the npm bin and the artifact script (see [Which config keys claim a conventional module](#which-config-keys-claim-a-conventional-module)). | Prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | +| `src/scripts/.ts` | Plain script compiled to `scripts/.mjs` in every selected target artifact — the same pipeline explicit `scripts` entries use, with ordinary Node stdout/stderr semantics. A `scripts` entry that references the file claims it. Nested modules are hard errors (`AB4808`). A `bin` (or `lib`) entry that references the file does **not** claim it: the module ships as both the npm bin and the artifact script (see [Which config keys claim a conventional module](#which-config-keys-claim-a-conventional-module)); export `main` or make the module self-executing, because a `default`-only module would run as the bin but ship as an inert script (`AB4738`). | Prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | | `src/scripts/.tsx` | Rendered script: the async default component receives `{ argv, signal }` and renders through the Agent renderer with the CLI output contract (`--json`, `--ndjson`, TTY progress, piped Markdown). Compiles to `scripts/.mjs` plus a `scripts/-flight.mjs` react-server worker. The extension is the explicit, visible contract — plain `.ts` scripts are never wrapped in React behavior, and explicit `scripts` config entries stay plain regardless of extension. A `bin` entry that references a rendered script exporting no named `main` is `AB4737`: the component cannot double as the bin's `main`; export `main` and the module serves both surfaces. | Rename to `.ts`, prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | | `src/cli/**/*.{ts,tsx}` | Routed CLI commands compiled into one collision-checked command graph and one generated package executable named after `plugin.name` (superseding the `src/cli.ts` bin convention for the project). Nesting is identity: `src/cli/library/audit.ts` runs as ` library audit`. Plain `.ts` commands execute directly and print one canonical JSON line; `.tsx` commands render through the dispatcher with the four output modes. | `bin: false`, `routes.cli: 'conventional'`, or prefix a path segment with `_` | | `src/events//.{ts,tsx}`, `src/events/stop.{ts,tsx}` | Semantic event route: the path is the canonical event family (`src/events/tool/after.tsx` is `tool/after`; `stop` is the one top-level family) and must be one of the admitted `canonicalAgentEvents`. The optional static `config` (`AgentEventRouteConfig`: `targets`, `tools`, `runtime: 'shared' \| 'standalone'`, `fallback`, `delivery`, `timeoutMs`) restricts hosts and selects the execution mode; the async default Server Component receives `AgentEventRouteProps` (`{ canonical, native, signal }`) and returns `Agent.*` output that the selected host adapter encodes into its native hook envelope. Application code never branches on host JSON or emits native hook documents; per-host support is a capability state (`supported`/`degraded`/`unavailable`/`prohibited`) surfaced by `inspect` and enforced at build time (`AB4817`, `AB4823`–`AB4825`). | Restrict `config.targets`, or prefix a path segment with `_` | @@ -109,7 +109,7 @@ intended "same entry, npm bin + hook target" shape. | `scripts.` | every route directory | Claimed: the explicit entry ships it as `scripts/.mjs`; the convention no longer applies. A *different* module under `src/scripts/` sharing the configured `` is `AB4809`. | | `hooks.[].handler` | every route directory | Claimed: the module is a hook handler compiled under `hooks/`, not an artifact script. | | `mcp.servers..entry`, `mcp.servers..apps..entry` / `.template` | every route directory | Claimed: the module is the server or App entry compiled under `mcp/` or `mcp-apps/`. | -| `bin.` | `src/cli/**`, `src/events/**`, `src/mcp/**`, `src/providers/*`, and any `src/scripts/**` module that is nested or unsafely named — **not** a safely named direct `src/scripts/` child | Not claimed: the module ships as both `dist/bin/.js` and `scripts/.mjs`. A rendered `src/scripts/.tsx` referenced by `bin` must also export `main`, otherwise `AB4737`. | +| `bin.` | `src/cli/**`, `src/events/**`, `src/mcp/**`, `src/providers/*`, and any `src/scripts/**` module that is nested or unsafely named — **not** a safely named direct `src/scripts/` child | Not claimed: the module ships as both `dist/bin/.js` and `scripts/.mjs`. The module must export `main` or be self-executing: a plain `default`-only module is `AB4738`, and a rendered `src/scripts/.tsx` without `main` is `AB4737`. | | `lib.entry` | `src/cli/**`, `src/events/**`, `src/mcp/**`, `src/providers/*`, and any `src/scripts/**` module that is nested or unsafely named — **not** a safely named direct `src/scripts/` child | Not claimed: the module ships as both `dist/.js` (with declarations) and `scripts/.mjs`. | To ship a `src/scripts/` module as a bin only, prefix a path segment with `_` diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 0d9b729fa..ad85a7838 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -88,9 +88,9 @@ project). The `src/cli.ts`, `src/index.ts`, and `src/mcp/.ts` convent when the config is silent; config always wins and `bin: false` / `lib: false` opt out. An explicit `scripts`, `hooks`, or `mcp` entry claims the module it references out of conventional route discovery, but a `bin` or `lib` entry does not claim a `src/scripts/.ts` module: the same file -ships as both `dist/bin/.js` and the artifact `scripts/.mjs` (a rendered `.tsx` script -referenced by `bin` must also export `main`, otherwise `AB4737`; prefix a path segment with `_` for a -bin-only module). MCP server +ships as both `dist/bin/.js` and the artifact `scripts/.mjs` (the module must export +`main` or be self-executing: a `default`-only plain script is `AB4738` and a rendered `.tsx` script +without `main` is `AB4737`; prefix a path segment with `_` for a bin-only module). MCP server entries that default-export a server factory are wrapped in the framework stdio lifecycle shell (console-to-stderr guard with raw stdout restored for protocol frames, SIGINT 130 / SIGTERM 143, stdin-EOF exit 0, bounded shutdown, heartbeat), also available directly from diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index af1fdd099..07b07a811 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -1,7 +1,7 @@ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'; import { basename, extname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path'; -import { scanEntryExportsSource } from '../build/entry-exports.ts'; +import { type EntryExportScan, scanEntryExportsSource } from '../build/entry-exports.ts'; import { toPosixRelative } from '../core/paths.ts'; import { isPlainRecord, isRecord } from '../core/strict-json.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; @@ -1881,13 +1881,16 @@ const explicitBinNamesBySource = (loaded: LoadedConfig): ReadonlyMap { +/** + * The build's own static export scan of one conventional script, so the + * dual-surface gates below agree with the bin envelope's export selection + * (`main` first, then `default`). Undefined when the module is unreadable. + */ +const scriptEntryExports = (source: string): EntryExportScan | undefined => { try { - return scanEntryExportsSource(readFileSync(source, 'utf8')).hasMainExport; + return scanEntryExportsSource(readFileSync(source, 'utf8')); } catch { - // An unreadable module cannot satisfy the bin surface either. - return false; + return undefined; } }; @@ -1901,11 +1904,28 @@ const validateConventionalScripts = ( for (const route of discovered.routeGraph?.scripts ?? []) { const relativePath = route.provenance.relativePath; const judgment = judgeScriptRoute(route, configured); + const binNames = binNamesBySource.get(route.source); + const binList = binNames?.map((name) => JSON.stringify(name)).join(', '); switch (judgment) { - case 'shippable': - // A plain script a `bin` entry also names ships as both surfaces: - // the npm bin and the artifact script are disjoint outputs (#389). + case 'shippable': { + // A plain script a `bin` entry also names ships as both surfaces: the + // npm bin and the artifact script are disjoint outputs (#389). Both + // pipelines wrap a `main` export in the process envelope and bundle a + // self-executing module byte for byte, so those shapes agree. Only + // the bin envelope falls back to a default export; the artifact + // script would merely define it, so that shape is gated. + const exports = binNames === undefined ? undefined : scriptEntryExports(route.source); + if (exports !== undefined && !exports.hasMainExport && exports.hasDefaultExport) { + diagnostics.push({ + code: 'AB4738', + message: `Script ${relativePath} is also the entry of bin ${binList} and exports a default but no main; the bin envelope would run the default export while the artifact script would only define it.`, + recovery: 'Export a named main(argv) so both the bin and the artifact script run the same entry, make the module self-executing (no default export), or prefix a path segment with "_" to keep the module bin-only.', + severity: 'error', + sourcePath: route.source, + }); + } break; + } // Rendered scripts ship through the Agent renderer pipeline (#102 // stage 3); AB4807 is retired and never reused. case 'rendered': { @@ -1914,11 +1934,10 @@ const validateConventionalScripts = ( // only falls back to the default export, so the two surfaces can share // one module exactly when it exports `main`; the detection is the // build's own export scan, so the gate and the envelope always agree. - const binNames = binNamesBySource.get(route.source); - if (binNames !== undefined && !renderedScriptExportsMain(route.source)) { + if (binNames !== undefined && scriptEntryExports(route.source)?.hasMainExport !== true) { diagnostics.push({ code: 'AB4737', - message: `Rendered script ${relativePath} is also the entry of bin ${binNames.map((name) => JSON.stringify(name)).join(', ')} but exports no main; the bin envelope would call its default Server Component as main(argv).`, + message: `Rendered script ${relativePath} is also the entry of bin ${binList} but exports no main; the bin envelope would call its default Server Component as main(argv).`, recovery: 'Export a named main(argv) from the module for the bin surface, point the bin entry at a plain module that exports main, rename the script to .ts so one plain module ships as both the bin and the artifact script, or prefix a path segment with "_" to keep the module bin-only.', severity: 'error', sourcePath: route.source, diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index a091ac7f3..b8af88568 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -328,6 +328,40 @@ it('gates a bin-claimed rendered script with AB4737 only when it exports no main expect(graph.scripts.map((route) => route.id)).toEqual(['script:render-notes', 'script:render-poster']); }); +it('gates a bin-claimed plain script with AB4738 only when its bin would run a default export the script ignores (#389)', async () => { + const project = await createInspectProject({ + 'agent-bundle.config.ts': [ + 'export default {', + ' bin: {', + " 'default-only': './src/scripts/default-only.ts',", + " hauler: './src/scripts/hauler.ts',", + " plain: './src/scripts/plain.ts',", + ' },', + " plugin: { name: 'routes-fixture', version: '1.0.0' },", + " targets: ['portable'],", + '};', + '', + ].join('\n'), + // The bin envelope would run this default export; the artifact script + // pipeline only wraps main, so scripts/default-only.mjs would be inert. + 'src/scripts/default-only.ts': 'export default async (argv: readonly string[]): Promise => argv.length;\n', + // main is wrapped by both envelopes; a self-executing module bundles + // byte for byte on both surfaces. + 'src/scripts/hauler.ts': 'export const main = async (argv: readonly string[]): Promise => argv.length;\n', + 'src/scripts/plain.ts': "process.stdout.write('plain\\n');\n", + }); + + const result = await validate({ root: project }); + const gate = result.diagnostics.filter(({ code }) => code === 'AB4738'); + expect(gate).toHaveLength(1); + expect(gate[0]).toMatchObject({ + message: expect.stringContaining('default-only.ts is also the entry of bin "default-only" and exports a default but no main'), + severity: 'error', + sourcePath: join(project, 'src/scripts/default-only.ts'), + }); + expect(result.diagnostics.filter(({ code }) => code === 'AB4737')).toEqual([]); +}); + it('errors with AB4800 when a declared entry, command, or url claims a routed server', async () => { const root = await createRoot(); await writeTree(root, { 'src/mcp/curator/tools/inspect.ts': moduleSource }); From 18515e1810350fb424e1089d95449e535a116ae4 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 09:33:40 +0000 Subject: [PATCH 06/12] fix(validate): require both main and the default component for a bin-claimed rendered script A rendered script that exports main but no default component would build a working bin beside an artifact script that fails at run time with nothing to render. AB4737 now names whichever export is missing. --- .changeset/389-bin-claim-script-discovery.md | 2 +- docs/diagnostics.md | 41 ++++++++++--------- docs/entry-conventions.md | 4 +- packages/agent-bundle/README.md | 5 ++- packages/agent-bundle/src/config/validate.ts | 26 +++++++----- .../agent-bundle/tests/normalization.test.ts | 6 +-- .../agent-bundle/tests/route-graph.test.ts | 36 ++++++++++------ 7 files changed, 71 insertions(+), 49 deletions(-) diff --git a/.changeset/389-bin-claim-script-discovery.md b/.changeset/389-bin-claim-script-discovery.md index e1c788b1d..4d655dcb0 100644 --- a/.changeset/389-bin-claim-script-discovery.md +++ b/.changeset/389-bin-claim-script-discovery.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Keep a conventional `src/scripts/.ts` module in script discovery when a `bin` or `lib` config entry also references it, so `agent-bundle build` emits both `dist/bin/.js` and the artifact `scripts/.mjs` and `agent-bundle inspect --json` lists the module under both `packageBuild.bins` and `scripts` instead of silently dropping the script. Explicit `scripts`, `hooks`, and `mcp` entries still claim the module they reference. Report `AB4737` when a `bin` entry points at a rendered `src/scripts/.tsx` script that exports no named `main`, because its Server Component cannot double as the bin's `main`, and `AB4738` when it points at a plain `src/scripts/.ts` script that exports a `default` but no `main`, because the artifact script would ship inert while the bin runs the default export. (#413) +Keep a conventional `src/scripts/.ts` module in script discovery when a `bin` or `lib` config entry also references it, so `agent-bundle build` emits both `dist/bin/.js` and the artifact `scripts/.mjs` and `agent-bundle inspect --json` lists the module under both `packageBuild.bins` and `scripts` instead of silently dropping the script. Explicit `scripts`, `hooks`, and `mcp` entries still claim the module they reference. Report `AB4737` when a `bin` entry points at a rendered `src/scripts/.tsx` script that does not export both its default Server Component and a named `main`, because the bin envelope calls `main` while the artifact script renders the component, and `AB4738` when it points at a plain `src/scripts/.ts` script that exports a `default` but no `main`, because the artifact script would ship inert while the bin runs the default export. (#413) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 8ee89cf9a..ce8e04e68 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -286,27 +286,30 @@ Recover: move the document under `src/skills/`, `src/commands/`, or `src/rules/`. Explicit `skills` paths remain valid anywhere. Published artifact paths remain `skills/`, `commands/`, and `rules/`. -### `AB4737` — rendered script claimed as a package bin entry without `main` +### `AB4737` — rendered script claimed as a package bin entry lacks `main` or the component An explicit `bin` entry references a conventional rendered script -(`src/scripts/.tsx` or `.jsx`) that exports no named `main`. A plain -`src/scripts/.ts` module ships happily on both surfaces — the npm bin -envelope calls its `main(argv)` and the artifact script is the same bundle — -but a rendered script's default export is an async Server Component the -Agent renderer drives with `{ argv, signal }` props. The bin envelope prefers -a named `main` export and only falls back to the default export, so without -`main` it would call that component as `main(argv)` and produce a bin that -renders nothing; the compiler refuses the pair instead of emitting a broken -executable beside a working script. A rendered script that also exports -`main` serves both surfaces and is not gated. The detection is the same -static export scan the package build uses, so the gate and the envelope -always agree. The message names every `bin` entry referencing the module. - -Recover: export a named `main(argv)` from the module for the bin surface; -point the `bin` entry at a plain module that exports `main`; rename the -script to `.ts` so one plain module ships as both the bin and the artifact -script; or prefix a path segment with `_` (`src/scripts/_name.tsx`) to keep -the module out of script discovery and bin-only. +(`src/scripts/.tsx` or `.jsx`) that does not export **both** a default +Server Component and a named `main`. A plain `src/scripts/.ts` module +ships happily on both surfaces — the npm bin envelope calls its `main(argv)` +and the artifact script is the same bundle — but a rendered script's default +export is an async Server Component the Agent renderer drives with +`{ argv, signal }` props. The bin envelope prefers a named `main` export and +only falls back to the default export, so without `main` it would call that +component as `main(argv)` and produce a bin that renders nothing; without the +default component, the bin works but `scripts/.mjs` fails at run time +with no component to render. The compiler refuses either shape instead of +emitting a broken surface beside a working one. A rendered script that +exports both serves both surfaces and is not gated. The detection is the +same static export scan the package build uses, so the gate and the envelope +always agree. The message names every `bin` entry referencing the module and +which export is missing. + +Recover: export both a default Server Component and a named `main(argv)` +from the module; point the `bin` entry at a plain module that exports `main`; +rename the script to `.ts` so one plain module ships as both the bin and the +artifact script; or prefix a path segment with `_` (`src/scripts/_name.tsx`) +to keep the module out of script discovery and bin-only. ### `AB4738` — plain script claimed as a package bin entry runs only as the bin diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 71294b422..75508631c 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -78,7 +78,7 @@ entries carry `provenance.kind: 'conventional'` in the normalized model. | `src/mcp//{tools,resources,prompts}/*.{ts,tsx}` | Generated MCP server routes; path supplies identity and each executable module supplies static `config`, schemas, and one async default Server Component. | Set `routes.servers.` to `custom`, `command`, or `remote` | | `src/mcp//apps/*.{ts,tsx}` | Browser MCP App entry compiled to self-contained HTML and registered on the generated server; static `config.resourceUri` is required. An optional `config.template` HTML shell resolves relative to the route module like its imports (`'./dashboard.html'`); the legacy project-root-relative form is accepted only while unambiguous (`AB4827` otherwise). Tools, resources, and prompts reference the App from their own static `config` with `appResourceUri('')` from `agent-bundle/routes` or a shared `const` string literal instead of repeating the `ui://` literal. | Use a custom server or prefix the file with `_` | | `src/scripts/.ts` | Plain script compiled to `scripts/.mjs` in every selected target artifact — the same pipeline explicit `scripts` entries use, with ordinary Node stdout/stderr semantics. A `scripts` entry that references the file claims it. Nested modules are hard errors (`AB4808`). A `bin` (or `lib`) entry that references the file does **not** claim it: the module ships as both the npm bin and the artifact script (see [Which config keys claim a conventional module](#which-config-keys-claim-a-conventional-module)); export `main` or make the module self-executing, because a `default`-only module would run as the bin but ship as an inert script (`AB4738`). | Prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | -| `src/scripts/.tsx` | Rendered script: the async default component receives `{ argv, signal }` and renders through the Agent renderer with the CLI output contract (`--json`, `--ndjson`, TTY progress, piped Markdown). Compiles to `scripts/.mjs` plus a `scripts/-flight.mjs` react-server worker. The extension is the explicit, visible contract — plain `.ts` scripts are never wrapped in React behavior, and explicit `scripts` config entries stay plain regardless of extension. A `bin` entry that references a rendered script exporting no named `main` is `AB4737`: the component cannot double as the bin's `main`; export `main` and the module serves both surfaces. | Rename to `.ts`, prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | +| `src/scripts/.tsx` | Rendered script: the async default component receives `{ argv, signal }` and renders through the Agent renderer with the CLI output contract (`--json`, `--ndjson`, TTY progress, piped Markdown). Compiles to `scripts/.mjs` plus a `scripts/-flight.mjs` react-server worker. The extension is the explicit, visible contract — plain `.ts` scripts are never wrapped in React behavior, and explicit `scripts` config entries stay plain regardless of extension. A `bin` entry that references a rendered script is `AB4737` unless the module exports both the default component (for the script) and a named `main` (for the bin envelope); with both, the module serves both surfaces. | Rename to `.ts`, prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | | `src/cli/**/*.{ts,tsx}` | Routed CLI commands compiled into one collision-checked command graph and one generated package executable named after `plugin.name` (superseding the `src/cli.ts` bin convention for the project). Nesting is identity: `src/cli/library/audit.ts` runs as ` library audit`. Plain `.ts` commands execute directly and print one canonical JSON line; `.tsx` commands render through the dispatcher with the four output modes. | `bin: false`, `routes.cli: 'conventional'`, or prefix a path segment with `_` | | `src/events//.{ts,tsx}`, `src/events/stop.{ts,tsx}` | Semantic event route: the path is the canonical event family (`src/events/tool/after.tsx` is `tool/after`; `stop` is the one top-level family) and must be one of the admitted `canonicalAgentEvents`. The optional static `config` (`AgentEventRouteConfig`: `targets`, `tools`, `runtime: 'shared' \| 'standalone'`, `fallback`, `delivery`, `timeoutMs`) restricts hosts and selects the execution mode; the async default Server Component receives `AgentEventRouteProps` (`{ canonical, native, signal }`) and returns `Agent.*` output that the selected host adapter encodes into its native hook envelope. Application code never branches on host JSON or emits native hook documents; per-host support is a capability state (`supported`/`degraded`/`unavailable`/`prohibited`) surfaced by `inspect` and enforced at build time (`AB4817`, `AB4823`–`AB4825`). | Restrict `config.targets`, or prefix a path segment with `_` | | `src/state.ts` | Project state definition: default-exports `defineState({ ... })`; generated MCP, routed-CLI, and rendered-script request scopes mount `(await agent()).state` and `.notices`. | `state: false`, or rename the file to `_state.ts` | @@ -109,7 +109,7 @@ intended "same entry, npm bin + hook target" shape. | `scripts.` | every route directory | Claimed: the explicit entry ships it as `scripts/.mjs`; the convention no longer applies. A *different* module under `src/scripts/` sharing the configured `` is `AB4809`. | | `hooks.[].handler` | every route directory | Claimed: the module is a hook handler compiled under `hooks/`, not an artifact script. | | `mcp.servers..entry`, `mcp.servers..apps..entry` / `.template` | every route directory | Claimed: the module is the server or App entry compiled under `mcp/` or `mcp-apps/`. | -| `bin.` | `src/cli/**`, `src/events/**`, `src/mcp/**`, `src/providers/*`, and any `src/scripts/**` module that is nested or unsafely named — **not** a safely named direct `src/scripts/` child | Not claimed: the module ships as both `dist/bin/.js` and `scripts/.mjs`. The module must export `main` or be self-executing: a plain `default`-only module is `AB4738`, and a rendered `src/scripts/.tsx` without `main` is `AB4737`. | +| `bin.` | `src/cli/**`, `src/events/**`, `src/mcp/**`, `src/providers/*`, and any `src/scripts/**` module that is nested or unsafely named — **not** a safely named direct `src/scripts/` child | Not claimed: the module ships as both `dist/bin/.js` and `scripts/.mjs`. The module must export `main` or be self-executing: a plain `default`-only module is `AB4738`, and a rendered `src/scripts/.tsx` must export both the default component and `main`, otherwise `AB4737`. | | `lib.entry` | `src/cli/**`, `src/events/**`, `src/mcp/**`, `src/providers/*`, and any `src/scripts/**` module that is nested or unsafely named — **not** a safely named direct `src/scripts/` child | Not claimed: the module ships as both `dist/.js` (with declarations) and `scripts/.mjs`. | To ship a `src/scripts/` module as a bin only, prefix a path segment with `_` diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index ad85a7838..90e1a6248 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -89,8 +89,9 @@ when the config is silent; config always wins and `bin: false` / `lib: false` op `scripts`, `hooks`, or `mcp` entry claims the module it references out of conventional route discovery, but a `bin` or `lib` entry does not claim a `src/scripts/.ts` module: the same file ships as both `dist/bin/.js` and the artifact `scripts/.mjs` (the module must export -`main` or be self-executing: a `default`-only plain script is `AB4738` and a rendered `.tsx` script -without `main` is `AB4737`; prefix a path segment with `_` for a bin-only module). MCP server +`main` or be self-executing: a `default`-only plain script is `AB4738`, and a rendered `.tsx` script +must export both its default component and `main` or it is `AB4737`; prefix a path segment with `_` +for a bin-only module). MCP server entries that default-export a server factory are wrapped in the framework stdio lifecycle shell (console-to-stderr guard with raw stdout restored for protocol frames, SIGINT 130 / SIGTERM 143, stdin-EOF exit 0, bounded shutdown, heartbeat), also available directly from diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index 07b07a811..c3f834fe6 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -1932,17 +1932,23 @@ const validateConventionalScripts = ( // The rendered-script default export is a Server Component the // renderer drives. The bin envelope prefers a named `main` export and // only falls back to the default export, so the two surfaces can share - // one module exactly when it exports `main`; the detection is the + // one module exactly when it exports both; the detection is the // build's own export scan, so the gate and the envelope always agree. - if (binNames !== undefined && scriptEntryExports(route.source)?.hasMainExport !== true) { - diagnostics.push({ - code: 'AB4737', - message: `Rendered script ${relativePath} is also the entry of bin ${binList} but exports no main; the bin envelope would call its default Server Component as main(argv).`, - recovery: 'Export a named main(argv) from the module for the bin surface, point the bin entry at a plain module that exports main, rename the script to .ts so one plain module ships as both the bin and the artifact script, or prefix a path segment with "_" to keep the module bin-only.', - severity: 'error', - sourcePath: route.source, - }); - } + if (binNames === undefined) break; + const exports = scriptEntryExports(route.source); + const hasMain = exports?.hasMainExport === true; + const hasDefault = exports?.hasDefaultExport === true; + if (hasMain && hasDefault) break; + const missing = !hasMain && !hasDefault + ? 'neither a default Server Component nor a named main' + : hasMain ? 'no default Server Component' : 'no named main'; + diagnostics.push({ + code: 'AB4737', + message: `Rendered script ${relativePath} is also the entry of bin ${binList} but exports ${missing}; the artifact script renders the default component and the bin envelope calls main(argv).`, + recovery: 'Export both a default Server Component and a named main(argv) from the module, point the bin entry at a plain module that exports main, rename the script to .ts so one plain module ships as both the bin and the artifact script, or prefix a path segment with "_" to keep the module bin-only.', + severity: 'error', + sourcePath: route.source, + }); break; } case 'nested': diff --git a/packages/agent-bundle/tests/normalization.test.ts b/packages/agent-bundle/tests/normalization.test.ts index cdcf0649d..776e18785 100644 --- a/packages/agent-bundle/tests/normalization.test.ts +++ b/packages/agent-bundle/tests/normalization.test.ts @@ -1200,13 +1200,13 @@ it('ships a bin-claimed plain conventional script as both surfaces and refuses a skills: [], }; - // The fixture root has no files, so the export scan finds no main and the gate fires. + // The fixture root has no files, so the export scan finds neither export and the gate fires. const gate = validateSource(loaded, discovered, registry).filter(({ code }) => code === 'AB4737'); expect(gate).toEqual([ { code: 'AB4737', - message: 'Rendered script src/scripts/render-notes.tsx is also the entry of bin "notes", "notes-again" but exports no main; the bin envelope would call its default Server Component as main(argv).', - recovery: 'Export a named main(argv) from the module for the bin surface, point the bin entry at a plain module that exports main, rename the script to .ts so one plain module ships as both the bin and the artifact script, or prefix a path segment with "_" to keep the module bin-only.', + message: 'Rendered script src/scripts/render-notes.tsx is also the entry of bin "notes", "notes-again" but exports neither a default Server Component nor a named main; the artifact script renders the default component and the bin envelope calls main(argv).', + recovery: 'Export both a default Server Component and a named main(argv) from the module, point the bin entry at a plain module that exports main, rename the script to .ts so one plain module ships as both the bin and the artifact script, or prefix a path segment with "_" to keep the module bin-only.', severity: 'error', sourcePath: `${root}/src/scripts/render-notes.tsx`, }, diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index b8af88568..7a25f851b 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -296,36 +296,48 @@ it('gates a bin-claimed rendered script with AB4737 only when it exports no main const project = await createInspectProject({ 'agent-bundle.config.ts': [ 'export default {', - " bin: { notes: './src/scripts/render-notes.tsx', poster: './src/scripts/render-poster.tsx' },", + ' bin: {', + " notes: './src/scripts/render-notes.tsx',", + " poster: './src/scripts/render-poster.tsx',", + " tool: './src/scripts/render-tool.tsx',", + ' },', " plugin: { name: 'routes-fixture', version: '1.0.0' },", " targets: ['portable'],", '};', '', ].join('\n'), - // Exports the named main the bin envelope selects first, so the module - // serves both surfaces: main(argv) for the bin, the component for the script. + // Exports both: main(argv) for the bin envelope, the component for the + // rendered script, so the module serves both surfaces. 'src/scripts/render-notes.tsx': [ 'export const main = async (argv: readonly string[]): Promise => argv.length;', 'export default async () => undefined;', '', ].join('\n'), + // Component only: the bin envelope would call it as main(argv). 'src/scripts/render-poster.tsx': 'export default async () => undefined;\n', + // main only: the bin works, but the rendered script has no component to render. + 'src/scripts/render-tool.tsx': 'export const main = async (argv: readonly string[]): Promise => argv.length;\n', }); const result = await validate({ root: project }); const gate = result.diagnostics.filter(({ code }) => code === 'AB4737'); - expect(gate).toHaveLength(1); - expect(gate[0]).toMatchObject({ - message: expect.stringContaining('render-poster.tsx is also the entry of bin "poster" but exports no main'), - severity: 'error', - sourcePath: join(project, 'src/scripts/render-poster.tsx'), - }); - // Both rendered scripts stay discovered beside their bins: the gate names + expect(gate.map((diagnostic) => diagnostic.sourcePath)).toEqual([ + join(project, 'src/scripts/render-poster.tsx'), + join(project, 'src/scripts/render-tool.tsx'), + ]); + expect(gate[0]!.message).toContain('render-poster.tsx is also the entry of bin "poster" but exports no named main'); + expect(gate[1]!.message).toContain('render-tool.tsx is also the entry of bin "tool" but exports no default Server Component'); + expect(gate.every((diagnostic) => diagnostic.severity === 'error')).toBe(true); + // Every rendered script stays discovered beside its bin: the gate names // the conflict instead of dropping a route. const graph = await compileRouteGraph(project, fixtureConfig({ - bin: { notes: './src/scripts/render-notes.tsx', poster: './src/scripts/render-poster.tsx' }, + bin: { + notes: './src/scripts/render-notes.tsx', + poster: './src/scripts/render-poster.tsx', + tool: './src/scripts/render-tool.tsx', + }, })); - expect(graph.scripts.map((route) => route.id)).toEqual(['script:render-notes', 'script:render-poster']); + expect(graph.scripts.map((route) => route.id)).toEqual(['script:render-notes', 'script:render-poster', 'script:render-tool']); }); it('gates a bin-claimed plain script with AB4738 only when its bin would run a default export the script ignores (#389)', async () => { From f582e31958155a90467adfe45e3fb773b0511bea Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 09:39:14 +0000 Subject: [PATCH 07/12] fix(routes): scope the dual-surface exception to bin claims; lib keeps claiming A library entry is not an executable surface, so a lib.entry under src/scripts/ has no second envelope to agree with and keeps removing the module from discovery exactly as before. --- .changeset/389-bin-claim-script-discovery.md | 2 +- docs/diagnostics.md | 11 ++--- docs/entry-conventions.md | 25 ++++++------ packages/agent-bundle/README.md | 4 +- packages/agent-bundle/src/routes/graph.ts | 40 +++++++++---------- .../agent-bundle/tests/route-graph.test.ts | 15 +++---- 6 files changed, 50 insertions(+), 47 deletions(-) diff --git a/.changeset/389-bin-claim-script-discovery.md b/.changeset/389-bin-claim-script-discovery.md index 4d655dcb0..71798b5e1 100644 --- a/.changeset/389-bin-claim-script-discovery.md +++ b/.changeset/389-bin-claim-script-discovery.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Keep a conventional `src/scripts/.ts` module in script discovery when a `bin` or `lib` config entry also references it, so `agent-bundle build` emits both `dist/bin/.js` and the artifact `scripts/.mjs` and `agent-bundle inspect --json` lists the module under both `packageBuild.bins` and `scripts` instead of silently dropping the script. Explicit `scripts`, `hooks`, and `mcp` entries still claim the module they reference. Report `AB4737` when a `bin` entry points at a rendered `src/scripts/.tsx` script that does not export both its default Server Component and a named `main`, because the bin envelope calls `main` while the artifact script renders the component, and `AB4738` when it points at a plain `src/scripts/.ts` script that exports a `default` but no `main`, because the artifact script would ship inert while the bin runs the default export. (#413) +Keep a conventional `src/scripts/.ts` module in script discovery when a `bin` config entry also references it, so `agent-bundle build` emits both `dist/bin/.js` and the artifact `scripts/.mjs` and `agent-bundle inspect --json` lists the module under both `packageBuild.bins` and `scripts` instead of silently dropping the script. Explicit `scripts`, `hooks`, `lib`, and `mcp` entries still claim the module they reference. Report `AB4737` when a `bin` entry points at a rendered `src/scripts/.tsx` script that does not export both its default Server Component and a named `main`, because the bin envelope calls `main` while the artifact script renders the component, and `AB4738` when it points at a plain `src/scripts/.ts` script that exports a `default` but no `main`, because the artifact script would ship inert while the bin runs the default export. (#413) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index ce8e04e68..fcd98281d 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -210,11 +210,12 @@ human `validate` output and includes them in every `--json` diagnostics array. Which explicit config keys *claim* a conventional module out of discovery is tabulated in `docs/entry-conventions.md` ("Which config keys claim a -conventional module"). In short: `scripts`, `hooks`, and `mcp` entries claim -the module they reference; `bin` and `lib` entries claim every conventional -module **except** one under `src/scripts/`, which keeps shipping as an -artifact script beside the package output because the two outputs are -disjoint. That dual-surface shape is intentional and raises no diagnostic. +conventional module"). In short: `scripts`, `hooks`, `lib`, and `mcp` entries +claim the module they reference; a `bin` entry claims every conventional +module **except** a safely named direct `src/scripts/` child, which +keeps shipping as an artifact script beside the bin because the two outputs +are disjoint and both envelopes run the same `main`. That dual-surface shape +is intentional and raises no diagnostic. ### `AB4730` — self-connecting stdio MCP entry diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 75508631c..2396cb7cb 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -77,7 +77,7 @@ entries carry `provenance.kind: 'conventional'` in the normalized model. | `src/mcp/.ts` | Stdio entry for the declared MCP server `` that names no `entry`, `command`, or `url`. | Declare `entry` explicitly | | `src/mcp//{tools,resources,prompts}/*.{ts,tsx}` | Generated MCP server routes; path supplies identity and each executable module supplies static `config`, schemas, and one async default Server Component. | Set `routes.servers.` to `custom`, `command`, or `remote` | | `src/mcp//apps/*.{ts,tsx}` | Browser MCP App entry compiled to self-contained HTML and registered on the generated server; static `config.resourceUri` is required. An optional `config.template` HTML shell resolves relative to the route module like its imports (`'./dashboard.html'`); the legacy project-root-relative form is accepted only while unambiguous (`AB4827` otherwise). Tools, resources, and prompts reference the App from their own static `config` with `appResourceUri('')` from `agent-bundle/routes` or a shared `const` string literal instead of repeating the `ui://` literal. | Use a custom server or prefix the file with `_` | -| `src/scripts/.ts` | Plain script compiled to `scripts/.mjs` in every selected target artifact — the same pipeline explicit `scripts` entries use, with ordinary Node stdout/stderr semantics. A `scripts` entry that references the file claims it. Nested modules are hard errors (`AB4808`). A `bin` (or `lib`) entry that references the file does **not** claim it: the module ships as both the npm bin and the artifact script (see [Which config keys claim a conventional module](#which-config-keys-claim-a-conventional-module)); export `main` or make the module self-executing, because a `default`-only module would run as the bin but ship as an inert script (`AB4738`). | Prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | +| `src/scripts/.ts` | Plain script compiled to `scripts/.mjs` in every selected target artifact — the same pipeline explicit `scripts` entries use, with ordinary Node stdout/stderr semantics. A `scripts` entry that references the file claims it. Nested modules are hard errors (`AB4808`). A `bin` entry that references the file does **not** claim it: the module ships as both the npm bin and the artifact script (see [Which config keys claim a conventional module](#which-config-keys-claim-a-conventional-module)); export `main` or make the module self-executing, because a `default`-only module would run as the bin but ship as an inert script (`AB4738`). | Prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | | `src/scripts/.tsx` | Rendered script: the async default component receives `{ argv, signal }` and renders through the Agent renderer with the CLI output contract (`--json`, `--ndjson`, TTY progress, piped Markdown). Compiles to `scripts/.mjs` plus a `scripts/-flight.mjs` react-server worker. The extension is the explicit, visible contract — plain `.ts` scripts are never wrapped in React behavior, and explicit `scripts` config entries stay plain regardless of extension. A `bin` entry that references a rendered script is `AB4737` unless the module exports both the default component (for the script) and a named `main` (for the bin envelope); with both, the module serves both surfaces. | Rename to `.ts`, prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | | `src/cli/**/*.{ts,tsx}` | Routed CLI commands compiled into one collision-checked command graph and one generated package executable named after `plugin.name` (superseding the `src/cli.ts` bin convention for the project). Nesting is identity: `src/cli/library/audit.ts` runs as ` library audit`. Plain `.ts` commands execute directly and print one canonical JSON line; `.tsx` commands render through the dispatcher with the four output modes. | `bin: false`, `routes.cli: 'conventional'`, or prefix a path segment with `_` | | `src/events//.{ts,tsx}`, `src/events/stop.{ts,tsx}` | Semantic event route: the path is the canonical event family (`src/events/tool/after.tsx` is `tool/after`; `stop` is the one top-level family) and must be one of the admitted `canonicalAgentEvents`. The optional static `config` (`AgentEventRouteConfig`: `targets`, `tools`, `runtime: 'shared' \| 'standalone'`, `fallback`, `delivery`, `timeoutMs`) restricts hosts and selects the execution mode; the async default Server Component receives `AgentEventRouteProps` (`{ canonical, native, signal }`) and returns `Agent.*` output that the selected host adapter encodes into its native hook envelope. Application code never branches on host JSON or emits native hook documents; per-host support is a capability state (`supported`/`degraded`/`unavailable`/`prohibited`) surfaced by `inspect` and enforced at build time (`AB4817`, `AB4823`–`AB4825`). | Restrict `config.targets`, or prefix a path segment with `_` | @@ -93,24 +93,25 @@ An explicit config entry that references a module under a conventional route directory *claims* it: the module belongs to that declaration and leaves conventional discovery. Claims are decided by the module path the entry resolves to, so nothing is ever compiled twice into one artifact output. The -exception is the package build: a `bin` or `lib` entry compiles to `dist/`, -which is disjoint from every artifact output, so a direct `src/scripts/` -child those keys reference stays a conventional script and ships on both -surfaces. (A nested `src/scripts//` module or one whose stem is not -a safe route identity — which the flat scripts artifact could not ship anyway -— stays claimed, so a package-only entry there never turns into `AB4808` or -`AB4803`.) -`inspect` shows such a module under both `packageBuild.bins` (or -`packageBuild.lib`) and `scripts`; no diagnostic fires, because that is the -intended "same entry, npm bin + hook target" shape. +exception is `bin`: a bin compiles to `dist/bin/.js`, which is disjoint +from every artifact output, and the bin envelope and the artifact-script +envelope run the same `main`, so a direct `src/scripts/` child a `bin` +entry references stays a conventional script and ships on both surfaces. (A +nested `src/scripts//` module or one whose stem is not a safe route +identity — which the flat scripts artifact could not ship anyway — stays +claimed, so a bin-only entry there never turns into `AB4808` or `AB4803`.) +`inspect` shows such a module under both `packageBuild.bins` and `scripts`; no +diagnostic fires, because that is the intended "same entry, npm bin + hook +target" shape. `lib` is not an executable surface, so a `lib.entry` claims +its module like every other key. | Config key | Claims a module under | Effect on a `src/scripts/.ts` module it references | | --- | --- | --- | | `scripts.` | every route directory | Claimed: the explicit entry ships it as `scripts/.mjs`; the convention no longer applies. A *different* module under `src/scripts/` sharing the configured `` is `AB4809`. | | `hooks.[].handler` | every route directory | Claimed: the module is a hook handler compiled under `hooks/`, not an artifact script. | | `mcp.servers..entry`, `mcp.servers..apps..entry` / `.template` | every route directory | Claimed: the module is the server or App entry compiled under `mcp/` or `mcp-apps/`. | +| `lib.entry` | every route directory | Claimed: the module is the library entry compiled to `dist/.js`, not an artifact script. | | `bin.` | `src/cli/**`, `src/events/**`, `src/mcp/**`, `src/providers/*`, and any `src/scripts/**` module that is nested or unsafely named — **not** a safely named direct `src/scripts/` child | Not claimed: the module ships as both `dist/bin/.js` and `scripts/.mjs`. The module must export `main` or be self-executing: a plain `default`-only module is `AB4738`, and a rendered `src/scripts/.tsx` must export both the default component and `main`, otherwise `AB4737`. | -| `lib.entry` | `src/cli/**`, `src/events/**`, `src/mcp/**`, `src/providers/*`, and any `src/scripts/**` module that is nested or unsafely named — **not** a safely named direct `src/scripts/` child | Not claimed: the module ships as both `dist/.js` (with declarations) and `scripts/.mjs`. | To ship a `src/scripts/` module as a bin only, prefix a path segment with `_` (`src/scripts/_hauler.ts`): private segments opt the module out of discovery diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 90e1a6248..3b89daed4 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -86,8 +86,8 @@ agent-bundle also owns the npm-facing package build: `bin` entries become self-e optional `lib` entry becomes `dist/.js` with declarations (resolving `typescript` from the project). The `src/cli.ts`, `src/index.ts`, and `src/mcp/.ts` conventions fill these in when the config is silent; config always wins and `bin: false` / `lib: false` opt out. An explicit -`scripts`, `hooks`, or `mcp` entry claims the module it references out of conventional route -discovery, but a `bin` or `lib` entry does not claim a `src/scripts/.ts` module: the same file +`scripts`, `hooks`, `lib`, or `mcp` entry claims the module it references out of conventional route +discovery, but a `bin` entry does not claim a `src/scripts/.ts` module: the same file ships as both `dist/bin/.js` and the artifact `scripts/.mjs` (the module must export `main` or be self-executing: a `default`-only plain script is `AB4738`, and a rendered `.tsx` script must export both its default component and `main` or it is `AB4737`; prefix a path segment with `_` diff --git a/packages/agent-bundle/src/routes/graph.ts b/packages/agent-bundle/src/routes/graph.ts index f75af3cbf..71e96e462 100644 --- a/packages/agent-bundle/src/routes/graph.ts +++ b/packages/agent-bundle/src/routes/graph.ts @@ -205,36 +205,36 @@ const claimedModuleEntry = (value: unknown): string | undefined => { interface ConfigClaimedSources { /** - * Modules an artifact-side declaration (`scripts`, `hooks`, `mcp`) + * Modules an explicit `scripts`, `hooks`, `lib`, or `mcp` declaration * references. They belong to that declaration and never become * conventional routes. */ readonly artifact: ReadonlySet; /** - * Modules the package build (`bin`, `lib`) compiles. They leave route - * discovery too, except a direct `src/scripts/` child: - * `dist/bin/.js` and `/scripts/.mjs` are disjoint - * outputs, so one entry ships as both an npm bin and an artifact script - * instead of silently losing the script (#389). + * Modules explicit `bin` entries compile. They leave route discovery too, + * except a direct `src/scripts/` child: `dist/bin/.js` and + * `/scripts/.mjs` are disjoint outputs and both envelopes + * run the same `main`, so one entry ships as both an npm bin and an + * artifact script instead of silently losing the script (#389). */ - readonly packageBuild: ReadonlySet; + readonly bin: ReadonlySet; } /** * Absolute module paths explicit configuration already claims. Config always * wins — the rule the entry conventions established — so a module an explicit - * `scripts`, `hooks`, or `mcp` declaration references belongs to that + * `scripts`, `hooks`, `lib`, or `mcp` declaration references belongs to that * declaration and never becomes a conventional route. Two shipped examples * declare `scripts` entries under `src/scripts/`; this rule keeps their - * layouts route-free without a migration. Package-build claims (`bin`, `lib`) - * are reported separately because they coexist with a conventional script. + * layouts route-free without a migration. `bin` claims are reported + * separately because a bin coexists with a conventional script. */ const configClaimedSources = ( projectRoot: string, config: Readonly, ): ConfigClaimedSources => { const artifact = new Set(); - const packageBuild = new Set(); + const bin = new Set(); const claimInto = (claimed: Set, value: unknown): void => { const entry = claimedModuleEntry(value); if (entry !== undefined && entry.trim().length > 0) claimed.add(resolve(projectRoot, entry)); @@ -252,11 +252,11 @@ const configClaimedSources = ( } } } - const bin = configValue(config, 'bin'); - if (isRecord(bin)) { - for (const value of Object.values(bin)) claimInto(packageBuild, value); + const bins = configValue(config, 'bin'); + if (isRecord(bins)) { + for (const value of Object.values(bins)) claimInto(bin, value); } - claimInto(packageBuild, configValue(config, 'lib')); + claim(configValue(config, 'lib')); const mcp = configValue(config, 'mcp'); const servers = isRecord(mcp) && isRecord(mcp.servers) ? mcp.servers : undefined; for (const server of Object.values(servers ?? {})) { @@ -269,15 +269,15 @@ const configClaimedSources = ( claim(app.template); } } - return { artifact, packageBuild }; + return { artifact, bin }; }; /** * True for a direct child of the conventional scripts root whose stem is a * safe route identity — the only shape the flat scripts artifact can ship. A - * nested or unsafely named module a package-build entry names stays claimed: - * keeping it discovered would only turn a valid package-only configuration - * into an AB4808 or AB4803 error. + * nested or unsafely named module a `bin` entry names stays claimed: keeping + * it discovered would only turn a valid bin-only configuration into an + * AB4808 or AB4803 error. */ const isConventionalScriptPath = (relativePath: string): boolean => { const segments = relativePath.split('/'); @@ -567,7 +567,7 @@ export const compileRouteGraph = async ( for (const source of sources) { if (claimed.artifact.has(source)) continue; const relativePath = toPosixPath(relative(projectRoot, source)); - if (claimed.packageBuild.has(source) && !isConventionalScriptPath(relativePath)) continue; + if (claimed.bin.has(source) && !isConventionalScriptPath(relativePath)) continue; if (isPrivateRoutePath(relativePath) || isProjectPathIgnored(rules, projectRoot, source)) continue; const module = classifyModule(source, relativePath); if ( diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index 7a25f851b..89c8755bb 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -258,7 +258,7 @@ it('never compiles a module explicit configuration claims: config always wins', expect('routeGraph' in discovered).toBe(false); }); -it('keeps a bin- or lib-claimed src/scripts module in script discovery (#389)', async () => { +it('keeps a bin-claimed src/scripts module in script discovery while lib still claims (#389)', async () => { const root = await createRoot(); await writeTree(root, { 'src/cli/doctor.ts': moduleSource, @@ -282,12 +282,13 @@ it('keeps a bin- or lib-claimed src/scripts module in script discovery (#389)', })); expect(graph.diagnostics).toEqual([]); - // Package-build claims never remove a safely named direct src/scripts/ - // child: the bin and the artifact script are disjoint outputs, so both - // surfaces ship. The nested and the unsafely named modules stay claimed — - // discovering them would only turn a valid package-only configuration into - // AB4808 or AB4803. - expect(graph.scripts.map((route) => route.id)).toEqual(['script:hauler', 'script:shared']); + // A bin claim never removes a safely named direct src/scripts/ child: + // the bin and the artifact script are disjoint outputs running the same + // main, so both surfaces ship. The nested and the unsafely named modules + // stay claimed — discovering them would only turn a valid bin-only + // configuration into AB4808 or AB4803 — and a lib entry still claims its + // module: a library is not a script. + expect(graph.scripts.map((route) => route.id)).toEqual(['script:hauler']); // Every other route kind still belongs to the claiming declaration. expect(graph.cli).toBeUndefined(); }); From 543e9f87f608c4b6a2f0a78c023e923c5d7539ed Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 09:43:08 +0000 Subject: [PATCH 08/12] =?UTF-8?q?chore(changeset):=20bump=20#389=20to=20mi?= =?UTF-8?q?nor=20=E2=80=94=20bin-claimed=20default-only=20scripts=20now=20?= =?UTF-8?q?require=20consumer=20action?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/389-bin-claim-script-discovery.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/389-bin-claim-script-discovery.md b/.changeset/389-bin-claim-script-discovery.md index 71798b5e1..971b38273 100644 --- a/.changeset/389-bin-claim-script-discovery.md +++ b/.changeset/389-bin-claim-script-discovery.md @@ -1,5 +1,5 @@ --- -"agent-bundle": patch +"agent-bundle": minor --- Keep a conventional `src/scripts/.ts` module in script discovery when a `bin` config entry also references it, so `agent-bundle build` emits both `dist/bin/.js` and the artifact `scripts/.mjs` and `agent-bundle inspect --json` lists the module under both `packageBuild.bins` and `scripts` instead of silently dropping the script. Explicit `scripts`, `hooks`, `lib`, and `mcp` entries still claim the module they reference. Report `AB4737` when a `bin` entry points at a rendered `src/scripts/.tsx` script that does not export both its default Server Component and a named `main`, because the bin envelope calls `main` while the artifact script renders the component, and `AB4738` when it points at a plain `src/scripts/.ts` script that exports a `default` but no `main`, because the artifact script would ship inert while the bin runs the default export. (#413) From 8910caa62967a0a4b62c6e455a40d929303c9dcd Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 09:50:28 +0000 Subject: [PATCH 09/12] fix(validate): judge the dual-surface rendered script's component with the route contract scan A present but non-callable default export (export default {}) built a bin that worked beside a rendered script that failed at run time. AB4737 now uses scanRouteModuleExports so the default must be an async function. --- docs/diagnostics.md | 16 ++++----- packages/agent-bundle/src/config/validate.ts | 33 ++++++++++++++----- .../agent-bundle/tests/normalization.test.ts | 4 +-- .../agent-bundle/tests/route-graph.test.ts | 22 +++++++++++-- 4 files changed, 54 insertions(+), 21 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index fcd98281d..6602659fd 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -290,8 +290,10 @@ artifact paths remain `skills/`, `commands/`, and `rules/`. ### `AB4737` — rendered script claimed as a package bin entry lacks `main` or the component An explicit `bin` entry references a conventional rendered script -(`src/scripts/.tsx` or `.jsx`) that does not export **both** a default -Server Component and a named `main`. A plain `src/scripts/.ts` module +(`src/scripts/.tsx` or `.jsx`) that does not export **both** an async +default Server Component and a named `main`. The component check is the +route compiler's own static scan — the default export must be an async +function, so `export default {}` does not count. A plain `src/scripts/.ts` module ships happily on both surfaces — the npm bin envelope calls its `main(argv)` and the artifact script is the same bundle — but a rendered script's default export is an async Server Component the Agent renderer drives with @@ -301,13 +303,11 @@ component as `main(argv)` and produce a bin that renders nothing; without the default component, the bin works but `scripts/.mjs` fails at run time with no component to render. The compiler refuses either shape instead of emitting a broken surface beside a working one. A rendered script that -exports both serves both surfaces and is not gated. The detection is the -same static export scan the package build uses, so the gate and the envelope -always agree. The message names every `bin` entry referencing the module and -which export is missing. +exports both serves both surfaces and is not gated. The message names every +`bin` entry referencing the module and which export is missing. -Recover: export both a default Server Component and a named `main(argv)` -from the module; point the `bin` entry at a plain module that exports `main`; +Recover: export both an async default Server Component and a named +`main(argv)` from the module; point the `bin` entry at a plain module that exports `main`; rename the script to `.ts` so one plain module ships as both the bin and the artifact script; or prefix a path segment with `_` (`src/scripts/_name.tsx`) to keep the module out of script discovery and bin-only. diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index c3f834fe6..e3c9d1e88 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -18,6 +18,7 @@ import { satisfiesGeneratedRuntimeFloor, } from '../core/runtime.ts'; import { canonicalHookEvents, isPrebuiltEntryInput, parseNativeHookToolSelector } from '../core/types.ts'; +import { type RouteModuleExports, scanRouteModuleExports } from '../routes/contract.ts'; import type { AgentBundleBinEntry, AgentBundleHookEntry, @@ -1894,6 +1895,19 @@ const scriptEntryExports = (source: string): EntryExportScan | undefined => { } }; +/** + * The route compiler's static export scan of one rendered script, which + * judges the default export's component shape (an async function) rather + * than its mere presence. Undefined when the module is unreadable. + */ +const renderedScriptExports = (source: string, relativePath: string): RouteModuleExports | undefined => { + try { + return scanRouteModuleExports(readFileSync(source, 'utf8'), relativePath); + } catch { + return undefined; + } +}; + const validateConventionalScripts = ( loaded: LoadedConfig, discovered: DiscoveredProject, @@ -1935,17 +1949,20 @@ const validateConventionalScripts = ( // one module exactly when it exports both; the detection is the // build's own export scan, so the gate and the envelope always agree. if (binNames === undefined) break; - const exports = scriptEntryExports(route.source); - const hasMain = exports?.hasMainExport === true; - const hasDefault = exports?.hasDefaultExport === true; - if (hasMain && hasDefault) break; - const missing = !hasMain && !hasDefault - ? 'neither a default Server Component nor a named main' - : hasMain ? 'no default Server Component' : 'no named main'; + // The component contract is the same static scan the route compiler + // applies (an async default function), not mere default-export + // presence: `export default {}` would build and fail at run time. + const exports = renderedScriptExports(route.source, relativePath); + const hasMain = exports?.named.has('main') === true; + const hasComponent = exports?.asyncDefault === true; + if (hasMain && hasComponent) break; + const missing = !hasMain && !hasComponent + ? 'neither an async default Server Component nor a named main' + : hasMain ? 'no async default Server Component' : 'no named main'; diagnostics.push({ code: 'AB4737', message: `Rendered script ${relativePath} is also the entry of bin ${binList} but exports ${missing}; the artifact script renders the default component and the bin envelope calls main(argv).`, - recovery: 'Export both a default Server Component and a named main(argv) from the module, point the bin entry at a plain module that exports main, rename the script to .ts so one plain module ships as both the bin and the artifact script, or prefix a path segment with "_" to keep the module bin-only.', + recovery: 'Export both an async default Server Component and a named main(argv) from the module, point the bin entry at a plain module that exports main, rename the script to .ts so one plain module ships as both the bin and the artifact script, or prefix a path segment with "_" to keep the module bin-only.', severity: 'error', sourcePath: route.source, }); diff --git a/packages/agent-bundle/tests/normalization.test.ts b/packages/agent-bundle/tests/normalization.test.ts index 776e18785..9b5626776 100644 --- a/packages/agent-bundle/tests/normalization.test.ts +++ b/packages/agent-bundle/tests/normalization.test.ts @@ -1205,8 +1205,8 @@ it('ships a bin-claimed plain conventional script as both surfaces and refuses a expect(gate).toEqual([ { code: 'AB4737', - message: 'Rendered script src/scripts/render-notes.tsx is also the entry of bin "notes", "notes-again" but exports neither a default Server Component nor a named main; the artifact script renders the default component and the bin envelope calls main(argv).', - recovery: 'Export both a default Server Component and a named main(argv) from the module, point the bin entry at a plain module that exports main, rename the script to .ts so one plain module ships as both the bin and the artifact script, or prefix a path segment with "_" to keep the module bin-only.', + message: 'Rendered script src/scripts/render-notes.tsx is also the entry of bin "notes", "notes-again" but exports neither an async default Server Component nor a named main; the artifact script renders the default component and the bin envelope calls main(argv).', + recovery: 'Export both an async default Server Component and a named main(argv) from the module, point the bin entry at a plain module that exports main, rename the script to .ts so one plain module ships as both the bin and the artifact script, or prefix a path segment with "_" to keep the module bin-only.', severity: 'error', sourcePath: `${root}/src/scripts/render-notes.tsx`, }, diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index 89c8755bb..d1d5bc6da 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -299,6 +299,7 @@ it('gates a bin-claimed rendered script with AB4737 only when it exports no main 'export default {', ' bin: {', " notes: './src/scripts/render-notes.tsx',", + " object: './src/scripts/render-object.tsx',", " poster: './src/scripts/render-poster.tsx',", " tool: './src/scripts/render-tool.tsx',", ' },', @@ -314,6 +315,13 @@ it('gates a bin-claimed rendered script with AB4737 only when it exports no main 'export default async () => undefined;', '', ].join('\n'), + // A default export that is not a component: present, but the rendered + // script would fail at run time, so presence alone is not enough. + 'src/scripts/render-object.tsx': [ + 'export const main = async (argv: readonly string[]): Promise => argv.length;', + 'export default {};', + '', + ].join('\n'), // Component only: the bin envelope would call it as main(argv). 'src/scripts/render-poster.tsx': 'export default async () => undefined;\n', // main only: the bin works, but the rendered script has no component to render. @@ -323,22 +331,30 @@ it('gates a bin-claimed rendered script with AB4737 only when it exports no main const result = await validate({ root: project }); const gate = result.diagnostics.filter(({ code }) => code === 'AB4737'); expect(gate.map((diagnostic) => diagnostic.sourcePath)).toEqual([ + join(project, 'src/scripts/render-object.tsx'), join(project, 'src/scripts/render-poster.tsx'), join(project, 'src/scripts/render-tool.tsx'), ]); - expect(gate[0]!.message).toContain('render-poster.tsx is also the entry of bin "poster" but exports no named main'); - expect(gate[1]!.message).toContain('render-tool.tsx is also the entry of bin "tool" but exports no default Server Component'); + expect(gate[0]!.message).toContain('render-object.tsx is also the entry of bin "object" but exports no async default Server Component'); + expect(gate[1]!.message).toContain('render-poster.tsx is also the entry of bin "poster" but exports no named main'); + expect(gate[2]!.message).toContain('render-tool.tsx is also the entry of bin "tool" but exports no async default Server Component'); expect(gate.every((diagnostic) => diagnostic.severity === 'error')).toBe(true); // Every rendered script stays discovered beside its bin: the gate names // the conflict instead of dropping a route. const graph = await compileRouteGraph(project, fixtureConfig({ bin: { notes: './src/scripts/render-notes.tsx', + object: './src/scripts/render-object.tsx', poster: './src/scripts/render-poster.tsx', tool: './src/scripts/render-tool.tsx', }, })); - expect(graph.scripts.map((route) => route.id)).toEqual(['script:render-notes', 'script:render-poster', 'script:render-tool']); + expect(graph.scripts.map((route) => route.id)).toEqual([ + 'script:render-notes', + 'script:render-object', + 'script:render-poster', + 'script:render-tool', + ]); }); it('gates a bin-claimed plain script with AB4738 only when its bin would run a default export the script ignores (#389)', async () => { From a01b34698d82beadc75993b4d24d8c2e7a4d48bd Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 09:58:02 +0000 Subject: [PATCH 10/12] fix(validate): judge a dual-surface rendered script's main with the bin envelope's scan so type-only exports do not count --- packages/agent-bundle/src/config/validate.ts | 12 ++++++------ packages/agent-bundle/tests/route-graph.test.ts | 9 +++++++-- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index e3c9d1e88..9d54f74fa 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -1949,12 +1949,12 @@ const validateConventionalScripts = ( // one module exactly when it exports both; the detection is the // build's own export scan, so the gate and the envelope always agree. if (binNames === undefined) break; - // The component contract is the same static scan the route compiler - // applies (an async default function), not mere default-export - // presence: `export default {}` would build and fail at run time. - const exports = renderedScriptExports(route.source, relativePath); - const hasMain = exports?.named.has('main') === true; - const hasComponent = exports?.asyncDefault === true; + // `main` is judged by the bin envelope's own scan (which ignores + // 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). + const hasMain = scriptEntryExports(route.source)?.hasMainExport === true; + const hasComponent = renderedScriptExports(route.source, relativePath)?.asyncDefault === true; if (hasMain && hasComponent) break; const missing = !hasMain && !hasComponent ? 'neither an async default Server Component nor a named main' diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index d1d5bc6da..64caceba3 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -322,8 +322,13 @@ it('gates a bin-claimed rendered script with AB4737 only when it exports no main 'export default {};', '', ].join('\n'), - // Component only: the bin envelope would call it as main(argv). - 'src/scripts/render-poster.tsx': 'export default async () => undefined;\n', + // Component plus a type-only main: the bin envelope ignores type exports + // and would still call the component as main(argv). + 'src/scripts/render-poster.tsx': [ + 'export type main = (argv: readonly string[]) => Promise;', + 'export default async () => undefined;', + '', + ].join('\n'), // main only: the bin works, but the rendered script has no component to render. 'src/scripts/render-tool.tsx': 'export const main = async (argv: readonly string[]): Promise => argv.length;\n', }); From 1d7773235ed1c83533e0e2f5602be952ad3612f8 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 10:04:19 +0000 Subject: [PATCH 11/12] fix(routes): ignore type-only export specifiers in the route module export scan export type { X } and export { type X as default } emit no JavaScript binding, so they must not satisfy the component or named-export contract; this closes the last dual-surface hole where a bin-claimed rendered script passed AB4737 with a type-only default alias. --- packages/agent-bundle/src/routes/contract.ts | 4 ++++ packages/agent-bundle/tests/route-graph.test.ts | 14 ++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/packages/agent-bundle/src/routes/contract.ts b/packages/agent-bundle/src/routes/contract.ts index 7d2adb71f..b5f7a292a 100644 --- a/packages/agent-bundle/src/routes/contract.ts +++ b/packages/agent-bundle/src/routes/contract.ts @@ -91,7 +91,11 @@ export const scanRouteModuleExports = ( 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; 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; diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index 64caceba3..8e1a77226 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -302,12 +302,22 @@ it('gates a bin-claimed rendered script with AB4737 only when it exports no main " object: './src/scripts/render-object.tsx',", " poster: './src/scripts/render-poster.tsx',", " tool: './src/scripts/render-tool.tsx',", + " typed: './src/scripts/render-typed.tsx',", ' },', " plugin: { name: 'routes-fixture', version: '1.0.0' },", " targets: ['portable'],", '};', '', ].join('\n'), + // main plus a type-only default alias of an async function binding: no + // JavaScript default export is emitted, so the rendered script has no + // component even though a same-named function exists. + 'src/scripts/render-typed.tsx': [ + 'const Component = async () => undefined;', + 'export const main = async (argv: readonly string[]): Promise => argv.length;', + 'export { type Component as default };', + '', + ].join('\n'), // Exports both: main(argv) for the bin envelope, the component for the // rendered script, so the module serves both surfaces. 'src/scripts/render-notes.tsx': [ @@ -339,10 +349,12 @@ it('gates a bin-claimed rendered script with AB4737 only when it exports no main join(project, 'src/scripts/render-object.tsx'), join(project, 'src/scripts/render-poster.tsx'), join(project, 'src/scripts/render-tool.tsx'), + join(project, 'src/scripts/render-typed.tsx'), ]); expect(gate[0]!.message).toContain('render-object.tsx is also the entry of bin "object" but exports no async default Server Component'); expect(gate[1]!.message).toContain('render-poster.tsx is also the entry of bin "poster" but exports no named main'); expect(gate[2]!.message).toContain('render-tool.tsx is also the entry of bin "tool" but exports no async default Server Component'); + expect(gate[3]!.message).toContain('render-typed.tsx is also the entry of bin "typed" but exports no async default Server Component'); expect(gate.every((diagnostic) => diagnostic.severity === 'error')).toBe(true); // Every rendered script stays discovered beside its bin: the gate names // the conflict instead of dropping a route. @@ -352,6 +364,7 @@ it('gates a bin-claimed rendered script with AB4737 only when it exports no main object: './src/scripts/render-object.tsx', poster: './src/scripts/render-poster.tsx', tool: './src/scripts/render-tool.tsx', + typed: './src/scripts/render-typed.tsx', }, })); expect(graph.scripts.map((route) => route.id)).toEqual([ @@ -359,6 +372,7 @@ it('gates a bin-claimed rendered script with AB4737 only when it exports no main 'script:render-object', 'script:render-poster', 'script:render-tool', + 'script:render-typed', ]); }); From 2920a59762e5bd44ac5f2bc882ad00478cae6cc6 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 11:04:27 +0000 Subject: [PATCH 12/12] fix(validate): accept a re-exported default component on a bin-claimed rendered script --- docs/diagnostics.md | 5 ++++- packages/agent-bundle/src/config/validate.ts | 7 +++++-- packages/agent-bundle/tests/route-graph.test.ts | 11 +++++++++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 6602659fd..91f0ea35e 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -293,7 +293,10 @@ An explicit `bin` entry references a conventional rendered script (`src/scripts/.tsx` or `.jsx`) that does not export **both** an async default Server Component and a named `main`. The component check is the route compiler's own static scan — the default export must be an async -function, so `export default {}` does not count. A plain `src/scripts/.ts` module +function, so `export default {}` does not count; a default re-exported from +another module (`export { default } from './component.tsx'`) cannot be judged +statically and is accepted (the rendered worker still verifies it at run +time). A plain `src/scripts/.ts` module ships happily on both surfaces — the npm bin envelope calls its `main(argv)` and the artifact script is the same bundle — but a rendered script's default export is an async Server Component the Agent renderer drives with diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index 9d54f74fa..f702a6981 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -1952,9 +1952,12 @@ const validateConventionalScripts = ( // `main` is judged by the bin envelope's own scan (which ignores // 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). + // `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. const hasMain = scriptEntryExports(route.source)?.hasMainExport === true; - const hasComponent = renderedScriptExports(route.source, relativePath)?.asyncDefault === true; + const routeExports = renderedScriptExports(route.source, relativePath); + const hasComponent = routeExports?.asyncDefault === true || routeExports?.named.has('default') === true; if (hasMain && hasComponent) break; const missing = !hasMain && !hasComponent ? 'neither an async default Server Component nor a named main' diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index 8e1a77226..50293ae78 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -301,6 +301,7 @@ it('gates a bin-claimed rendered script with AB4737 only when it exports no main " notes: './src/scripts/render-notes.tsx',", " object: './src/scripts/render-object.tsx',", " poster: './src/scripts/render-poster.tsx',", + " reexport: './src/scripts/render-reexport.tsx',", " tool: './src/scripts/render-tool.tsx',", " typed: './src/scripts/render-typed.tsx',", ' },', @@ -309,6 +310,14 @@ 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). + 'src/scripts/_component.tsx': 'export default async () => undefined;\n', + 'src/scripts/render-reexport.tsx': [ + 'export const main = async (argv: readonly string[]): Promise => argv.length;', + "export { default } from './_component.tsx';", + '', + ].join('\n'), // main plus a type-only default alias of an async function binding: no // JavaScript default export is emitted, so the rendered script has no // component even though a same-named function exists. @@ -363,6 +372,7 @@ it('gates a bin-claimed rendered script with AB4737 only when it exports no main notes: './src/scripts/render-notes.tsx', object: './src/scripts/render-object.tsx', poster: './src/scripts/render-poster.tsx', + reexport: './src/scripts/render-reexport.tsx', tool: './src/scripts/render-tool.tsx', typed: './src/scripts/render-typed.tsx', }, @@ -371,6 +381,7 @@ it('gates a bin-claimed rendered script with AB4737 only when it exports no main 'script:render-notes', 'script:render-object', 'script:render-poster', + 'script:render-reexport', 'script:render-tool', 'script:render-typed', ]);