enhance(#3906): two terminators over one registry, with a versioned exit projection (#3924)

* feat(#3906): two terminators over one registry, with a versioned projection

Adds terminateNow (write-then-terminate, for callers that cannot wait for the event loop) beside runMain (drain-then-exit), both projecting through one shared function so they cannot disagree - the parity the ADR makes mandatory. A failed write does not change the exit code: letting it propagate would fail a hook open, which is what the fail-closed branches exist to prevent.

The projection is versioned. v1 reproduces today's integers, including keeping a payload-carried degraded result at exit 0 - ADR-2980 ratified that across 60 sites and declined normalizing it on measured blast radius. v2 applies the registry. --exit-contract=v2 or GSD_EXIT_CONTRACT=v2 selects it; an unrecognized version throws rather than silently defaulting.

The registry is now emitted beside both copies of the exit module, so it resolves as a sibling in the built tree and in the committed scripts/ copy that must load on an unbuilt clone.

* fix(#3906): actually restrict code 2 to terminateNow, and generate the registry's type

The claim that terminateNow is the only place 2 can be produced was false: runMain's outcome arm applied no guard, so runMain(()=>'HOOK_DENY') set exitCode 2 through the drain path - and the parity matrix demonstrated it while calling it parity. runMain now refuses any outcome projecting to the hook-protocol code, gated on the code rather than the name so an alias cannot slip past, and the matrix asserts the restriction instead of contradicting it.

The ambient type for the generated registry was hand-written with no gate against the generator's actual output - the declared-surface-diverges-from-runtime defect class this epic exists to close, reintroduced inside it. It is now a third generated artifact covered by the same --check. Also converts every test-body try/finally to t.after().

* test(#3906): derive the glossary fixture's dependencies instead of hand-listing them

Adding a require to scripts/lib/cli-exit.cjs broke 31 tests in one suite that built its fixture from a hand-written dependency list, so the new sibling was absent and the copied script could not load. copyScriptWithDeps walks the require graph and exists for exactly this class - #3412 paid the same bill when one new require broke 82 tests across two suites. Migrating rather than adding another copyFileSync line keeps the class closed. The other nine suites referencing that path were triaged; none copies-and-spawns, so none needed migrating.

* fix(#3906): enumerate the new shipped file, drop a vendor name from shipped data, and fix three test defects

install: scripts/lib/exit-code-registry.cjs was missing from GSD_SCRIPTS_LIB_FILES, so it shipped to every install and orphaned on uninstall.

The registry gave HOOK_DENY a meaning naming one harness, and that string ships into every runtime's tree - a guard correctly caught it leaking into the hermes and qwen installs. The registry is runtime-neutral infrastructure; the vendor name belongs in the ADR, not in shipped data.

Two more fixture harnesses built their trees from hand-listed dependencies and broke on the new require; both migrated to the derived helper, and all 23 copy-and-spawn candidates were enumerated so the class is closed rather than patched. One generator test used a fixture code that collided with a real allocation, so the generator correctly reported a duplicate where the test expected drift. The large-payload test embedded a 256KB literal in the child's argv, exceeding Linux's 128KiB MAX_ARG_STRLEN so the child never started - it now builds the payload inside the child.

* chore(#3906): backfill changeset pr number

* docs(#3906): document the exit-code contract selector

P2 is the first phase of this epic with a user-invocable surface, so the flag and env var owe a reference entry. Records what actually differs between v1 and v2 today (one outcome), that an unrecognized value is rejected rather than silently defaulted, and the fail-safe property that makes switching safe.

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-27 03:31:02 -04:00
committed by GitHub
parent 39673ae9ff
commit 941b62249e
34 changed files with 2044 additions and 189 deletions

View File

@@ -1,23 +1,39 @@
#!/usr/bin/env node
/**
* gen-exit-code-registry.cjs — generates gsd-core/bin/lib/exit-code-registry.cjs
* from the declaration at gsd-core/bin/shared/exit-codes.json.
* gen-exit-code-registry.cjs — generates THREE byte-identical/derived
* artifacts from the declaration at gsd-core/bin/shared/exit-codes.json:
* - gsd-core/bin/lib/exit-code-registry.cjs (tsc-adjacent build tree)
* - scripts/lib/exit-code-registry.cjs (committed, for scripts/
* consumers that must work on an unbuilt clone — same reason
* scripts/lib/cli-exit.cjs exists alongside gsd-core/bin/lib/cli-exit.cjs;
* see scripts/gen-scripts-cli-exit.cjs).
* - src/exit-code-registry.d.cts (the ambient type declaration
* tsc uses to typecheck src/cli-exit.cts's `require('./exit-code-registry.cjs')`
* against the shape the .cjs artifacts above actually export — generated
* from the SAME ENTRY_FIELD_TYPES table serializeRegistry() uses, so the
* two can never independently drift).
*
* ADR-3889 ("One exit-code registry — 0 and 1 are free, everything else is
* allocated") Phase 1 (#3905): this script builds the ALLOCATOR. It owns
* validating the declaration (band rules, one-number-one-meaning, one-owner)
* and hand-serializing the generated lookup module — the same
* declaration -> generator -> `--check` gate pattern this repo already uses
* for capability-registry.cjs, the model catalog, and the ADR index.
* allocated") Phase 1 (#3905) built the single-output allocator; Phase 2
* (#3906) added the second .cjs emission so scripts/ has its own committed
* copy instead of reaching into gitignored build output, and a follow-up
* closed the review finding that the .d.cts was hand-maintained with no gate
* by generating it here too.
*
* The two .cjs artifacts are byte-identical: serializeRegistry() only encodes
* the DECLARATION path (for the banner comment), never the output path, so
* one generated string is written to both locations unchanged. The .d.cts is
* a separate, smaller derivation (a structural type, not a per-entry table)
* but is generated and --check-gated exactly the same way.
*
* Nothing in this script emits a registered exit code itself; wiring
* consumers onto the registry is a later phase (#3906).
* consumers onto the registry is separate work.
*
* Usage:
* node scripts/gen-exit-code-registry.cjs # same as --write
* node scripts/gen-exit-code-registry.cjs --write # write the artifact
* node scripts/gen-exit-code-registry.cjs --check # exit 1 if the committed artifact is stale
* node scripts/gen-exit-code-registry.cjs --declaration <path> --out <path> # override for tests
* node scripts/gen-exit-code-registry.cjs --write # write all three artifacts
* node scripts/gen-exit-code-registry.cjs --check # exit 1 if ANY committed artifact is stale
* node scripts/gen-exit-code-registry.cjs --declaration <path> --out <path> --scripts-out <path> --dts-out <path> # override for tests
* node scripts/gen-exit-code-registry.cjs --json # emit ONE JSON report on stdout instead of human prose
*/
@@ -29,6 +45,24 @@ const path = require('node:path');
const REPO_ROOT = path.resolve(__dirname, '..');
const DEFAULT_DECLARATION_PATH = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared', 'exit-codes.json');
const DEFAULT_OUTPUT_PATH = path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'exit-code-registry.cjs');
const DEFAULT_SCRIPTS_OUTPUT_PATH = path.join(REPO_ROOT, 'scripts', 'lib', 'exit-code-registry.cjs');
const DEFAULT_DTS_OUTPUT_PATH = path.join(REPO_ROOT, 'src', 'exit-code-registry.d.cts');
/**
* Single source of the entry field list (name -> TS type), in emission order.
* serializeRegistry()'s per-entry object literal and serializeDts()'s
* ExitCodeEntry interface are BOTH derived from this one table, so the two
* artifacts cannot independently drift out of shape with each other — closing
* the review finding that the ambient .d.cts was a hand-maintained guess at
* what serializeRegistry() emits.
*/
const ENTRY_FIELD_TYPES = Object.freeze({
code: 'number',
name: 'string',
meaning: 'string',
owner: 'string',
authorizedBy: 'string',
});
/** Frozen reason codes so tests assert on structure, not prose. */
const REASON = Object.freeze({
@@ -48,12 +82,14 @@ const REASON = Object.freeze({
});
const USAGE_MESSAGE = [
'Usage: node scripts/gen-exit-code-registry.cjs [--write|--check] [--declaration <path>] [--out <path>] [--json]',
'Usage: node scripts/gen-exit-code-registry.cjs [--write|--check] [--declaration <path>] [--out <path>] [--scripts-out <path>] [--dts-out <path>] [--json]',
' (no flag) same as --write',
' --write write the generated registry artifact',
' --check exit 1 if the committed artifact is stale',
' --write write all three generated registry artifacts',
' --check exit 1 if ANY committed artifact is stale',
' --declaration override the declaration path (default: gsd-core/bin/shared/exit-codes.json)',
' --out override the output artifact path (default: gsd-core/bin/lib/exit-code-registry.cjs)',
' --out override the primary output artifact path (default: gsd-core/bin/lib/exit-code-registry.cjs)',
' --scripts-out override the secondary output artifact path (default: scripts/lib/exit-code-registry.cjs)',
' --dts-out override the ambient type declaration path (default: src/exit-code-registry.d.cts)',
' --json emit ONE JSON report ({ok, reason, context, detail?}) on stdout instead of human-readable prose',
].join('\n');
@@ -274,21 +310,20 @@ function serializeRegistry(entries, declarationPath) {
'// GENERATED FILE — DO NOT EDIT BY HAND.',
`// Source of truth: ${relDeclaration}. Regenerate with:`,
'// node scripts/gen-exit-code-registry.cjs --write',
'// Byte-compared by `npm run lint:generated-sync` (#3905, ADR-3889 Phase 1).',
'// This exact content is emitted to TWO locations — gsd-core/bin/lib/exit-code-registry.cjs',
'// and scripts/lib/exit-code-registry.cjs (the latter committed so scripts/',
'// consumers work on an unbuilt clone) — both byte-compared by',
'// `npm run lint:generated-sync` (#3905 ADR-3889 Phase 1; #3906 Phase 2 added the second copy).',
'//',
'// exitCodeFor(name) / nameForExitCode(code) are pure and total over this',
'// closed table — each throws for anything not registered here.',
'',
].join('\n');
const entryFieldNames = Object.keys(ENTRY_FIELD_TYPES);
const entryLiterals = entries.map((e) => {
return ' Object.freeze({\n'
+ ` code: ${JSON.stringify(e.code)},\n`
+ ` name: ${JSON.stringify(e.name)},\n`
+ ` meaning: ${JSON.stringify(e.meaning)},\n`
+ ` owner: ${JSON.stringify(e.owner)},\n`
+ ` authorizedBy: ${JSON.stringify(e.authorizedBy)},\n`
+ ' })';
const fieldLines = entryFieldNames.map((field) => ` ${field}: ${JSON.stringify(e[field])},`).join('\n');
return ` Object.freeze({\n${fieldLines}\n })`;
}).join(',\n');
const body = [
@@ -341,6 +376,65 @@ function serializeRegistry(entries, declarationPath) {
return banner + '\n' + body;
}
/**
* Generate the ambient type declaration for the generated .cjs registry
* artifacts. Derived from the SAME ENTRY_FIELD_TYPES table serializeRegistry()
* iterates for its per-entry object literals, and from serializeRegistry()'s
* own fixed `module.exports = { EXIT_CODES, exitCodeFor, nameForExitCode }`
* shape — so this declaration cannot drift out of step with what the sibling
* .cjs artifacts actually export without both call sites being edited
* together. Structural only (no per-entry data): the type is the same
* regardless of how many rows the declaration has.
*/
function serializeDts(declarationPath) {
const relDeclaration = path.relative(REPO_ROOT, declarationPath).split(path.sep).join('/');
const fieldLines = Object.entries(ENTRY_FIELD_TYPES)
.map(([field, type]) => ` readonly ${field}: ${type};`)
.join('\n');
return [
'// GENERATED FILE — DO NOT EDIT BY HAND.',
`// Source of truth: ${relDeclaration} + the ENTRY_FIELD_TYPES table in`,
'// scripts/gen-exit-code-registry.cjs. Regenerate with:',
'// node scripts/gen-exit-code-registry.cjs --write',
'//',
'// Ambient type declaration for exit-code-registry.cjs — a GENERATED,',
'// committed artifact with no `.cts` source of its own (it is hand-serialized',
'// from gsd-core/bin/shared/exit-codes.json by scripts/gen-exit-code-registry.cjs,',
'// ADR-3889 §2, #3905/#3906), so tsc has nothing to compile for it. This file',
"// exists purely so `src/cli-exit.cts`'s `require('./exit-code-registry.cjs')`",
'// type-checks against the SAME shape the generated artifact actually exports',
'// at runtime — mirroring the src/vendor/*.d.cts pattern already used for',
'// other verbatim/generated JS this tree resolves types for without compiling.',
'//',
'// This declaration is generated from the same ENTRY_FIELD_TYPES table',
"// serializeRegistry()'s per-entry object literals iterate, and is",
'// byte-compared by `node scripts/gen-exit-code-registry.cjs --check`',
'// (the same check that already covers the two sibling .cjs artifacts) so a',
'// shape drift here fails the build instead of surfacing at a destructuring',
'// call site.',
'',
'export interface ExitCodeEntry {',
fieldLines,
'}',
'',
'declare const exitCodeRegistry: {',
' readonly EXIT_CODES: readonly ExitCodeEntry[];',
' // Property-typed function signatures (`name: (args) => ret`), NOT method',
' // shorthand (`name(args): ret`) — the latter is a TS "method" and trips',
' // @typescript-eslint/unbound-method at every destructuring call site',
' // (`const { exitCodeFor } = ...`), since a method may implicitly use',
' // `this`. These are pure functions that never do, so they are typed as',
' // plain function-valued properties instead.',
' exitCodeFor: (name: string) => number;',
' nameForExitCode: (code: number) => string;',
'};',
'',
'export = exitCodeRegistry;',
'',
].join('\n');
}
/**
* Load, validate, and serialize the declaration in one step.
* @returns {{ok:true,content:string}|{ok:false,reason:string,message:string}}
@@ -393,61 +487,106 @@ function emitOk(reason, humanMessage, json) {
console.log(humanMessage);
}
function doWrite(declarationPath, outPath, json) {
function doWrite(declarationPath, outPath, scriptsOutPath, dtsPath, json) {
const result = buildRegistryContent(declarationPath);
if (!result.ok) {
emitFail(result, json);
return 1;
}
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, result.content, 'utf8');
emitOk(REASON.OK, `ok gen-exit-code-registry: wrote ${outPath}`, json);
return 0;
}
function doCheck(declarationPath, outPath, json) {
const result = buildRegistryContent(declarationPath);
if (!result.ok) {
emitFail(result, json);
return 1;
// The two .cjs artifacts are byte-identical copies of the same generated
// content (serializeRegistry never encodes the output path), so the same
// string is written to both locations unchanged.
for (const target of [outPath, scriptsOutPath]) {
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, result.content, 'utf8');
}
if (!fs.existsSync(outPath)) {
emitFail(
{
reason: REASON.MISSING_ARTIFACT,
message: `${outPath} does not exist. Run:\n node scripts/gen-exit-code-registry.cjs --write`,
},
json,
);
return 1;
}
const committed = fs.readFileSync(outPath, 'utf8');
if (committed !== result.content) {
emitFail(
{
reason: REASON.DRIFTED,
message:
`${outPath} (${committed.length} bytes) != freshly generated content (${result.content.length} bytes)\n\n`
+ 'Regenerate with:\n node scripts/gen-exit-code-registry.cjs --write',
},
json,
);
return 1;
}
emitOk(REASON.OK, `ok gen-exit-code-registry: ${outPath} matches ${declarationPath}`, json);
const dtsContent = serializeDts(declarationPath);
fs.mkdirSync(path.dirname(dtsPath), { recursive: true });
fs.writeFileSync(dtsPath, dtsContent, 'utf8');
emitOk(
REASON.OK,
`ok gen-exit-code-registry: wrote ${outPath}\nok gen-exit-code-registry: wrote ${scriptsOutPath}\n`
+ `ok gen-exit-code-registry: wrote ${dtsPath}`,
json,
);
return 0;
}
/**
* @returns {{mode:'write'|'check', declarationPath:?string, outPath:?string, json:boolean}}
* Verify one committed artifact against the freshly generated content.
* @returns {{ok:true}|{ok:false,reason:string,message:string,context:object}}
*/
function checkOneArtifact(artifactLabel, artifactPath, content) {
if (!fs.existsSync(artifactPath)) {
return {
ok: false,
reason: REASON.MISSING_ARTIFACT,
message: `${artifactPath} (${artifactLabel}) does not exist. Run:\n node scripts/gen-exit-code-registry.cjs --write`,
context: { artifact: artifactLabel, path: artifactPath },
};
}
const committed = fs.readFileSync(artifactPath, 'utf8');
if (committed !== content) {
return {
ok: false,
reason: REASON.DRIFTED,
message:
`${artifactPath} (${artifactLabel}, ${committed.length} bytes) != freshly generated content (${content.length} bytes)\n\n`
+ 'Regenerate with:\n node scripts/gen-exit-code-registry.cjs --write',
context: { artifact: artifactLabel, path: artifactPath },
};
}
return { ok: true };
}
/**
* --check verifies ALL THREE committed artifacts against the same freshly
* generated content and fails naming which one drifted (or is missing) if
* any does. Checked in a fixed order (primary, secondary, dts) so a
* single-artifact failure is always reported deterministically.
*/
function doCheck(declarationPath, outPath, scriptsOutPath, dtsPath, json) {
const result = buildRegistryContent(declarationPath);
if (!result.ok) {
emitFail(result, json);
return 1;
}
const dtsContent = serializeDts(declarationPath);
const artifacts = [
['primary', outPath, result.content],
['secondary', scriptsOutPath, result.content],
['dts', dtsPath, dtsContent],
];
for (const [artifactLabel, artifactPath, content] of artifacts) {
const checked = checkOneArtifact(artifactLabel, artifactPath, content);
if (!checked.ok) {
emitFail(checked, json);
return 1;
}
}
emitOk(
REASON.OK,
`ok gen-exit-code-registry: ${outPath} matches ${declarationPath}\n`
+ `ok gen-exit-code-registry: ${scriptsOutPath} matches ${declarationPath}\n`
+ `ok gen-exit-code-registry: ${dtsPath} matches ${declarationPath}`,
json,
);
return 0;
}
/**
* @returns {{mode:'write'|'check', declarationPath:?string, outPath:?string, scriptsOutPath:?string, dtsPath:?string, json:boolean}}
*/
function parseArgs(argv) {
let mode = null;
let declarationPath = null;
let outPath = null;
let scriptsOutPath = null;
let dtsPath = null;
let json = false;
for (let i = 0; i < argv.length; i++) {
@@ -471,12 +610,24 @@ function parseArgs(argv) {
outPath = value;
} else if (arg.startsWith('--out=')) {
outPath = arg.slice('--out='.length);
} else if (arg === '--scripts-out') {
const value = argv[++i];
if (value === undefined) throw new Error('--scripts-out requires a value');
scriptsOutPath = value;
} else if (arg.startsWith('--scripts-out=')) {
scriptsOutPath = arg.slice('--scripts-out='.length);
} else if (arg === '--dts-out') {
const value = argv[++i];
if (value === undefined) throw new Error('--dts-out requires a value');
dtsPath = value;
} else if (arg.startsWith('--dts-out=')) {
dtsPath = arg.slice('--dts-out='.length);
} else {
throw new Error(`unrecognized argument: ${arg}`);
}
}
return { mode: mode || 'write', declarationPath, outPath, json };
return { mode: mode || 'write', declarationPath, outPath, scriptsOutPath, dtsPath, json };
}
function main() {
@@ -496,8 +647,12 @@ function main() {
const declarationPath = args.declarationPath || DEFAULT_DECLARATION_PATH;
const outPath = args.outPath || DEFAULT_OUTPUT_PATH;
const scriptsOutPath = args.scriptsOutPath || DEFAULT_SCRIPTS_OUTPUT_PATH;
const dtsPath = args.dtsPath || DEFAULT_DTS_OUTPUT_PATH;
return args.mode === 'check' ? doCheck(declarationPath, outPath, args.json) : doWrite(declarationPath, outPath, args.json);
return args.mode === 'check'
? doCheck(declarationPath, outPath, scriptsOutPath, dtsPath, args.json)
: doWrite(declarationPath, outPath, scriptsOutPath, dtsPath, args.json);
}
if (require.main === module) process.exitCode = main();
@@ -507,12 +662,16 @@ module.exports = {
USAGE_MESSAGE,
DEFAULT_DECLARATION_PATH,
DEFAULT_OUTPUT_PATH,
DEFAULT_SCRIPTS_OUTPUT_PATH,
DEFAULT_DTS_OUTPUT_PATH,
ENTRY_FIELD_TYPES,
isAllocatableCode,
bandFor,
validateEntry,
validateEntries,
loadDeclaration,
serializeRegistry,
serializeDts,
buildRegistryContent,
parseArgs,
main,