diff --git a/.changeset/conventional-plain-scripts.md b/.changeset/conventional-plain-scripts.md new file mode 100644 index 000000000..3c8a03797 --- /dev/null +++ b/.changeset/conventional-plain-scripts.md @@ -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/.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. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 77d682d6b..10c7cd13e 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -118,7 +118,7 @@ simply not been built yet is a validation **warning** that only | `AB4749` | error (build) | A payload directory overlaps the artifact `--output` root. | | `AB4750` | info | A payload is older than the newest project source file and may be stale; rerun the project's own build if so. | -## Route graph (`AB4800`–`AB4806`) +## Route graph (`AB4800`–`AB4809`) The route-graph compiler discovers conventional route modules (`src/mcp//{tools,resources,prompts,apps}/*`, `src/events/*/*`, @@ -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/.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//` and an existing entry claim (the conventional `src/mcp/.ts` module, or a declared `entry`/`command`/`url`) without an explicit `routes.servers.` mode. | @@ -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`) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 81cc83ebe..91bed376f 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -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/.ts` | Stdio entry for the declared MCP server `` that names no `entry`, `command`, or `url`. | Declare `entry` explicitly | +| `src/scripts/.ts` | Plain script compiled to `scripts/.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. diff --git a/examples/hooks-and-scripts/README.md b/examples/hooks-and-scripts/README.md index 49b4ed312..89d5863a5 100644 --- a/examples/hooks-and-scripts/README.md +++ b/examples/hooks-and-scripts/README.md @@ -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 diff --git a/examples/hooks-and-scripts/agent-bundle.config.ts b/examples/hooks-and-scripts/agent-bundle.config.ts index 302a7706d..18498faf9 100644 --- a/examples/hooks-and-scripts/agent-bundle.config.ts +++ b/examples/hooks-and-scripts/agent-bundle.config.ts @@ -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'], }); diff --git a/packages/agent-bundle/src/build/entries.ts b/packages/agent-bundle/src/build/entries.ts index 920fa3be5..fa9f5baf9 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -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))); }; diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index d85ac8f34..4fc0c0dc6 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -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)]; @@ -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.'; @@ -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 = { diff --git a/packages/agent-bundle/src/config/script-routes.ts b/packages/agent-bundle/src/config/script-routes.ts new file mode 100644 index 000000000..c7a204afd --- /dev/null +++ b/packages/agent-bundle/src/config/script-routes.ts @@ -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']); + +/** 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): ReadonlySet => + new Set(isRecord(config.scripts) ? Object.keys(config.scripts) : []); + +export const judgeScriptRoute = ( + route: CompiledAgentRoute, + configuredNames: ReadonlySet, +): 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'; +}; diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index 5c12d9d9f..9ad6111a2 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -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'; @@ -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, @@ -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; }; diff --git a/packages/agent-bundle/tests/api.test.ts b/packages/agent-bundle/tests/api.test.ts index 4018efcc6..a896b8b36 100644 --- a/packages/agent-bundle/tests/api.test.ts +++ b/packages/agent-bundle/tests/api.test.ts @@ -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 => {\n process.stdout.write('claimed script\\n');\n return 0;\n};\n", + ), + writeFile( + join(root, 'src', 'scripts', 'greet.ts'), + "export const main = async (): Promise => {\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 => 0;\n'), + writeFile(join(root, 'src', 'scripts', 'audit.ts'), 'export const main = async (): Promise => 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 => 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'); diff --git a/packages/agent-bundle/tests/normalization.test.ts b/packages/agent-bundle/tests/normalization.test.ts index 903b2a147..621c5580b 100644 --- a/packages/agent-bundle/tests/normalization.test.ts +++ b/packages/agent-bundle/tests/normalization.test.ts @@ -11,6 +11,7 @@ import type { AgentBundleConfig } from '../src/core/types.ts'; import type { Diagnostic } from '../src/core/diagnostics.ts'; import type { DiscoveredProject } from '../src/config/discover.ts'; import type { LoadedConfig } from '../src/config/load.ts'; +import { emptyRouteConfig, type CompiledAgentRoute, type CompiledRouteGraph } from '../src/routes/types.ts'; const registry: NormalizationTargetRegistry = { configExtensions: () => [], @@ -760,3 +761,140 @@ it('validates the assets configuration shape, containment, and literal existence expect(diagnosticsFor(['definitely-missing/*.svg'], process.cwd())).toEqual(['AB4008']); expect(diagnosticsFor(['package.json'], process.cwd())).toEqual(['AB4008']); }); + +const scriptRouteFixture = (root: string, relativePath: string): CompiledAgentRoute => { + const identity = relativePath + .split('/') + .slice(2) + .join('/') + .replace(/\.[^./]+$/u, ''); + return { + config: emptyRouteConfig, + id: `script:${identity}`, + kind: 'script', + provenance: { kind: 'conventional', relativePath }, + source: `${root}/${relativePath}`, + }; +}; + +const routeGraphWithScripts = (root: string, relativePaths: readonly string[]): CompiledRouteGraph => ({ + diagnostics: [], + digest: 'fixture-digest', + events: [], + providers: [], + scripts: relativePaths.map((relativePath) => scriptRouteFixture(root, relativePath)), + servers: [], +}); + +it('normalizes shippable conventional script routes through the explicit scripts pipeline', async () => { + const root = '/workspace/project'; + const loaded = loadedProject({ + plugin: { name: 'review-tools', version: '1.0.0' }, + scripts: { 'detect-risk': { entry: './src/tasks/detect-risk.ts', targets: ['claude'] } }, + }); + const discovered: DiscoveredProject = { + routeGraph: routeGraphWithScripts(root, [ + 'src/scripts/detect-risk.ts', + 'src/scripts/release/tag.ts', + 'src/scripts/render-notes.tsx', + 'src/scripts/verify-release.ts', + ]), + skills: [], + }; + + const model = await normalizeProject(loaded, discovered, registry); + + expect(model.scripts).toEqual([ + { + id: 'script:detect-risk', + mode: 'bundle', + name: 'detect-risk', + provenance: { kind: 'config', sourcePath: `${root}/agent-bundle.config.ts` }, + source: `${root}/src/tasks/detect-risk.ts`, + targets: ['claude'], + }, + { + id: 'script:verify-release', + mode: 'bundle', + name: 'verify-release', + provenance: { kind: 'conventional', sourcePath: `${root}/src/scripts/verify-release.ts` }, + source: `${root}/src/scripts/verify-release.ts`, + targets: ['portable'], + }, + ]); +}); + +it('normalizes conventional scripts when config declares none', async () => { + const root = '/workspace/project'; + const model = await normalizeProject( + loadedProject({ plugin: { name: 'review-tools', version: '1.0.0' } }), + { routeGraph: routeGraphWithScripts(root, ['src/scripts/verify-release.ts']), skills: [] }, + registry, + ); + + expect(model.scripts).toEqual([ + { + id: 'script:verify-release', + mode: 'bundle', + name: 'verify-release', + provenance: { kind: 'conventional', sourcePath: `${root}/src/scripts/verify-release.ts` }, + source: `${root}/src/scripts/verify-release.ts`, + targets: ['portable'], + }, + ]); +}); + +it('gates rendered, nested, and conflicting conventional script routes as AB4807-AB4809', () => { + const root = '/workspace/project'; + const loaded = loadedProject({ + plugin: { name: 'review-tools', version: '1.0.0' }, + scripts: { 'detect-risk': './src/tasks/detect-risk.ts' }, + }); + const discovered: DiscoveredProject = { + routeGraph: routeGraphWithScripts(root, [ + 'src/scripts/detect-risk.ts', + 'src/scripts/release/tag.ts', + 'src/scripts/render-notes.tsx', + 'src/scripts/verify-release.ts', + ]), + skills: [], + }; + + const gate = validateSource(loaded, discovered, registry) + .filter(({ code }) => code.startsWith('AB48')); + + expect(gate).toEqual([ + { + code: 'AB4809', + message: 'Conventional script src/scripts/detect-risk.ts and the configured script "detect-risk" share one script identity; the compiler never chooses silently.', + recovery: 'Point the scripts.detect-risk config entry at src/scripts/detect-risk.ts to claim the module, or rename one of the two scripts.', + severity: 'error', + sourcePath: `${root}/src/scripts/detect-risk.ts`, + }, + { + code: 'AB4808', + message: 'Conventional script src/scripts/release/tag.ts 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: `${root}/src/scripts/release/tag.ts`, + }, + { + code: 'AB4807', + message: 'Conventional script src/scripts/render-notes.tsx 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: `${root}/src/scripts/render-notes.tsx`, + }, + ]); +}); + +it('keeps shippable conventional script routes free of stage-1 gate diagnostics', () => { + const root = '/workspace/project'; + const diagnostics = validateSource( + loadedProject({ plugin: { name: 'review-tools', version: '1.0.0' } }), + { routeGraph: routeGraphWithScripts(root, ['src/scripts/verify-release.ts']), skills: [] }, + registry, + ); + + expect(diagnostics.filter(({ code }) => code.startsWith('AB48'))).toEqual([]); +});