enhance(#3905): the exit-code registry — one number, one meaning, enforced at build (#3920)

* feat(#3905): the exit-code registry — one number, one meaning, enforced at build

A generated registry replaces locally-invented exit codes. Every entry records code, name, meaning, owning module and the decision that authorized it. The generator refuses to build a table where two entries claim one code, two claim one name, a code falls in a range Node or the shell reserves, 2 is claimed by anything but the hook adapter, or an allocation carries no justification. exitCodeFor is pure and total: it throws rather than returning undefined, including for prototype-chain names.

Inert by design — nothing emits a registered code until #3906. Every registered code is non-zero, asserted over the whole table, so a caller testing for failure behaves identically for pass and trips for everything else.

* feat(#3905): make the registry generator's failures machine-readable

Adds a --json mode carrying {ok, reason, context, detail}, where context is a typed payload naming the specifics the prose embedded - which code collided and under which names, which band rejected a code, which field was missing. The tests now assert on that structure instead of regex-matching the generator's stderr, which CONTRIBUTING prohibits, and the CONTEXT.md glossary gains the entry the issue's scope requires.

* test(#3905): refresh the install-tree fixtures for the new declaration

The registry declaration ships in the install tree, so all 19 golden fixtures needed regenerating. Caught by the remote matrix, not by lint:ci - the install-tree goldens are verified by a test rather than a lint, so a newly shipped file clears every local gate and fails only under the suite.

* chore(#3905): backfill changeset pr number

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-27 00:10:11 -04:00
committed by GitHub
parent 7e9d33c378
commit 878f25025c
28 changed files with 1250 additions and 2 deletions

View File

@@ -0,0 +1,87 @@
'use strict';
// GENERATED FILE — DO NOT EDIT BY HAND.
// Source of truth: gsd-core/bin/shared/exit-codes.json. Regenerate with:
// node scripts/gen-exit-code-registry.cjs --write
// Byte-compared by `npm run lint:generated-sync` (#3905, ADR-3889 Phase 1).
//
// exitCodeFor(name) / nameForExitCode(code) are pure and total over this
// closed table — each throws for anything not registered here.
const EXIT_CODES = Object.freeze([
Object.freeze({
code: 2,
name: "HOOK_DENY",
meaning: "Claude Code hook protocol — deny the tool call",
owner: "hook-adapter",
authorizedBy: "ADR-3889",
}),
Object.freeze({
code: 64,
name: "USAGE",
meaning: "Caller error — bad argv, unknown subcommand, missing argument",
owner: "generic",
authorizedBy: "ADR-3889",
}),
Object.freeze({
code: 66,
name: "NO_INPUT",
meaning: "Ran; zero units were in scope, and that emptiness is known to be genuine",
owner: "generic",
authorizedBy: "ADR-3889",
}),
Object.freeze({
code: 69,
name: "UNAVAILABLE",
meaning: "Could not run — prerequisite absent, input unreadable, scope unestablished",
owner: "generic",
authorizedBy: "ADR-3889",
}),
Object.freeze({
code: 70,
name: "INTERNAL",
meaning: "Self-failure — crash, timeout, killed subprocess",
owner: "generic",
authorizedBy: "ADR-3889",
})
]);
const NAME_TO_CODE = new Map(EXIT_CODES.map((entry) => [entry.name, entry.code]));
const CODE_TO_NAME = new Map(EXIT_CODES.map((entry) => [entry.code, entry.name]));
/**
* Resolve the registered exit code for a symbolic name. Pure, total: throws
* for anything not an exact, registered, exact-case key — including
* non-strings, the empty string, untrimmed strings, wrong case, and
* prototype-chain names like `__proto__`/`constructor`/`toString` (a Map
* lookup never touches the prototype chain, so these are indistinguishable
* from any other unregistered name).
*
* @param {string} name
* @returns {number}
*/
function exitCodeFor(name) {
if (typeof name !== 'string' || name.length === 0) {
throw new Error(`exitCodeFor: name must be a non-empty string, received ${JSON.stringify(name)}`);
}
if (!NAME_TO_CODE.has(name)) {
throw new Error(`exitCodeFor: unregistered exit code name: ${JSON.stringify(name)}`);
}
return NAME_TO_CODE.get(name);
}
/**
* Reverse of exitCodeFor: resolve the symbolic name for a registered exit
* code. Pure, total: throws for anything not an exact, registered code.
*
* @param {number} code
* @returns {string}
*/
function nameForExitCode(code) {
if (!CODE_TO_NAME.has(code)) {
throw new Error(`nameForExitCode: unregistered exit code: ${JSON.stringify(code)}`);
}
return CODE_TO_NAME.get(code);
}
module.exports = { EXIT_CODES, exitCodeFor, nameForExitCode };