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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/conventional-plain-scripts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"agent-bundle": patch
---

Discover plain `src/scripts/` modules as conventional script entries (#102
stage 1). An unclaimed plain `.ts` module directly under `src/scripts/` now
compiles through the existing explicit-`scripts` pipeline to
`scripts/<name>.mjs` in every selected target artifact, carrying
`provenance.kind: 'conventional'`; explicit `scripts` configuration keeps
claiming its files. Rendered (`.tsx`/`.jsx`), nested, and
identity-conflicting script routes fail source validation with the new
`AB4807`–`AB4809` diagnostics instead of building silently.
11 changes: 10 additions & 1 deletion docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ simply not been built yet is a validation **warning** that only
| `AB4749` | error (build) | A payload directory overlaps the artifact `--output` root. |
| `AB4750` | info | A payload is older than the newest project source file and may be stale; rerun the project's own build if so. |

## Route graph (`AB4800`–`AB4806`)
## Route graph (`AB4800`–`AB4809`)

The route-graph compiler discovers conventional route modules
(`src/mcp/<server>/{tools,resources,prompts,apps}/*`, `src/events/*/*`,
Expand All @@ -142,6 +142,12 @@ accepted form. Anything else is dynamic: the route compiles with an empty
config beside a named `AB4806` error. A module without a `config` export
compiles silently with an empty config.

Conventional `src/scripts/` routes ship through the same pipeline as
explicit `scripts` entries (#102 stage 1): a plain module directly under
`src/scripts/` compiles to `scripts/<name>.mjs` in every selected target
artifact with `provenance.kind: 'conventional'`. Script routes that pipeline
cannot ship yet are hard errors (`AB4807`–`AB4809`), never silent omissions.

| Code | Severity | Trigger |
| --- | --- | --- |
| `AB4800` | error | An MCP server has both discovered route modules under `src/mcp/<id>/` and an existing entry claim (the conventional `src/mcp/<id>.ts` module, or a declared `entry`/`command`/`url`) without an explicit `routes.servers.<id>` mode. |
Expand All @@ -151,6 +157,9 @@ compiles silently with an empty config.
| `AB4804` | error | A `routes` mode override is not `generated`/`custom`/`command`/`remote` for a server, or `generated`/`conventional` for the CLI. |
| `AB4805` | error | A route module exports `config` through a rejected declaration shape (`let`/`var`, destructuring, `export { config }`, a function or class, a missing initializer), or the extracted value is not an object. |
| `AB4806` | error | A route module's `config` initializer is dynamic — the message names the offending construct and position. |
| `AB4807` | error | A conventional `src/scripts/` route is a rendered-script module (`.tsx`/`.jsx`); rendered scripts are not supported yet. Rename it to `.ts`, prefix a path segment with `_` to keep it private, or declare it under `scripts` in config to opt into plain bundling. |
| `AB4808` | error | A conventional `src/scripts/` route nests below the scripts root; conventional scripts ship as direct children only. Move it up, prefix a path segment with `_`, or declare it under `scripts` in config with a flat name. |
| `AB4809` | error | A conventional `src/scripts/` route and a configured `scripts` entry share one script identity through different files. Point the config entry at the module to claim it, or rename one of the two. |

## Development package build (`AB7103`)

Expand Down
1 change: 1 addition & 0 deletions docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ entries carry `provenance.kind: 'conventional'` in the normalized model.
| `src/cli.ts` | Package bin named after `plugin.name` (skipped when the name is not a safe output name). | `bin: false` |
| `src/index.ts` | Library output with declarations. | `lib: false` |
| `src/mcp/<server-id>.ts` | Stdio entry for the declared MCP server `<server-id>` that names no `entry`, `command`, or `url`. | Declare `entry` explicitly |
| `src/scripts/<name>.ts` | Plain script compiled to `scripts/<name>.mjs` in every selected target artifact — the same pipeline explicit `scripts` entries use. A `scripts` entry that references the file claims it. Rendered (`.tsx`) and nested modules are hard errors until later #102 stages (`AB4807`/`AB4808`). | Prefix a path segment with `_`, or claim the file with an explicit `scripts` entry |

Conventions match `.ts` and `.tsx` files exactly.

Expand Down
5 changes: 4 additions & 1 deletion examples/hooks-and-scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ session-start Hook, a manifest-backed packaging check, and a risk-register
check so the Workbench can show canonical Hook simulation, successful and
blocking script traces, and live Logs. Both scripts export `main` and return
their exit codes; the build generates the process envelope that owns argv,
awaiting, and exit-code adoption.
awaiting, and exit-code adoption. `verify-release` ships by convention — any
unclaimed plain script under `src/scripts/` is discovered — while
`detect-risk` stays explicitly configured to restrict its targets, so the
example keeps both modes covered.

## Workbench walkthrough

Expand Down
4 changes: 3 additions & 1 deletion examples/hooks-and-scripts/agent-bundle.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ export default defineConfig({
name: 'hooks-and-scripts',
version: '1.0.0',
},
// verify-release ships by convention: unclaimed plain scripts under
// src/scripts/ are discovered. detect-risk stays explicitly configured
// because it restricts targets.
scripts: {
'detect-risk': {
entry: './src/scripts/detect-risk.ts',
targets: ['portable'],
},
'verify-release': './src/scripts/verify-release.ts',
},
targets: ['portable', 'codex', 'claude'],
});
2 changes: 1 addition & 1 deletion packages/agent-bundle/src/build/entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export const planCompiledEntries = (
),
outputKind: script.mode === 'copy' ? 'copy' as const : 'bundle' as const,
source: script.source,
sourceInputs: Object.freeze([script.provenance.sourcePath, script.source]),
sourceInputs: Object.freeze([...new Set([script.provenance.sourcePath, script.source])]),
};
}).map((entry) => Object.freeze(entry)));
};
Expand Down
52 changes: 33 additions & 19 deletions packages/agent-bundle/src/config/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import type {
} from '../core/types.ts';
import { type DiscoveredProject, payloadDeclarationSource } from './discover.ts';
import type { LoadedConfig } from './load.ts';
import { configuredScriptNames, judgeScriptRoute, scriptRouteName } from './script-routes.ts';

const unique = (values: readonly string[]): string[] => [...new Set(values)];

Expand Down Expand Up @@ -561,27 +562,40 @@ const scriptMode = (source: string): 'bundle' | 'copy' =>

const normalizeScripts = (
loaded: LoadedConfig,
discovered: DiscoveredProject,
targetNames: readonly string[],
): readonly NormalizedScript[] => {
const configured = loaded.config.scripts;
if (configured === undefined) return [];

const provenance: SourceProvenance = { kind: 'config', sourcePath: loaded.configPath };
return Object.entries(configured)
.sort(([left], [right]) => left.localeCompare(right))
.map(([name, input]) => {
const declaration = input as AgentBundleScriptInput;
const entry = typeof declaration === 'string' ? declaration : declaration.entry;
const source = resolve(loaded.context.projectRoot, entry);
return {
id: `script:${name}`,
mode: scriptMode(source),
name,
provenance: { ...provenance },
source,
targets: sortedUnique(typeof declaration === 'string' ? targetNames : (declaration.targets ?? targetNames)),
};
});
const explicit = Object.entries(loaded.config.scripts ?? {}).map(([name, input]): NormalizedScript => {
const declaration = input as AgentBundleScriptInput;
const entry = typeof declaration === 'string' ? declaration : declaration.entry;
const source = resolve(loaded.context.projectRoot, entry);
return {
id: `script:${name}`,
mode: scriptMode(source),
name,
provenance: { ...provenance },
source,
targets: sortedUnique(typeof declaration === 'string' ? targetNames : (declaration.targets ?? targetNames)),
};
});
// Conventional `src/scripts/` routes ship through the same pipeline as
// explicit entries (#102 stage 1). The judgment is shared with source
// validation: routes the pipeline cannot ship (rendered, nested, or
// conflicting with a configured name) are AB4807-AB4809 errors there, so
// omitting them here is deterministic hygiene, never a silent choice.
const configured = configuredScriptNames(loaded.config);
const conventional = (discovered.routeGraph?.scripts ?? [])
.filter((route) => judgeScriptRoute(route, configured) === 'shippable')
.map((route): NormalizedScript => ({
id: route.id,
mode: scriptMode(route.source),
name: scriptRouteName(route),
provenance: { kind: 'conventional', sourcePath: route.source },
source: route.source,
targets: sortedUnique(targetNames),
}));
return [...explicit, ...conventional].sort((left, right) => left.name.localeCompare(right.name));
};

export const configExtensionFiniteJsonDiagnosticMessage = 'A registered config extension must contain strict finite JSON data.';
Expand Down Expand Up @@ -770,7 +784,7 @@ export const normalizeProject = async (
const nativeHooks = await normalizeNativeHooks(loaded, targetNames, registry);
const payloads = normalizePayloads(loaded, discovered, targetNames);
const mcpServers = normalizeMcpServers(loaded, targetNames, payloads);
const scripts = normalizeScripts(loaded, targetNames);
const scripts = normalizeScripts(loaded, discovered, targetNames);
const assets = normalizeAssets(loaded, discovered, targetNames);
const packageBuild = normalizePackageBuild(loaded.config, loaded.context.projectRoot, loaded.configPath);
const model: NormalizedPlugin = {
Expand Down
42 changes: 42 additions & 0 deletions packages/agent-bundle/src/config/script-routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { extname } from 'node:path';

import { isRecord } from '../core/strict-json.ts';
import type { AgentBundleConfig } from '../core/types.ts';
import type { CompiledAgentRoute } from '../routes/types.ts';

/**
* The #102 stage-1 judgment of one conventional `src/scripts/` route.
* Normalization ships exactly the `shippable` routes through the explicit
* `scripts` pipeline; source validation reports every other state as a hard
* error (`AB4807`-`AB4809`). Both sides share this rule so no discovered
* script route is ever dropped silently.
*/
export type ScriptRouteJudgment =
/** A configured `scripts` entry already uses this identity for another file. */
| 'conflicting'
/** Nested below the scripts root; the flat scripts artifact layout cannot place it yet. */
| 'nested'
/** A rendered-script module (`.tsx`/`.jsx`); needs the Agent renderer (#102 stage 3). */
| 'rendered'
/** A plain module directly under `src/scripts/` with an unclaimed identity. */
| 'shippable';

const renderedScriptExtensions = new Set(['.jsx', '.tsx']);
Comment thread
ScriptedAlchemy marked this conversation as resolved.

/** The path-derived identity of one script route (`script:release/verify` -> `release/verify`). */
export const scriptRouteName = (route: CompiledAgentRoute): string =>
route.id.slice('script:'.length);

/** The script names explicit configuration declares, tolerant of malformed config shapes. */
export const configuredScriptNames = (config: Readonly<AgentBundleConfig>): ReadonlySet<string> =>
new Set(isRecord(config.scripts) ? Object.keys(config.scripts) : []);

export const judgeScriptRoute = (
route: CompiledAgentRoute,
configuredNames: ReadonlySet<string>,
): ScriptRouteJudgment => {
if (renderedScriptExtensions.has(extname(route.source).toLowerCase())) return 'rendered';
const name = scriptRouteName(route);
if (name.includes('/')) return 'nested';
return configuredNames.has(name) ? 'conflicting' : 'shippable';
};
60 changes: 60 additions & 0 deletions packages/agent-bundle/src/config/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
} from './normalize.ts';
import { type DiscoveredProject, payloadDeclarationEntry, payloadDeclarationSource } from './discover.ts';
import type { LoadedConfig } from './load.ts';
import { configuredScriptNames, judgeScriptRoute, scriptRouteName } from './script-routes.ts';
import type { SkillDocument } from './skill.ts';
import { referencedResources } from './skill-references.ts';
import { validateAgentSkillsFrontmatter } from '../schemas/agent-skills/contract.ts';
Expand Down Expand Up @@ -1393,6 +1394,61 @@ const validatePackageIdentity = (loaded: LoadedConfig): Diagnostic[] => {
return diagnostics;
};

/**
* The stage-1 script-route gate (#102): conventional `src/scripts/` routes
* ship through the explicit-`scripts` pipeline, so every discovered script
* route that pipeline cannot ship yet is a hard error naming its explicit
* resolution. Discovery is not a packaging choice - a route never
* disappears silently.
*/
const validateConventionalScripts = (
loaded: LoadedConfig,
discovered: DiscoveredProject,
): Diagnostic[] => {
const diagnostics: Diagnostic[] = [];
const configured = configuredScriptNames(loaded.config);
for (const route of discovered.routeGraph?.scripts ?? []) {
const relativePath = route.provenance.relativePath;
const judgment = judgeScriptRoute(route, configured);
switch (judgment) {
case 'shippable':
break;
case 'rendered':
diagnostics.push({
code: 'AB4807',
message: `Conventional script ${relativePath} is a rendered-script module; rendered scripts are not supported yet.`,
recovery: 'Rename the module to .ts to ship a plain script, prefix a path segment with "_" to keep it private, or declare it under scripts in config to opt into plain bundling.',
severity: 'error',
sourcePath: route.source,
});
break;
case 'nested':
diagnostics.push({
code: 'AB4808',
message: `Conventional script ${relativePath} nests below the src/scripts/ root; conventional scripts ship as direct children only.`,
recovery: 'Move the module directly under src/scripts/, prefix a path segment with "_" to keep it private, or declare it under scripts in config with a flat name.',
severity: 'error',
sourcePath: route.source,
});
break;
case 'conflicting':
diagnostics.push({
code: 'AB4809',
message: `Conventional script ${relativePath} and the configured script ${JSON.stringify(scriptRouteName(route))} share one script identity; the compiler never chooses silently.`,
recovery: `Point the scripts.${scriptRouteName(route)} config entry at ${relativePath} to claim the module, or rename one of the two scripts.`,
severity: 'error',
sourcePath: route.source,
});
break;
default: {
const unreachable: never = judgment;
throw new TypeError(`Unhandled script route judgment ${String(unreachable)}.`);
}
}
}
return diagnostics;
};

export const validateSource = (
loaded: LoadedConfig,
discovered: DiscoveredProject,
Expand Down Expand Up @@ -1465,6 +1521,10 @@ export const validateSource = (
// Route-graph collisions (AB4800-AB4804) are compiled during discovery;
// they are project-source errors, so they gate inspect and build here.
diagnostics.push(...(discovered.routeGraph?.diagnostics ?? []));
// The stage-1 gate for conventional script routes rides beside the graph's
// own collisions: rendered, nested, and config-conflicting script routes
// stay hard errors until later #102 stages ship them.
diagnostics.push(...validateConventionalScripts(loaded, discovered));

return diagnostics;
};
Expand Down
93 changes: 93 additions & 0 deletions packages/agent-bundle/tests/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -962,6 +962,99 @@ it('normalizes named top-level scripts with stable IDs, modes, and sorted target
}
});

it('builds conventional src/scripts modules beside explicit entries', async () => {
const parent = await mkdtemp(join(tmpdir(), 'agent-bundle-conventional-scripts-parent-'));
const root = join(parent, 'project with spaces');
await mkdir(join(root, 'src', 'scripts'), { recursive: true });
await Promise.all([
writeFile(
join(root, 'agent-bundle.config.ts'),
[
'export default {',
" plugin: { name: 'conventional-scripts-fixture', version: '1.0.0' },",
" targets: ['portable'],",
" scripts: { claimed: './src/scripts/claimed.ts' },",
'};',
'',
].join('\n'),
),
writeFile(
join(root, 'src', 'scripts', 'claimed.ts'),
"export const main = async (): Promise<number> => {\n process.stdout.write('claimed script\\n');\n return 0;\n};\n",
),
writeFile(
join(root, 'src', 'scripts', 'greet.ts'),
"export const main = async (): Promise<number> => {\n process.stdout.write('hello from convention\\n');\n return 0;\n};\n",
),
]);
const output = join(root, 'artifact');

try {
const result = await build({ output, root });

expect(result.model.scripts).toEqual([
{
id: 'script:claimed',
mode: 'bundle',
name: 'claimed',
provenance: { kind: 'config', sourcePath: join(root, 'agent-bundle.config.ts') },
source: join(root, 'src', 'scripts', 'claimed.ts'),
targets: ['portable'],
},
{
id: 'script:greet',
mode: 'bundle',
name: 'greet',
provenance: { kind: 'conventional', sourcePath: join(root, 'src', 'scripts', 'greet.ts') },
source: join(root, 'src', 'scripts', 'greet.ts'),
targets: ['portable'],
},
]);
await expect(readFile(join(output, 'portable', 'scripts', 'greet.mjs'), 'utf8')).resolves.toContain('hello from convention');
await expect(stat(join(output, 'portable', 'scripts', 'claimed.mjs'))).resolves.toBeDefined();
} finally {
await rm(parent, { force: true, recursive: true });
}
});

it('refuses unshippable conventional script routes with actionable diagnostics', async () => {
const parent = await mkdtemp(join(tmpdir(), 'agent-bundle-unshippable-scripts-parent-'));
const root = join(parent, 'project');
await mkdir(join(root, 'src', 'scripts', 'release'), { recursive: true });
await mkdir(join(root, 'src', 'tasks'), { recursive: true });
await Promise.all([
writeFile(
join(root, 'agent-bundle.config.ts'),
[
'export default {',
" plugin: { name: 'unshippable-scripts-fixture', version: '1.0.0' },",
" targets: ['portable'],",
" scripts: { audit: './src/tasks/audit.ts' },",
'};',
'',
].join('\n'),
),
writeFile(join(root, 'src', 'tasks', 'audit.ts'), 'export const main = async (): Promise<number> => 0;\n'),
writeFile(join(root, 'src', 'scripts', 'audit.ts'), 'export const main = async (): Promise<number> => 0;\n'),
writeFile(join(root, 'src', 'scripts', 'render-notes.tsx'), 'export default async () => undefined;\n'),
writeFile(join(root, 'src', 'scripts', 'release', 'tag.ts'), 'export const main = async (): Promise<number> => 0;\n'),
]);

try {
const result = await validate({ root });
const gate = result.diagnostics.filter(({ code }) => ['AB4807', 'AB4808', 'AB4809'].includes(code));

expect(gate.map(({ code }) => code).sort()).toEqual(['AB4807', 'AB4808', 'AB4809']);
for (const diagnostic of gate) {
expect(diagnostic.severity).toBe('error');
expect(diagnostic.recovery).toBeTruthy();
expect(diagnostic.sourcePath).toContain(join('src', 'scripts'));
}
} finally {
await rm(parent, { force: true, recursive: true });
}
});

it('copies every supported top-level script output suffix byte-for-byte with source modes', async () => {
const parent = await mkdtemp(join(tmpdir(), 'agent-bundle-copy-scripts-parent-'));
const root = join(parent, 'project with spaces');
Expand Down
Loading
Loading