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

@@ -234,6 +234,9 @@ const DOCS_GUARD_TESTS = {
'docs/reference/host-integration-capability-matrix.md',
],
'tests/execute-phase-wave.test.cjs': ['docs/COMMANDS.md'],
// #3913 (ADR-3889 terminal phase): reads the generated docs/reference/exit-codes.md
// (content invariants, F1/F3) and docs/README.md (F4, the index link).
'tests/exit-code-registry.test.cjs': ['docs/reference/exit-codes.md', 'docs/README.md'],
'tests/external-job-waiting.test.cjs': ['docs/reference/planning-artifacts.md'],
// Rows 8/9 (negative controls) read real docs/registries/eos.json and
// docs/adr/0001-dispatch-policy-module.md and assert on their EXACT

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 };

View File

@@ -95,6 +95,7 @@ const REASON = Object.freeze({
RESERVED_CODE: 'fail_reserved_code',
FORBIDDEN_OWNER: 'fail_forbidden_owner',
MISSING_ARTIFACT: 'fail_missing_artifact',
INVALID_CHARACTERS: 'fail_invalid_characters',
});
const USAGE_MESSAGE = [
@@ -117,6 +118,39 @@ const NAME_RE = /^[A-Z][A-Z0-9_]*$/;
/** Fields every entry must carry as a non-empty, non-whitespace-only string. */
const REQUIRED_STRING_FIELDS = ['meaning', 'owner', 'authorizedBy'];
/**
* Characters forbidden in any declaration string field: a literal pipe `|`
* (the Markdown table cell delimiter `gen-exit-code-docs.cjs`'s
* `renderRegisteredTable` interpolates these fields into — an unescaped `|`
* breaks the row and everything after it lands verbatim in the rendered
* page, including a forged Markdown heading), and any C0 control character
* (`\x00`-`\x1F`, `\x7F`) — which subsumes CR (`\r`) and LF (`\n`), both of
* which would otherwise let a single declaration entry inject an entire
* extra row (or non-table content) into the generated table.
*
* Enforced HERE, at the validator both generators share (`gen-exit-code-registry.cjs`'s
* `validateEntry`, called by `gen-exit-code-docs.cjs`'s `loadEntries`), not
* at the docs renderer — failing closed at the declaration source is
* correct: an escaping fix at render time would let a malformed declaration
* through validation and only cosmetically repair the symptom (#3913 P9
* SEC-3).
*
* Checked via char codes rather than a literal control-character regex
* range — same approach as `scripts/registry-schema.cjs`'s
* `hasDisallowedControlChar` — so this never trips ESLint's `no-control-regex`.
*
* @param {string} v
* @returns {boolean}
*/
function hasForbiddenDeclarationChar(v) {
for (let i = 0; i < v.length; i += 1) {
const code = v.charCodeAt(i);
if (code === 0x7c) return true; // '|'
if (code < 0x20 || code === 0x7f) return true; // C0 control chars (incl. CR/LF/tab) + DEL
}
return false;
}
/**
* Bands, per ADR-3889 §1:
* 0, 1 free (not allocatable here)
@@ -125,26 +159,44 @@ const REQUIRED_STRING_FIELDS = ['meaning', 'owner', 'authorizedBy'];
* 14-63, 79, 126+ outside every band
* 64-78 generic
* 80-125 domain
*
* SINGLE SOURCE for every band boundary in this file: `isAllocatableCode`,
* `bandFor`, and (via scripts/gen-exit-code-docs.cjs's `classifyBand`) the
* generated "Reserved bands" table are ALL derived from this one ordered
* list of `{category, allocatable, test}` predicates — there is no second
* hand-typed range anywhere else. Widening or narrowing a band means editing
* a `test` function here; every consumer (validation, the docs page) picks
* that change up with nothing else to keep in sync.
*/
const BANDS = Object.freeze([
{ category: 'free', allocatable: false, test: (code) => code === 0 || code === 1 },
{ category: 'hook-only', allocatable: true, test: (code) => code === 2 },
{ category: 'node-reserved', allocatable: false, test: (code) => code >= 3 && code <= 13 },
{ category: 'generic', allocatable: true, test: (code) => code >= 64 && code <= 78 },
{ category: 'domain', allocatable: true, test: (code) => code >= 80 && code <= 125 },
{ category: 'shell-signal', allocatable: false, test: (code) => code >= 126 },
]);
/** The band a code falls into, or `null` for the residual "outside every band" gap (14-63, 79) that no BANDS entry above claims. */
function bandEntryFor(code) {
return BANDS.find((band) => band.test(code)) || null;
}
function isAllocatableCode(code) {
if (code === 2) return true;
if (code >= 64 && code <= 78) return true;
if (code >= 80 && code <= 125) return true;
return false;
const band = bandEntryFor(code);
return band !== null && band.allocatable;
}
/**
* Label the non-allocatable band a rejected code falls into, per the same
* range boundaries documented on isAllocatableCode/ADR-3889 §1. Only called
* for codes that already failed isAllocatableCode, so 2 and 64-125 never
* reach here.
* BANDS table isAllocatableCode reads. Only called for codes that already
* failed isAllocatableCode, so an allocatable band's category is never
* returned here.
* @returns {string}
*/
function bandFor(code) {
if (code === 0 || code === 1) return 'free';
if (code >= 3 && code <= 13) return 'node-reserved';
if (code >= 126) return 'shell-signal';
return 'outside-every-band'; // 14-63, 79
const band = bandEntryFor(code);
return band ? band.category : 'outside-every-band'; // 14-63, 79
}
/**
@@ -190,6 +242,15 @@ function validateEntry(entry, index) {
context: { field, index, code, name },
};
}
if (hasForbiddenDeclarationChar(value)) {
return {
ok: false,
reason: REASON.INVALID_CHARACTERS,
message: `entry[${index}] (${name}).${field} must not contain a "|", CR, LF, or other control character ` +
`(these are interpolated into a Markdown table cell by gen-exit-code-docs.cjs), received ${JSON.stringify(value)}`,
context: { field, index, code, name },
};
}
}
if (!isAllocatableCode(code)) {
@@ -737,6 +798,70 @@ function main() {
if (require.main === module) process.exitCode = main();
/**
* Classify a single code into one of the band categories the docs page
* renders a row for. Reads the SAME `bandEntryFor`/BANDS table that
* `isAllocatableCode`/`bandFor` are themselves derived from — there is no
* second, independently-typed band boundary anywhere in this classification,
* so widening or narrowing a band (editing a `test` in BANDS) changes what
* this returns too, with nothing else to keep in sync.
* @param {number} code
* @returns {'free'|'hook-only'|'node-reserved'|'outside-every-band'|'generic'|'domain'|'shell-signal'}
*/
function classifyBand(code) {
const band = bandEntryFor(code);
return band ? band.category : 'outside-every-band'; // 14-63, 79
}
/**
* Scan the code space [0, maxCode] and group it into maximal contiguous
* runs of the same classifyBand() category, in the order those categories
* first appear. The run touching `maxCode` is marked `openEnded: true` for
* any category whose classification never changes past that point
* (currently only 'shell-signal', since bandFor(code >= 126) is constant),
* so the docs renderer can print it as `126+` instead of a false upper
* bound. This is what makes the docs page's band table a DERIVED artifact:
* widening a band in isAllocatableCode/bandFor changes what this function
* returns, which changes the rendered table, with no second literal to
* keep in sync.
* @param {number} maxCode
* @returns {Array<{category:string, ranges:Array<{start:number,end:number,openEnded:boolean}>}>}
*/
function computeBandRanges(maxCode) {
/** @type {Map<string, Array<{start:number,end:number,openEnded:boolean}>>} */
const byCategory = new Map();
const order = [];
let runCategory = null;
let runStart = null;
for (let code = 0; code <= maxCode; code += 1) {
const category = classifyBand(code);
if (category !== runCategory) {
if (runCategory !== null) {
pushRun(byCategory, order, runCategory, runStart, code - 1, false);
}
runCategory = category;
runStart = code;
}
}
// Close the final run. It is open-ended (unbounded above) exactly when its
// category classification is constant for every code beyond maxCode too —
// true today only for 'shell-signal', since bandFor treats every code
// >= 126 identically with no further upper boundary.
const openEnded = classifyBand(maxCode) === classifyBand(maxCode + 1);
pushRun(byCategory, order, runCategory, runStart, maxCode, openEnded);
return order.map((category) => ({ category, ranges: byCategory.get(category) }));
}
function pushRun(byCategory, order, category, start, end, openEnded) {
if (!byCategory.has(category)) {
byCategory.set(category, []);
order.push(category);
}
byCategory.get(category).push({ start, end, openEnded });
}
module.exports = {
REASON,
USAGE_MESSAGE,
@@ -747,8 +872,13 @@ module.exports = {
DEFAULT_DTS_OUTPUT_PATH,
DEFAULT_SH_OUTPUT_PATH,
ENTRY_FIELD_TYPES,
BANDS,
bandEntryFor,
isAllocatableCode,
bandFor,
classifyBand,
computeBandRanges,
hasForbiddenDeclarationChar,
validateEntry,
validateEntries,
loadDeclaration,