* fix(#3914): retire n/no-process-exit where its successor governs
Epic #3889 criterion 5 — no phase closes with a guard added and its
predecessor left standing — is violated in the tree by the epic that wrote it.
local/require-registered-exit was registered on gsd-core/bin/**/*.cjs and
scripts/**/*.cjs, while n/no-process-exit stayed 'error' over a nine-glob block
covering those same two. Only the hooks 'off' exemption ever came down; the
predecessor's registration never did. Both rules have been enforcing the same
property on the same surfaces since P6.
Narrowed, not deleted. Seven of those nine globs have NO successor —
eslint-rules/, bin/lib/, pi/, examples/, vscode/, .kilo/, .opencode/ — so
deleting the rule outright would silently drop enforcement on all seven. That
is the inversion this epic has already hit three times: removing a coarse guard
because a narrower one exists somewhere it does not reach. Flat config is
last-match-wins and both successor blocks come after the nine-glob block, so
'n/no-process-exit': 'off' in exactly those two retires the predecessor
precisely where the successor governs and nowhere else.
The successor is strictly more precise: it permits process.exit only inside
terminateNow in cli-exit.cts, the single sanctioned terminator (ADR-3889 §3),
where n/no-process-exit permits none and would flag terminateNow's own
generated copy.
Asserted at the consumer's altitude via ESLint.calculateConfigForFile on real
paths, with the positive control that matters: n/no-process-exit is still
'error' on six of the seven successor-less globs, so a future edit that turns
this into a blanket disable goes red. bin/lib/ has no file in this checkout and
is reported as untested rather than given an invented path. Severity is
normalized across the string/numeric/array forms the API can return, and the
normalized value asserted — not truthiness.
Verified by running calculateConfigForFile myself on both superseded globs and
four controls before trusting the test.
Found and fixed inline: the change made an eslint-disable directive at
gsd-tools.cjs:257 partially unused, which --max-warnings 0 rejects; narrowed to
the one rule still in force.
Verification runs on the remote runner.
Refs #3914
* docs(#3914): the epic added three guards, it did not remove one
The audit reconciled the epic ledger against what actually landed. The net is
+3, not -1: four lint:generated-sync --check arms (gen-scripts-cli-exit,
gen-hooks-cli-exit, gen-exit-code-registry, gen-exit-code-docs) plus one rule,
against two retirements.
An epic whose thesis was consolidation ended with a larger guard surface than
it started with. The additions are each defensible; the claim that the total
fell was never true.
Two of the three prior errors in this amendment are mine. It said "Net -1 by
count" above terms reading -1 -1 +1 +1 +1, which sums to +1 — an arithmetic
error in the paragraph directly below the sentence arguing that an ADR about
honest accounting must not pad its own ledger. And the term list omitted two of
the four --check arms, which is what turns that +1 into the real +3.
Recorded rather than quietly rewritten. This ledger has now been wrong three
times — the original -2, the -1 that replaced it, and #3914's own table, which
states -1 above terms summing to 0 — and a written claim nobody checked against
the thing it describes is the exact failure this epic exists to close.
Refs #3914
* fix(#3914): make the successor actually supersede before retiring the predecessor
An isolated security review found that the previous commit turned off a guard
that was still doing work. Reproduced by executing both rules against a
fixture, not inferred:
const exit = 'exit';
process[exit](1);
n/no-process-exit flags it; local/require-registered-exit did not, because it
early-returned on callee.computed. So retiring the predecessor on
gsd-core/bin/**/*.cjs and scripts/**/*.cjs un-guarded that shape on precisely
the two globs this epic's exit contract cares most about.
This is the third time in this epic I have removed a coarse guard on the claim
that a narrower one covered it, without checking construct-level parity — after
the allowlist key-to-prefix-to-exact-membership sequence and the band
ranges-to-categories one. The rule is the same every time: a narrower guard
supersedes a coarser one only where it demonstrably reaches at least as far,
and "demonstrably" means executing both against the constructs, not reading
either.
The successor now resolves computed property access for the statically
determinable cases — a string Literal, and an Identifier bound once to a string
Literal, resolved through scope — and leaves genuinely dynamic properties
alone so the rule does not over-fire. Measured after the fix: plain
process.exit flagged, process['exit']() flagged, process[exit]() flagged,
process[globalThis.k]() not flagged. That makes it a strict superset of the
predecessor on these globs, since process['exit']() was caught by NEITHER rule
before.
The second finding is worse than the first, because it was reasoning rather
than oversight. My justification comment claimed n/no-process-exit "would flag
terminateNow's own generated copy here". It would not — that file is in the
global ignore list, so neither rule ever lints it. There was no conflict to
resolve; I wrote a rationale I had not checked, in a change whose entire
subject is written claims nobody verified. Both comment blocks now state the
real basis.
The tests that should have caught this asserted only rule SEVERITY per glob and
never construct REACH, which is exactly how a coverage hole passed. A parity
matrix now pins all five shapes, including a RED/GREEN regression pin against
an inlined reproduction of the pre-fix rule — inlined rather than loaded from
HEAD, because HEAD resolves to the fixed commit under the remote runner and
would silently stop testing anything.
Verification runs on the remote runner.
Refs #3914
* fix(#3914): the two exit rules are complementary — keep both
Reverts this branch's retirement of n/no-process-exit. The premise was wrong
twice, and the second review proved the change itself was wrong.
I claimed local/require-registered-exit was a strict superset on
gsd-core/bin/**/*.cjs and scripts/**/*.cjs. Measured, successor vs predecessor:
function f(exit) { process[exit](1); } 0 vs 1
let exit='exit'; exit='exit'; process[exit]() 0 vs 1
const { exit } = ...; process[exit](1) 0 vs 1
plus for-of bindings, let-then-assign, var redeclaration, catch params, and an
undeclared global named exit. The predecessor matches any identifier NAMED
exit however it is bound; the successor resolves only a string literal or a
single-write const. It never was a superset — I asserted the relationship after
fixing one construct and did not re-check the rest.
The justification was independently false: all three generated cli-exit copies
are in the global ignore list, so n/no-process-exit was never flagging
terminateNow. There was no conflict to resolve. I wrote a rationale I had not
verified, in the phase whose subject is written claims nobody checked.
So criterion 5 does not apply to this pair. They are not predecessor and
successor — they are complementary, each catching constructs the other misses.
The epic's criterion assumed a replacement relationship that does not exist
here, and retiring either rule loses real coverage. The ADR ledger now says so
with the measured shapes.
What survives is the genuine improvement: the computed-property strengthening.
local/require-registered-exit now catches process['exit'](1) and optional-chain
terminators like process?.[k]?.(1), which NEITHER rule caught before, while
correctly ignoring a genuinely dynamic property so it does not over-fire.
The parity tests are rewritten to assert what is true rather than what I wanted
to be true: a bidirectional matrix where each rule is shown catching shapes the
other misses. The previous matrix tested only the four shapes where the
successor wins, which is precisely why the regression shipped — a test set
selected to confirm the thesis.
Also corrected: a stale ADR sentence claiming a third wrong ledger version that
does not exist (the table it described now reads +3 over terms summing to +3),
and a changeset whose stated motivation was the false generated-copy conflict.
Verification runs on the remote runner.
Refs #3914
* fix(#3914): the exemption term was a no-op — the net is +4
Fourth correction to this ledger, and a fourth error of the same kind.
Every version counted removing the n/no-process-exit 'off' entry from the hooks
block as -1. Measured: calculateConfigForFile returns undefined for that rule on
hooks/**. It was never registered there, and no broader block sets it globally,
so the 'off' entry overrode nothing and removing it changed no enforcement at
all. A no-op removal, not a guard removal — the same category error as counting
baseline acknowledgement entries: a thing that is not a guard, in guard units.
It is misattributed too; that block came down in d98b55562 (#3910), already on
next before this branch existed.
So the epic added FOUR guards, not three.
This surfaced from a test of mine that overclaimed. I asserted n/no-process-exit
was error on "all nine CommonJS/hook globs" — but hooks is not one of the nine,
and the rule resolves to undefined there. Fixing the test to match reality is
what exposed the ledger term, which is the argument for tests that assert
identity rather than a comfortable shape.
The hooks state is now pinned explicitly rather than glossed: n/no-process-exit
unregistered, local/require-registered-exit error. It is mildly surprising and
therefore worth a test.
Also updates a pre-existing test that documented the old name-based-only
boundary as intentional. The computed-property strengthening deliberately moves
that boundary — process['exit'](0) was caught by NEITHER rule before — so the
test now asserts the new contract and cites the ADR, rather than being left to
fail or the rule weakened to satisfy it. A contract change should read as
deliberate in the test that pins it.
Verification runs on the remote runner.
Refs #3914
* chore(#3914): backfill changeset pr number to 4018
---------
Co-authored-by: sim <sim@local>
400 lines
24 KiB
Markdown
400 lines
24 KiB
Markdown
# ADR-3889: One exit-code registry — 0 and 1 are free, everything else is allocated
|
||
|
||
| | |
|
||
|---|---|
|
||
| **Status** | Proposed |
|
||
| **Date** | 2026-08-26 |
|
||
| **Issue** | [#3889](https://github.com/open-gsd/gsd-core/issues/3889) |
|
||
| **Supersedes** | — |
|
||
| **Amends** | [ADR-2980](2980-payload-carried-error-is-a-degraded-result.md) — supplies the compatibility boundary its "Revisit if" clause names |
|
||
| **Constrained by** | [ADR-2966](2966-loop-qa-walk.md) §5–§7, [ADR-2008](2008-command-exit-zero-gate.md), [ADR-1411](1411-resolution-provenance.md), [ADR-3473](3473-enforcement-by-construction.md) §B4 |
|
||
|
||
> **Evidence note.** Every count here is an **AST census** (`@typescript-eslint/parser`, `CallExpression`
|
||
> where `callee.object.name === 'process' && callee.property.name === 'exit'`), not a grep. A grep of
|
||
> `process\.exit` over-counts by roughly 2×: it matches comment prose (the hooks discuss
|
||
> `catch { process.exit(0) }` in their own comments) **and** it matches `process.exitCode`, which is
|
||
> the *correct* pattern. Behavioral claims below were executed, each with a control.
|
||
|
||
## Context
|
||
|
||
### What is actually true (measured, not inferred)
|
||
|
||
**128 real `process.exit()` call sites** (143 raw, less 15 `gsd-core/bin/lib/*.cjs` sites that are
|
||
tsc output of the `src/*.cts` originals). They are not evenly spread:
|
||
|
||
| Surface | Sites | Shape |
|
||
|---|--:|---|
|
||
| `hooks/**` | **91** | 83 × `exit(0)`, 8 × `exit(2)` |
|
||
| `bin/install.js` | 18 | generated installer |
|
||
| `src/**/*.cts` | 15 | mixed, incl. 4 ternaries |
|
||
| `bin/` other | 2 | |
|
||
| `scripts/**` | **1** | |
|
||
| `gsd-core/bin/gsd-tools.cjs` | 1 | |
|
||
|
||
Against that, **`process.exitCode` is assigned 88 times** — `src` 25, `scripts` 39, `gsd-core` 24,
|
||
`hooks` **0**.
|
||
|
||
Two conclusions follow, and both contradict the obvious framing:
|
||
|
||
1. **This repo already has an exit seam and already adopted it.** `src/cli-exit.cts`'s `runMain` +
|
||
`ExitError` is the owner. `scripts/` runs **39 `exitCode` assignments to 1 `process.exit`** — that
|
||
surface is done. The migration is not missing; it is *incomplete*, and it stopped at `hooks/`.
|
||
2. **71% of the remaining problem is one directory.** `hooks/` has zero `exitCode` usage, and for a
|
||
sound reason recorded at `eslint.config.mjs:563-582`: a hook's stdin-timeout guard fires from a
|
||
`setTimeout` where `process.exitCode = N; return;` terminates nothing. The seam has no adapter for
|
||
the one surface whose entire contract *is* the exit code.
|
||
|
||
### The real defect: codes are invented locally, so the same number means four things
|
||
|
||
`src/ui-safety-gate.cts:137` states its contract in a comment:
|
||
|
||
```
|
||
// Exit 0 = UI found, 1 = no UI, 2 = startup error.
|
||
```
|
||
|
||
`0` and `1` are exactly right — pass and fail are universal. The third code is the problem: it was
|
||
**invented at the module**, and four other things in this repo also invented `2`:
|
||
|
||
| Emitter | `2` means |
|
||
|---|---|
|
||
| Claude Code hook harness | **deny** the tool call |
|
||
| `scripts/{secret,base64,prompt-injection}-scan.sh` | **usage error** (bad argv) |
|
||
| `gsd-test` (foreign repo) | **infra error** (dispatch failed) |
|
||
| `ui-safety-gate` / `api-coverage` / `assumption-delta` | **startup error** (stdin read failed) |
|
||
|
||
A wrapper that shells one into another — precisely what a `command-exit-zero` gate
|
||
([ADR-2008](2008-command-exit-zero-gate.md)) does — cannot interpret `2` without out-of-band
|
||
knowledge of which program produced it. Four modules independently reinvented the same
|
||
three-outcome convention (`ui-safety-gate`, `api-coverage`, `assumption-delta`, `teams-status`),
|
||
each documenting it only in a comment. **The convention exists; what is missing is an allocator.**
|
||
|
||
### The failures this produces, executed with controls
|
||
|
||
**(a) An empty input is reported as an authoritative negative verdict.** These gates' `exit(2)` arm
|
||
is bound to `stdin.on('error')` only. There is no arm for *stdin closed with zero bytes*:
|
||
|
||
```console
|
||
$ printf 'Build a React component with a button' | node gsd-core/bin/lib/ui-safety-gate.cjs; echo $?
|
||
0 # control — UI found
|
||
$ printf '' | node gsd-core/bin/lib/ui-safety-gate.cjs; echo $?
|
||
1 # "no UI" — but nothing was examined
|
||
$ printf '' | node gsd-core/bin/lib/api-coverage.cjs; echo $?
|
||
1 # "no API integration" — same
|
||
```
|
||
|
||
An unset `$PHASE_SECTION` makes the UI safety gate assert that the phase has no UI.
|
||
|
||
**(b) A scan that could not run reports clean.**
|
||
|
||
```console
|
||
$ bash scripts/secret-scan.sh --diff origin/next; echo $?
|
||
secret-scan: scanned 2 files, 0 with findings
|
||
0 # control
|
||
$ bash scripts/secret-scan.sh --diff refs/heads/does-not-exist; echo $?
|
||
secret-scan: no files to scan
|
||
0 # ← could not compute a diff
|
||
$ cd /tmp/not-a-repo && bash …/scripts/secret-scan.sh --diff origin/next; echo $?
|
||
secret-scan: no files to scan
|
||
0 # ← not a git repository
|
||
```
|
||
|
||
`collect_files` (`scripts/secret-scan.sh:238-239`) ends `2>/dev/null || true`, which discards the
|
||
diagnostic and the status. Identical three lines at `base64-scan.sh:323-326` and
|
||
`prompt-injection-scan.sh:252-255`.
|
||
|
||
**(c) A failed probe fabricates a specific negative verdict.** Nine shell sites use
|
||
`… 2>/dev/null || echo '<json>'`. They split into two classes, and **the honest form already exists
|
||
in this repo**:
|
||
|
||
| Form | Site | Says |
|
||
|---|---|---|
|
||
| honest | `execute-phase/steps/codebase-drift-gate.md:19` | `{"skipped":true,"reason":"sdk-failed"}` |
|
||
| honest | `plan-phase.md:536` | `{"skipped":true}` |
|
||
| **fabricated** | `capabilities/ai-integration/fragments/api-coverage-plan-pre.md:25,114` | `{"detected":false,…}` |
|
||
| **fabricated** | `capabilities/assumption-delta/fragments/plan-pre.md:14` | `{"detected":false,…}` |
|
||
|
||
The fabricated form does not degrade — it asserts that no API integration exists, and that assertion
|
||
feeds the **blocking** `api-coverage.verify-pre` gate. A phase can ship without its COVERAGE.md
|
||
matrix because the detector failed to launch.
|
||
|
||
**(d) 25 failure reasons collapse to `1`.** `ERROR_REASON` (`src/io.cts:180-215`) is a curated
|
||
25-value enum whose docstring says it exists *"so tests can assert against typed values instead of
|
||
grepping stderr."* `error()` (`src/io.cts:246-254`) ends `process.exit(1)` unconditionally. Note the
|
||
symmetric fact: `output()` (`src/io.cts:144-168`) **never touches the exit code at all** — it writes
|
||
to fd 1 and returns. The `0` a caller observes is not a decision anyone made; it is the absence of
|
||
one.
|
||
|
||
### Why the existing guard cannot see it — proven, with a positive control
|
||
|
||
```console
|
||
# positive control: the rule works
|
||
$ printf "process.exit(0);" > scripts/_probe.cjs && npx eslint --no-cache scripts/_probe.cjs
|
||
n/no-process-exit fired: 1
|
||
|
||
$ npx eslint --no-cache src/io.cts # contains process.exit(1) at :253
|
||
n/no-process-exit fired: 0
|
||
$ npx eslint --no-cache gsd-core/bin/lib/io.cjs
|
||
"File ignored because of a matching ignore pattern"
|
||
$ npx eslint --no-cache hooks/gsd-read-guard.js
|
||
n/no-process-exit fired: 0
|
||
```
|
||
|
||
Three independent reasons, all live at `next`: `'off'` for `hooks/**` (`eslint.config.mjs:582`);
|
||
never registered on `src/**/*.cts` (that block loads only the `local` plugin); and `'error'` on
|
||
`gsd-core/bin/**/*.cjs` (`:478`) but `gsd-core/bin/lib/io.cjs` is item **239** in the global
|
||
`ignores` list. The most-executed exit site in the product is invisible to its own rule.
|
||
|
||
## Decision
|
||
|
||
**`0` and `1` are free. Every other exit code is allocated from one registry.**
|
||
|
||
### 1. The bands
|
||
|
||
| Range | Rule |
|
||
|---|---|
|
||
| `0` | **Free.** Pass — the operation ran and its verdict is affirmative. |
|
||
| `1` | **Free.** Fail — the operation ran and its verdict is negative. |
|
||
| `2` | **Reserved to the Claude Code hook protocol** (deny). Emittable *only* by the hook adapter. No other GSD code may produce it. |
|
||
| `3`–`13` | **Forbidden.** Node reserves these (3 = internal JS parse error, 5 = fatal error, 9 = invalid argument, 13 = unfinished top-level await). A domain `3` is ambiguous with a Node crash. |
|
||
| `64`–`78` | **Generic entries**, aligned to `sysexits.h` mnemonics where they fit. |
|
||
| `80`–`125` | **Domain entries**, allocated per tool. |
|
||
| `126`, `127`, `128+N` | Shell (not executable / not found) and signals. Not ours. |
|
||
|
||
A tool that only needs pass/fail registers nothing. The moment it needs to say anything **more
|
||
specific** than pass/fail, it takes a number from the registry — because a locally-invented number
|
||
is exactly how `2` came to mean four things.
|
||
|
||
### 2. The registry is the database
|
||
|
||
One generated source of truth, following the pattern this repo already uses for
|
||
`capability-registry.cjs`, the model catalog, and the ADR index — a declaration per owner, a
|
||
generator, and a `--check` gate in `lint:generated-sync`.
|
||
|
||
Every entry carries: **code**, **symbolic name**, **meaning**, **owning module**, and the **issue or
|
||
ADR that authorized it**. Two invariants the generator enforces by failing the build:
|
||
|
||
- **One number, one meaning.** A second declaration of an allocated code is a hard error. This is the
|
||
invariant whose absence produced the four-way `2`.
|
||
- **One owner.** A code is emitted only by its declaring module. A second emitter is a hard error —
|
||
that is how a generic code silently acquires a second meaning.
|
||
|
||
Generic entries seeded from the measured population:
|
||
|
||
| Code | Name | Meaning |
|
||
|--:|---|---|
|
||
| `64` | `USAGE` | Caller error — bad argv, unknown subcommand, missing argument |
|
||
| `66` | `NO_INPUT` | Ran; **zero units were in scope**, and that emptiness is known to be genuine |
|
||
| `69` | `UNAVAILABLE` | **Could not run** — prerequisite absent, input unreadable, scope unestablished |
|
||
| `70` | `INTERNAL` | Self-failure — crash, timeout, killed subprocess |
|
||
|
||
`NO_INPUT` versus `UNAVAILABLE` is the distinction the failures in (a) and (b) collapse, and it is
|
||
deliberately uncomfortable to author: emitting `NO_INPUT` honestly requires proving the scope was
|
||
established, which means deleting the `2>/dev/null || true` that currently destroys the evidence.
|
||
That cost **is** the missing error handling.
|
||
|
||
**Domain entries are permitted** and are the reason this is a registry rather than an enum. A tool
|
||
with a genuinely tool-specific outcome registers it (`80`+) with an owner and a justification,
|
||
rather than reaching for a generic code that nearly fits. The `80`–`125` band bounds this at 46
|
||
entries; needing more would itself be a finding.
|
||
|
||
**`sysexits` honestly:** FreeBSD documents the `sysexits(3)` *interface* as deprecated. We adopt
|
||
neither the header nor conformance to it — we borrow a collision-free band and its established
|
||
mnemonics so `69` in a CI log has somewhere to be looked up.
|
||
|
||
### 3. Two terminators over one registry
|
||
|
||
The seam is the process boundary. `src/cli-exit.cts` already sits on it and is deepened, not
|
||
replaced:
|
||
|
||
- **`runMain(main)`** — drain-then-exit. Sets `process.exitCode`; stdout flushes and
|
||
`process.on('exit')` handlers fire. Today's behavior, retyped. Serves `gsd-tools`, `scripts/`,
|
||
generators — the 88 sites already doing this.
|
||
- **`terminateNow(outcome)`** — write-then-terminate. `fs.writeSync` then immediate `process.exit`.
|
||
Required for `hooks/`, per the constraint `eslint.config.mjs:563-582` documents correctly and per
|
||
`hooks/gsd-write-guard.js:159-175` (pipe writes are async on Windows). **This is the only
|
||
sanctioned `process.exit` call site in the repo, and the only place `2` can be produced.**
|
||
|
||
Both project through the same registry lookup. A parity assertion test is mandatory — two
|
||
terminators with independent projections would re-create this ADR's defect inside its own fix
|
||
(`CONTEXT.md` → generative-fix-divergence).
|
||
|
||
### 4. Declaration is mandatory; the projection is versioned
|
||
|
||
- **Declaring an outcome is mandatory immediately.** Every terminating path names one. Under `v1`
|
||
the projection reproduces today's integers exactly, so this is a pure refactor.
|
||
- **The integers are policy.** `v1` pins [ADR-2980](2980-payload-carried-error-is-a-degraded-result.md)'s
|
||
60 ratified `output({error})` sites to exit 0 byte-for-byte. `v2` applies the registry. Selected by
|
||
`GSD_EXIT_CONTRACT=v2` / `--exit-contract=v2`; default flips at the next major.
|
||
|
||
This is the compatibility boundary ADR-2980's "Revisit if" clause asks for verbatim.
|
||
|
||
**Exception — these flip to `v2` immediately**, because no ADR ratified them and their callers are
|
||
in-repo and enumerable: the three shell scanners, and the four gate modules' empty-input arm.
|
||
|
||
### 5. The fail-safe property
|
||
|
||
Every registered code is non-zero. A caller written `if ! cmd; then` or `cmd && next` behaves
|
||
**identically** for pass and trips for everything else. **This can turn a false green red; it can
|
||
never turn a red green.** That is what makes it shippable across a surface with 170 direct callers,
|
||
and it is the property ADR-2980's declined Option 3 lacked.
|
||
|
||
## Consequences
|
||
|
||
**Good.**
|
||
|
||
- One number, one meaning, machine-enforced. The four-way `2` becomes unrepresentable rather than
|
||
merely documented.
|
||
- Four modules' hand-rolled convention consolidates onto one allocator — a consolidation, not a
|
||
greenfield concept.
|
||
- A scan that could not run exits `69`, not `0`. A gate handed empty input says so instead of
|
||
asserting a verdict.
|
||
- `exitCodeFor` is a pure total function over a closed table: exhaustively testable, and a natural
|
||
`fast-check` bijection property.
|
||
- `soft-error-exit-zero` and `untyped-success` stop being permanent SMELLs, and their four frozen
|
||
`tests/qa/smell-baseline.json` entries leave.
|
||
|
||
**Costs, stated plainly.**
|
||
|
||
1. **A registry is a governance surface.** Someone must review allocations, or `80`+ fills with
|
||
near-duplicates and callers end up looking up codes that all mean "something went wrong". The
|
||
generic four exist to absorb most cases; if domain allocations outpace them, the grain is wrong.
|
||
2. **The `NO_INPUT` / `UNAVAILABLE` split will be got wrong.** An author will reach for `NO_INPUT`
|
||
rather than pay for the proof. That is a lint target that does not exist yet, and I do not have a
|
||
design for one that is not itself a fingerprint-of-the-last-bug detector.
|
||
3. **`hooks/` is 71% of the work** and every hook edit doubles through `hooks/dist/**` via the build
|
||
seam.
|
||
4. **Retiring `2` from `ui-safety-gate`, `api-coverage`, `assumption-delta`, `teams-status` and the
|
||
three scanners changes observable behavior** for anything that pattern-matched on `2`.
|
||
5. **Under `v1`, most phases deliver no observable change.** The payoff is at the major. That is the
|
||
price of not breaking ADR-2980's population.
|
||
|
||
**Guard ledger** (per [ADR-3473](3473-enforcement-by-construction.md) §B6 — net count must fall):
|
||
**retired** the two SMELL oracles and their four baseline entries, the 20-line
|
||
`n/no-process-exit: 'off'` exemption block, and the duplicated `scripts/lib/cli-exit.cjs`;
|
||
**added** one rule (`local/require-registered-exit`) plus the registry's own `--check`. Net **−2**.
|
||
|
||
> **Amendment — 2026-08-28: the ledger above is superseded (#3913).**
|
||
>
|
||
> Three of its terms did not survive contact with the code, and the net it states was never
|
||
> achievable. It is left in place unedited so the drift is legible; the measured ledger is below.
|
||
>
|
||
> 1. **`scripts/lib/cli-exit.cjs` was not deleted.** Phase 0 changed it from a duplicated module
|
||
> into a *generated* one, so the line retires a copy that still exists. The epic body was
|
||
> corrected to net **−1** at the time; this ADR's line was not.
|
||
> 2. **"their four baseline entries" was five** — four `soft-error-exit-zero` and one
|
||
> `untyped-success`, counted directly off `tests/qa/smell-baseline.json`. Issue #3913 repeated
|
||
> the figure of four, and so did the first recon pass of this phase. This is the second
|
||
> documented count in this epic to drift, after [ADR-2980](2980-payload-carried-error-is-a-degraded-result.md)'s
|
||
> 60-versus-64 `output({error})` sites.
|
||
> 3. **Only one of the two oracles was retired.** `soft-error-exit-zero` is deleted: its condition
|
||
> is now *declared* by the exit contract (`output({error})` → `DEGRADED`, 0 under v1 and 80
|
||
> under v2), so an inert SMELL restating it is ledger inflation. `untyped-success` is **not**
|
||
> deleted — it asserts that a `KIND.PROSE` command exposes no typed surface, which has nothing
|
||
> to do with exit codes, and neither the registry nor `local/require-registered-exit` replaces
|
||
> it. The clause this ADR and #3913 both rely on — *"the registry and the ESLint rule enforce
|
||
> the same property by construction"* — is simply false for it. Rather than drop the property,
|
||
> it was **promoted from SMELL to VIOLATION**, so it can now fail a build, which it never could
|
||
> before (`runOracles`'s `get failed()` returns `violations` only).
|
||
>
|
||
> **Measured ledger for the epic as delivered** (corrected again by #3914's audit — the version
|
||
> first written here was wrong twice over, see below):
|
||
>
|
||
> | | |
|
||
> |---|--:|
|
||
> | −1 oracle (`soft-error-exit-zero`) | −1 |
|
||
> | ~~−1 `n/no-process-exit: 'off'` hooks exemption block~~ — **0, see below** | 0 |
|
||
> | +1 rule (`local/require-registered-exit`) | +1 |
|
||
> | +4 `lint:generated-sync --check` arms: `gen-scripts-cli-exit`, `gen-hooks-cli-exit`, `gen-exit-code-registry`, `gen-exit-code-docs` | +4 |
|
||
> | **Net** | **+4** |
|
||
>
|
||
> **The exemption-block term was a fourth error, corrected here.** Every prior version of this ledger
|
||
> counted removing the `n/no-process-exit: 'off'` entry from the hooks block as **−1**. Measured:
|
||
> `n/no-process-exit` is **not registered at all** on `hooks/**` — `ESLint.calculateConfigForFile`
|
||
> returns `undefined` for it there, not `error`. No broader block sets it globally. So the `'off'`
|
||
> entry was overriding nothing, and removing it changed no enforcement whatsoever. It is a **no-op
|
||
> removal, not a guard removal**, and counting it as −1 is the same category error as counting
|
||
> baseline acknowledgement entries: a thing that is not a guard, in guard units.
|
||
>
|
||
> It is also **misattributed** — that block came down in `d98b55562` (#3910), already on `next`
|
||
> before #3914 existed.
|
||
>
|
||
> **The epic ADDED four guards. It did not remove one.** That is the honest result, and it is worth
|
||
> stating without softening: an epic whose thesis is consolidation ended with a larger guard surface
|
||
> than it started with. The additions are defensible individually — a rule and four drift checks that
|
||
> did not exist — but "net −1" was never true.
|
||
>
|
||
> One further guard changed strength rather than count: `untyped-success` SMELL → VIOLATION. Two
|
||
> mis-scoped oracles were corrected in passing (`routing-validity`, `value-hygiene`) — see #3913.
|
||
>
|
||
> **Two corrections to what this paragraph previously claimed, both mine:**
|
||
>
|
||
> 1. It said **"Net −1 by count"** above a term list reading `−1 −1 +1 +1 +1`. That sums to **+1**.
|
||
> A plain arithmetic error, in the paragraph immediately below the sentence arguing that an ADR
|
||
> about honest accounting must not pad its own ledger.
|
||
> 2. The term list also **omitted two of the four `--check` arms** (`gen-scripts-cli-exit` from P0
|
||
> and `gen-hooks-cli-exit` from P7), which is what turns +1 into the real +4.
|
||
>
|
||
> Recorded rather than quietly rewritten, because the failure this epic exists to close is a written
|
||
> claim nobody checked against the thing it describes — and this ledger has now been wrong four
|
||
> times, three of them mine: the original "Net −2"; the "Net −1" that replaced it above a term list
|
||
> summing to +1; and the "Net +3" that replaced that, stated before the exemption-block term above
|
||
> was re-checked and found to be a no-op removal rather than a −1. Each of the four was found by
|
||
> someone checking the ledger against the tree, not by re-reading the ledger itself.
|
||
>
|
||
> The −4 first written here counted the five pruned `smell-baseline.json` entries in the same units
|
||
> as oracles and lint rules. They are not guards — they are *acknowledgements* that a guard fired.
|
||
> Removing them strengthens the surface rather than removing anything from it, and folding them into
|
||
> the count inflates the negative by five. An ADR about honest accounting should not pad its own
|
||
> ledger, so the entries are recorded as what they are and excluded from the count.
|
||
>
|
||
> **Two limits on how strong this is, stated rather than implied:**
|
||
>
|
||
> - **`soft-error-exit-zero`'s condition is now declared, not enforced.** No oracle references
|
||
> `KIND.SOFT_ERROR` after its deletion, and the corpus still contains four soft-error steps that
|
||
> nothing observes. Under `v1` — the default — those sites still exit `0`. The condition is
|
||
> modelled in the contract and projects to `80` only under the opt-in `v2`. That is a deliberate
|
||
> trade, not an equivalent replacement, and "the registry enforces the same property" would be too
|
||
> strong a claim for it.
|
||
> - **`untyped-success` is promoted against a corpus that no longer exercises the case.** Its
|
||
> `KIND.PROSE` count reached 0 because the sole prose-producing step was changed to
|
||
> `smart-entry --json`. The promotion is real and the guard now fails a build — but what it
|
||
> currently guards is that no *new* prose-only step appears, not that an existing one is caught.
|
||
>
|
||
> **The epic's criterion 5 ("retire the predecessor guard the new rule supersedes") does not apply
|
||
> to `n/no-process-exit` and `local/require-registered-exit`.** #3914 originally turned
|
||
> `n/no-process-exit` off on `gsd-core/bin/**/*.cjs` and `scripts/**/*.cjs`, on the premise that
|
||
> `local/require-registered-exit` was a strict superset there. It is not: the two rules are
|
||
> **complementary**, each catching constructs the other misses. Measured directly (successor vs.
|
||
> predecessor flag counts): `process['exit'](1)` and `process?.[k]?.(1)` are successor-only (a
|
||
> string-literal or optional-chained computed property the predecessor's Identifier-only property
|
||
> match never reaches); a function parameter, a destructured binding, a reassign-to-the-same-value
|
||
> binding, a `for`-of loop variable, a `var` redeclaration, a catch param, and an undeclared global
|
||
> — all literally named `exit` — are predecessor-only (the predecessor's esquery selector matches on
|
||
> the AST node's own `.name`, not a resolved value, while the successor only resolves a computed
|
||
> property through a single never-reassigned string-literal initializer). Retiring either rule on
|
||
> that shared surface loses real coverage. Criterion 5 assumed a replacement relationship that does
|
||
> not exist for this pair; both rules remain registered everywhere they were before.
|
||
|
||
## Revisit if
|
||
|
||
- Domain allocations exceed roughly a dozen. That means the generic four are wrong, not that the
|
||
band is too small.
|
||
- A runtime we adopt collides with `64`–`125`. The projection is one generated table.
|
||
- `NO_INPUT` proves undecidable at more than a couple of sites — the distinction would be wrong, not
|
||
the sites lazy.
|
||
|
||
## References
|
||
|
||
- `src/io.cts:144-168` (`output`, touches no exit code), `:180-215` (`ERROR_REASON`), `:246-254` (`error`)
|
||
- `src/cli-exit.cts` — the seam being deepened; `scripts/lib/cli-exit.cjs` — its drifted duplicate
|
||
- `src/ui-safety-gate.cts:137,149,154` — the convention stated in a comment
|
||
- `src/api-coverage.cts:977,981`, `src/assumption-delta.cts:248,253`, `src/teams-status.cts:88`
|
||
- `scripts/secret-scan.sh:238-239,330-333`; `base64-scan.sh:323-326`; `prompt-injection-scan.sh:252-255`
|
||
- `capabilities/ai-integration/fragments/api-coverage-plan-pre.md:25,114`;
|
||
`capabilities/assumption-delta/fragments/plan-pre.md:14` — fabricated verdicts
|
||
- `gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md:19` — the honest form, already in-tree
|
||
- `eslint.config.mjs:239,348-404,478,563-582`
|
||
- `tests/qa/oracles.cjs:574-619`, `tests/qa/smell-baseline.json`
|
||
- Node.js docs → *Exit codes* (1–13 reserved; 128+N signals); `sysexits(3)` — band borrowed, interface not adopted
|
||
- [#3838](https://github.com/open-gsd/gsd-core/issues/3838) — the filed hook fail-open instance
|