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

@@ -520,6 +520,9 @@ An injectable time abstraction accepted as an optional parameter by production c
### Process seam
The single subprocess-spawning primitive test code uses (`tests/helpers/process-seam.cjs`, #3055): `runNode` / `runGit` / `runHook`, each returning one discriminated union `{ outcome, exitCode, stdout, stderr, timedOut, signal, killed, code }` where `outcome` is the frozen `OUTCOME` enum (`EXITED` / `KILLED` / `TIMED_OUT` / `BUFFER_OVERFLOW` / `SPAWN_FAILED`). Every call is timeout-bounded — there is no unbounded code path — and nothing throws for a child's exit code, kill, timeout, buffer overflow, or spawn failure; all five are data. KILLED is a child terminated by a signal the seam did not send (a genuine OOM kill): `spawnSync` reports no `error` for that case, so it must be distinguished from EXITED, and the `runGsdTools` adapter retries it exactly as the pre-seam `isKilled()` did. This is what makes `timedOut` and `signal` assertable, so a fail-open guard's degraded verdict can be tested instead of merely observing that the call did not throw. Discrimination order is forced by runtime behavior: a timeout and a maxBuffer overflow are identical on both `status` (`null`) and `signal` (`SIGTERM`) and differ only by `code` (`ETIMEDOUT` vs `ENOBUFS`), so overflow is classified first — but that ordering assumes `status === null`, which is checked ahead of it: at the exact timeout boundary `spawnSync` can report `error.code === 'ETIMEDOUT'` on a result that ALSO carries a real `status` (the child finished on its own just as the timer fired), so `status !== null` is classified EXITED before any error-code branch runs, keeping `exitCode` coherent with the reported outcome. Per-suite wrappers remain and bind fixtures (cwd, env, payload); only the spawn body delegates here. Deliberately **not** a fault-injection surface — it cannot distinguish an injected timeout from a genuine bench OOM and would retry it; injection is in-process via `deps` (#3056). `runGsdTools` is an adapter over it that preserves its own legacy `{ success, output, error, exitCode }` shape and retry-once-on-kill behavior.
### Exit Code Registry Module
Generated central allocator for this repo's process exit codes (ADR-3889, epic #3889 — "nothing fails with success"; Phase 1, #3905). Declaration source of truth: `gsd-core/bin/shared/exit-codes.json` (append-only entries: `code`, `name`, `meaning`, `owner`, `authorizedBy`); generated artifact `gsd-core/bin/lib/exit-code-registry.cjs` (`EXIT_CODES`, `exitCodeFor(name)`, `nameForExitCode(code)`) is produced by `scripts/gen-exit-code-registry.cjs --write` and byte-guarded by `npm run lint:generated-sync` — the same declaration → generator → `--check` gate pattern as the Capability Registry, the Model Catalog, and the ADR index. Band contract (ADR-3889 §1): `0` and `1` are free and never allocatable here; `2` is reserved to the Claude Code hook-adapter protocol (owner must be `hook-adapter`); `3`-`13` are Node-reserved; `64`-`78` are the generic band; `80`-`125` are the domain band; every other value (`14`-`63`, `79`, `126`+) sits outside every band and is rejected — so every registered code is non-zero by construction. The generator also enforces one-number-one-meaning and one-name-one-code across the whole declaration. This module is the ALLOCATOR only: nothing in the generator or the generated artifact emits a registered exit code itself — wiring real call sites onto the registry is later work that does not land until #3906.
### Git fixture wrapper
The throw-preserving companion to the process seam (`tests/helpers/git-fixture.cjs`, #3143): `gitOrThrow(args, options)` runs `runGit` and returns `stdout` as a string on a clean exit, but throws on any other outcome. It exists because the seam **deliberately never throws** while `execSync` and `execFileSync` — the two forms 237 migrating call sites use — both throw on a non-zero exit. Migrating those mechanically onto `runGit` would convert a loud failure into a silent one: fixture setup that failed would return an empty string and surface as a baffling assertion failure further down. The thrown error carries `status` **and** `exitCode` as deliberate aliases (`status` is what the legacy `execSync` catch idiom reads, e.g. `tests/worktree-safety.test.cjs:1361`), plus `stdout`, `stderr`, `signal`, `timedOut` and `outcome`. Use `runGit` when every outcome is data you branch on; use `gitOrThrow` for fixture setup that must abort loudly. The seam module is **not** modified to add this — a throwing export would falsify the never-throws contract stated in its own header and in the `### Process seam` entry above. The module also exports `throwIfFailed(result, displayName)`, the single implementation of that throw shape: `gitOrThrow` itself is `throwIfFailed` specialized to `runGit`, so it routes through the same code path and the two cannot drift apart. Per-suite wrappers driving non-git targets — a node CLI via `runNode`, a bash snippet via `runHook` — call `throwIfFailed` directly rather than hand-rolling their own copy of this shape, which is exactly how five call sites had drifted from each other before this module exported it (#3144). It also exports `toLegacyResult(result)`, the non-throwing counterpart: a bare mapping onto the legacy `{ status, stdout, stderr }` shape (`status` aliasing the seam's `exitCode`) for call sites that already branch on exit status as data rather than wanting a throw — ~8 test files hand-rolled that identical three-line mapping before this module exported it too (#3147). Callers needing an extra field beyond that shape (e.g. a parsed-JSON body, a fixture-specific path) compose it — `{ ...toLegacyResult(result), extra }` — rather than folding the extra behavior into the shared helper.