enhance(#3913): docs, and the guards come down (#3994)

ADR-3889 terminal phase. Generated docs/reference/exit-codes.md from the exit-code declaration with a --check drift arm; deleted the inert soft-error-exit-zero oracle; promoted untyped-success from SMELL to VIOLATION so it can fail a build; pruned all 5 smell-baseline entries.

Fixed inline: two mis-scoped oracles (routing-validity, value-hygiene), a second source behind the band table, unescaped declaration strings reaching Markdown, and a pre-existing Windows 8.3 short-name path-comparison defect.

Guard ledger corrected from a claimed net -4 to a measured net -1.

Closes #3913
This commit is contained in:
Tom Boucher
2026-08-28 13:10:20 -04:00
committed by GitHub
parent c3e667df34
commit 12f9d1d9a0
16 changed files with 1517 additions and 152 deletions

View File

@@ -0,0 +1,318 @@
#!/usr/bin/env node
'use strict';
/**
* gen-exit-code-docs.cjs — ADR-3889 (#3913), the terminal phase of the exit-
* code-registry epic.
*
* Generates docs/reference/exit-codes.md FROM the exit-code declaration
* (gsd-core/bin/shared/exit-codes.json), so the human-facing reference page
* can never drift from the actual registered codes — the same allocator-less
* failure mode this epic exists to close, now closed for the doc surface too.
* Modelled directly on scripts/gen-capability-matrix.cjs ->
* docs/reference/capability-matrix.md: same --check/--write/stdout modes,
* same "generated, do not edit" banner convention, same normalize-then-
* byte-compare drift check.
*
* The declaration JSON is read directly (not the compiled
* gsd-core/bin/lib/exit-code-registry.cjs artifact, which is gitignored tsc
* output) so this generator — like scripts/gen-exit-code-registry.cjs itself —
* works on an unbuilt clone. The "Reserved bands" table below is DERIVED,
* not retyped: its ranges and allocatable/reserved status come from
* `classifyBand`/`computeBandRanges`, which are themselves composed
* entirely from `isAllocatableCode`/`bandFor` in
* scripts/gen-exit-code-registry.cjs (all four imported below). Only the
* per-band RATIONALE prose (BAND_PROSE) is hand-authored; widening or
* narrowing a band in gen-exit-code-registry.cjs changes the ranges this
* page renders, with no second literal to keep in sync.
*
* Usage:
* node scripts/gen-exit-code-docs.cjs # print to stdout
* node scripts/gen-exit-code-docs.cjs --write # write the committed file
* node scripts/gen-exit-code-docs.cjs --check # exit 1 if the committed file is stale
* node scripts/gen-exit-code-docs.cjs --declaration <path> --out <path> # override for tests
*/
const fs = require('fs');
const path = require('path');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const {
DEFAULT_DECLARATION_PATH,
loadDeclaration,
validateEntries,
computeBandRanges,
BANDS,
} = require('./gen-exit-code-registry.cjs');
const ROOT = path.resolve(__dirname, '..');
const DOC_PATH = path.join(ROOT, 'docs', 'reference', 'exit-codes.md');
/**
* How far above the highest defined band (125, the top of 'domain') to scan
* when deriving band ranges. Large enough to prove the 'shell-signal'
* (126+) run is genuinely open-ended (classifyBand is constant well past
* it), not just an artifact of stopping the scan too early.
*/
const BAND_SCAN_MAX = 500;
/** Hand-authored rationale prose per band category — the only part of the
* "Reserved bands" table that stays authored; the ranges themselves are
* derived by computeBandRanges from isAllocatableCode/bandFor. */
const BAND_PROSE = Object.freeze({
free: '**Free — never allocatable.** `0` is the universal "succeeded" convention and `1` is the universal "failed, no further detail" convention across nearly every CLI ecosystem. Registering either here would collide with that universal meaning instead of adding a distinct, named signal — so the registry leaves both permanently unallocated.',
'hook-only': 'Reserved exclusively to the Claude Code hook-protocol deny (`HOOK_DENY`) — owned by `hook-adapter` and no other module.',
'node-reserved': '**Node-reserved.** Node.js itself assigns meaning to this range (e.g. internal JavaScript errors, fatal exceptions, invalid argument errors) before a GSD process ever gets a chance to project its own outcome. Allocating one of these would be indistinguishable from a Node-level failure the process never intended to report.',
'outside-every-band': 'Outside every allocatable band — not Node-reserved, but also not opened for GSD use. `126`+ additionally collides with the shell convention for "command not executable" / "signal N" (`128+N`), which a process exit code must never impersonate.',
generic: '**Generic band.** Codes any module may use for caller-facing, non-domain-specific outcomes (bad argv, no input in scope, a missing prerequisite, an internal crash).',
domain: '**Domain band.** Codes reserved for a specific product surface\'s own vocabulary — currently only `gsd-tools`\' `DEGRADED` (a completed run reporting a condition through its payload rather than as a process failure).',
});
/**
* The 'shell-signal' category (126+) is folded into the SAME rendered row
* as 'outside-every-band' (both read "not opened for GSD use" to a reader —
* the original hand-authored table merged them into one row, and the prose
* above documents the 126+ collision inline). Every other category gets its
* own row, in this fixed display order.
*/
const CATEGORY_ROW_ORDER = ['free', 'hook-only', 'node-reserved', 'outside-every-band', 'generic', 'domain'];
const CATEGORY_MERGE_INTO = Object.freeze({ 'shell-signal': 'outside-every-band' });
/**
* Guard against category-set drift across the three authored lists the
* "Reserved bands" table depends on: BANDS (gen-exit-code-registry.cjs — the
* single source of every category classifyBand can ever return),
* CATEGORY_ROW_ORDER (the rendered row order here), and BAND_PROSE (the
* hand-authored rationale text per row). Nothing type-checks that these three
* agree, so adding a BANDS category with no row/prose entry silently drops it
* from the table, and a row with no prose entry renders the literal string
* "undefined" — see #3913 P9 review finding (unchecked `BAND_PROSE[category]`
* lookup) and its root cause: three authored lists, only one of them derived.
* Throws loudly the first time any of the three sets diverge instead of
* silently omitting a row or rendering `undefined`.
*/
function assertBandCategoriesConsistent() {
// Every category classifyBand can ever return: each BANDS entry's own
// category, plus the synthetic 'outside-every-band' residual classifyBand
// falls back to for a code no BANDS entry claims (14-63, 79).
const producedCategories = new Set([...BANDS.map((b) => b.category), 'outside-every-band']);
// 1. Every produced category must resolve — directly, or via
// CATEGORY_MERGE_INTO — to a row this page actually renders. Otherwise
// it is silently dropped from the table (the #3913 P9 finding).
for (const category of producedCategories) {
const rowCategory = CATEGORY_MERGE_INTO[category] || category;
if (!CATEGORY_ROW_ORDER.includes(rowCategory)) {
throw new ExitError(1, `fail_band_category_unaccounted: BANDS category "${category}" resolves to row ` +
`"${rowCategory}", which is in neither CATEGORY_ROW_ORDER nor a CATEGORY_MERGE_INTO target — add it to ` +
'one or the other in scripts/gen-exit-code-docs.cjs.');
}
}
// 2. Every rendered row must have hand-authored rationale prose — otherwise
// the table cell literally renders the string "undefined".
for (const category of CATEGORY_ROW_ORDER) {
if (!(category in BAND_PROSE)) {
throw new ExitError(1, `fail_band_prose_missing: CATEGORY_ROW_ORDER category "${category}" has no ` +
'BAND_PROSE entry — it would render the literal string "undefined" in the generated table.');
}
}
// 3. Reverse drift: a CATEGORY_ROW_ORDER or BAND_PROSE entry naming a
// category no BANDS entry (nor the 'outside-every-band' residual)
// produces is stale prose for a band that no longer exists.
const resolvedRowCategories = new Set(
[...producedCategories].map((category) => CATEGORY_MERGE_INTO[category] || category),
);
for (const category of CATEGORY_ROW_ORDER) {
if (!resolvedRowCategories.has(category)) {
throw new ExitError(1, `fail_band_category_stale: CATEGORY_ROW_ORDER names "${category}", which no BANDS ` +
'entry (directly, or via CATEGORY_MERGE_INTO) produces — remove it, or fix the drift between BANDS in ' +
'scripts/gen-exit-code-registry.cjs and CATEGORY_MERGE_INTO here.');
}
}
for (const category of Object.keys(BAND_PROSE)) {
if (!CATEGORY_ROW_ORDER.includes(category)) {
throw new ExitError(1, `fail_band_category_stale: BAND_PROSE has an entry for "${category}", which is not ` +
'in CATEGORY_ROW_ORDER — remove the stale prose, or add the row.');
}
}
}
/** Format one contiguous range as a Markdown-table-cell token. */
function formatRange(range) {
if (range.openEnded) return `\`${range.start}+\``;
if (range.start === range.end) return `\`${range.start}\``;
return `\`${range.start}\`–\`${range.end}\``;
}
/**
* Render the "Reserved bands" table. Ranges come from computeBandRanges
* (itself derived from isAllocatableCode/bandFor); only BAND_PROSE and the
* row grouping/order above are authored.
*/
function renderBandTable() {
assertBandCategoriesConsistent();
const byCategory = new Map();
for (const { category, ranges } of computeBandRanges(BAND_SCAN_MAX)) {
const rowCategory = CATEGORY_MERGE_INTO[category] || category;
if (!byCategory.has(rowCategory)) byCategory.set(rowCategory, []);
byCategory.get(rowCategory).push(...ranges);
}
const rows = CATEGORY_ROW_ORDER.map((category) => {
const ranges = byCategory.get(category) || [];
const label = ranges.map(formatRange).join(', ');
return `| ${label} | ${BAND_PROSE[category]} |`;
});
return ['| Band | Meaning |', '|---|---|', ...rows].join('\n');
}
/** Load + validate the declaration, throwing (loud) on anything malformed — mirrors gen-exit-code-registry.cjs's own gate. */
function loadEntries(declarationPath) {
const loaded = loadDeclaration(declarationPath);
if (!loaded.ok) {
throw new ExitError(1, `${loaded.reason}: ${loaded.message}`);
}
const validated = validateEntries(loaded.entries);
if (!validated.ok) {
throw new ExitError(1, `${validated.reason}: ${validated.message}`);
}
return loaded.entries;
}
function renderRegisteredTable(entries) {
const rows = [...entries]
.sort((a, b) => a.code - b.code)
.map((e) => `| ${e.code} | \`${e.name}\` | ${e.meaning} | \`${e.owner}\` | ${e.authorizedBy} |`);
return [
'| code | name | meaning | owning module | authorized by |',
'|---|---|---|---|---|',
...rows,
].join('\n');
}
function buildDoc(entries) {
const registeredTable = renderRegisteredTable(entries);
const registeredCount = entries.length;
const bandTable = renderBandTable();
return `# Exit code reference
> **Generated file — do not edit by hand.**
> This page is generated from the exit-code declaration
> (\`gsd-core/bin/shared/exit-codes.json\`) by \`scripts/gen-exit-code-docs.cjs\`
> and kept honest by a drift guard in \`npm run lint:generated-sync\` (which runs
> \`node scripts/gen-exit-code-docs.cjs --check\`). Any manual edit is overwritten
> on the next generation run. To register a new code, add an entry to the
> declaration and run \`node scripts/gen-exit-code-registry.cjs --write && node
> scripts/gen-exit-code-docs.cjs --write\`.
See also: [ADR-3889 — one exit-code registry](../adr/3889-process-exit-contract.md) —
[Adopt the v2 exit contract](../how-to/adopt-the-v2-exit-contract.md) —
[JSON error mode](../json-errors.md)
---
## Registered codes (${registeredCount})
Every process-level exit code \`gsd-tools\`, its hooks, and its scripts may terminate
with, by name, meaning, and the module that owns it.
${registeredTable}
---
## Reserved bands
The registered codes above are not chosen freely — each falls inside one of a
fixed set of allocatable bands (ADR-3889 §1). A code outside these bands can
never be registered; validation rejects it before it reaches the tables above.
This is what makes an unfamiliar number in a CI log actionable: look up its
band first, then its registered name if it has one.
${bandTable}
## The v1/v2 exit contract
ADR-3889 §4 adds a **version projection** on top of this registry, not a second
registry: every registered name above projects to the *same* code under both
contract versions, with one deliberate exception — \`DEGRADED\`. Under the
default, backward-compatible \`v1\` contract, a payload-carried error
(\`output({error})\`) still exits \`0\`, exactly as
[ADR-2980](../adr/2980-payload-carried-error-is-a-degraded-result.md) ratified
for the pre-existing call sites that already depended on that behavior — **64**
call sites across 9 modules per that ADR's own amendment (its original text
said ~60). Under the
opt-in \`v2\` contract, the same outcome exits \`80\` (\`DEGRADED\`) instead, so a
caller that wants to branch on the exit code alone — without parsing stdout —
can opt in without breaking every existing consumer. See
[Adopt the v2 exit contract](../how-to/adopt-the-v2-exit-contract.md) for how to
turn this on, and [JSON error mode](../json-errors.md) for the full fault vs.
degraded-result channel taxonomy this registry sits underneath.
`;
}
/** Normalize CRLF→LF + ensure a single trailing newline, for cross-platform compare. */
function normalize(s) {
return s.replace(/\r\n/g, '\n').replace(/\n+$/, '\n');
}
/**
* Parse the small flag set this CLI accepts. `--declaration`/`--out` exist
* only so tests can redirect both the source and the target to a tmpdir
* without ever touching the real committed declaration or doc page — the
* same reason scripts/gen-exit-code-registry.cjs and
* scripts/gen-capability-matrix.cjs's sibling generators take path overrides.
*/
function parseArgs(argv) {
let mode = null;
let declarationPath = null;
let outPath = null;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--check' || arg === '--write') {
mode = arg.slice(2);
} else if (arg === '--declaration') {
declarationPath = argv[++i];
} else if (arg === '--out') {
outPath = argv[++i];
} else {
throw new ExitError(1, `unrecognized argument: ${arg}`);
}
}
return { mode, declarationPath, outPath };
}
function main() {
const { mode, declarationPath, outPath } = parseArgs(process.argv.slice(2));
const docPath = outPath || DOC_PATH;
const entries = loadEntries(declarationPath || DEFAULT_DECLARATION_PATH);
const content = buildDoc(entries);
if (mode === 'check') {
let committed;
try {
committed = fs.readFileSync(docPath, 'utf8');
} catch {
throw new ExitError(1, `${path.relative(ROOT, docPath)} is missing. Run:\n node scripts/gen-exit-code-docs.cjs --write`);
}
if (normalize(committed) !== normalize(content)) {
throw new ExitError(1, `${path.relative(ROOT, docPath)} is stale. Run:\n node scripts/gen-exit-code-docs.cjs --write`);
}
console.log(`${path.relative(ROOT, docPath)} is up to date.`);
return;
}
if (mode === 'write') {
fs.mkdirSync(path.dirname(docPath), { recursive: true });
fs.writeFileSync(docPath, content, 'utf8');
console.log(`Wrote ${path.relative(ROOT, docPath)}`);
return;
}
process.stdout.write(content);
}
if (require.main === module) runMain(main);
module.exports = { buildDoc, loadEntries, DOC_PATH };