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

@@ -29,6 +29,7 @@ const { runNode } = require('./helpers/process-seam.cjs');
const { createTempDir, cleanup } = require('./helpers.cjs');
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const fc = require('./helpers/fast-check-setup.cjs');
const { splitLines } = require('../gsd-core/bin/lib/text-lines.cjs');
const REPO_ROOT = path.resolve(__dirname, '..');
const GEN_SCRIPT = path.join(REPO_ROOT, 'scripts', 'gen-exit-code-registry.cjs');
@@ -205,6 +206,7 @@ describe('gen-exit-code-registry: REASON', () => {
'OK', 'DRIFTED', 'USAGE', 'MISSING_DECLARATION', 'MALFORMED_DECLARATION',
'NOT_AN_ARRAY', 'EMPTY_DECLARATION', 'INVALID_ENTRY', 'DUPLICATE_CODE',
'DUPLICATE_NAME', 'RESERVED_CODE', 'FORBIDDEN_OWNER', 'MISSING_ARTIFACT',
'INVALID_CHARACTERS',
];
test('is frozen', () => {
@@ -302,6 +304,49 @@ describe('gen-exit-code-registry: required string fields', () => {
});
});
// #3913 P9 SEC-3: a declaration string field carrying a `|`, CR, LF, or other
// control character breaks the Markdown table gen-exit-code-docs.cjs
// interpolates it into (a `|` splits the row; a `\n` can forge an entire
// extra row, including a fake Markdown heading). Rejected at the shared
// validator (validateEntry), not the renderer, so both generators inherit
// the fix.
describe('gen-exit-code-registry: forbidden characters in declaration string fields (#3913 P9 SEC-3)', () => {
const fields = ['meaning', 'owner', 'authorizedBy'];
const badValues = [
['a literal pipe', 'contains | a pipe'],
['a CR', 'contains\ra CR'],
['a LF', 'contains\na LF'],
['a CRLF', 'contains\r\na CRLF'],
['a NUL byte', 'contains\x00a NUL'],
['a DEL byte', 'contains\x7fa DEL'],
];
for (const field of fields) {
for (const [label, bad] of badValues) {
test(`"${field}" containing ${label} -> INVALID_CHARACTERS`, () => {
// F3a: failing-first against the pre-fix validator, this entry would
// have passed validateEntry entirely (no character check existed).
const entry = makeEntry({ [field]: bad });
const result = generator.validateEntry(entry, 0);
assert.equal(result.ok, false);
assert.equal(result.reason, generator.REASON.INVALID_CHARACTERS);
});
}
}
test('a clean value with none of the forbidden characters is accepted', () => {
const result = generator.validateEntry(makeEntry({ meaning: 'a perfectly normal meaning, with commas' }), 0);
assert.deepEqual(result, { ok: true });
});
test('hasForbiddenDeclarationChar is the exact predicate validateEntry uses (no drift)', () => {
assert.equal(generator.hasForbiddenDeclarationChar('clean'), false);
assert.equal(generator.hasForbiddenDeclarationChar('a | pipe'), true);
assert.equal(generator.hasForbiddenDeclarationChar('a\nnewline'), true);
assert.equal(generator.hasForbiddenDeclarationChar('a\rreturn'), true);
});
});
describe('gen-exit-code-registry: cross-entry invariants', () => {
test('duplicate code -> DUPLICATE_CODE, context carries the code and both names', () => {
const entries = [
@@ -639,6 +684,10 @@ describe('gen-exit-code-registry: CLI positive controls for the ten guard rows',
['code "64" (string)', () => [{ ...validBase(), code: '64', name: 'STRCODE' }], 'INVALID_ENTRY'],
['missing meaning', () => [{ code: 64, name: 'NO_MEANING', owner: 'generic', authorizedBy: 'ADR-3889' }], 'INVALID_ENTRY'],
['[] empty declaration', () => [], 'EMPTY_DECLARATION'],
// F3a: a `meaning` carrying a `|` or a newline must be REJECTED with a non-zero exit —
// failing-first against the pre-fix validator (#3913 P9 SEC-3).
['meaning with a pipe', () => [{ ...validBase(), code: 64, name: 'PIPE_MEANING', meaning: 'a | pipe breaks the table' }], 'INVALID_CHARACTERS'],
['meaning with a newline', () => [{ ...validBase(), code: 64, name: 'NEWLINE_MEANING', meaning: 'a\nforged heading' }], 'INVALID_CHARACTERS'],
];
for (const [label, buildEntries, expectedReasonKey] of rows) {
@@ -668,6 +717,291 @@ describe('gen-exit-code-registry: CLI positive controls for the ten guard rows',
});
});
// ── gen-exit-code-docs.cjs: docs/reference/exit-codes.md (P9, #3913, matrix F) ──
describe('gen-exit-code-docs: generated exit-code reference page (matrix F1-F4)', () => {
const DOCS_GEN_SCRIPT = path.join(REPO_ROOT, 'scripts', 'gen-exit-code-docs.cjs');
const REAL_DOC_PATH = path.join(REPO_ROOT, 'docs', 'reference', 'exit-codes.md');
const README_PATH = path.join(REPO_ROOT, 'docs', 'README.md');
const docsGenerator = require(DOCS_GEN_SCRIPT);
function runDocsGen(args, opts = {}) {
return runNode([DOCS_GEN_SCRIPT, ...args], { timeoutMs: PROBE_TIMEOUT_MS, ...opts });
}
// F1: every code in the registry appears in the generated page with its
// name — asserted over the ENUMERATED registry, so a newly allocated code
// fails until documented.
test('F1: every registered code appears in the generated page with its name', () => {
const md = fs.readFileSync(REAL_DOC_PATH, 'utf8');
assert.ok(registry.EXIT_CODES.length > 0, 'precondition: the registry is non-empty');
for (const entry of registry.EXIT_CODES) {
const row = md.split(/\r?\n/).find((l) => l.startsWith(`| ${entry.code} |`));
assert.ok(row, `code ${entry.code} (${entry.name}) must appear as a row in the generated page`);
assert.ok(row.includes(`\`${entry.name}\``), `code ${entry.code}'s row must carry its name ${entry.name}`);
}
});
// F2: `--check` exits non-zero when the committed page diverges from a
// fresh render. Proven by mutating a COPY (via --declaration/--out
// redirection to a tmpdir, never the real committed file — test files in
// this repo run in parallel) and observing the non-zero exit — not by
// reading the code.
describe('F2: --check catches drift (mutate-then-observe, not read-the-code)', () => {
let tmpDir;
before(() => {
tmpDir = createTempDir('gsd-exit-code-docs-f2-');
});
after(() => {
cleanup(tmpDir);
});
test('a freshly generated copy of the real committed page passes --check', () => {
const decl = path.join(tmpDir, 'decl.json');
fs.copyFileSync(REAL_DECLARATION_PATH, decl);
const out = path.join(tmpDir, 'exit-codes.md');
const write = runDocsGen(['--write', '--declaration', decl, '--out', out]);
assert.equal(write.exitCode, 0, write.stderr);
const check = runDocsGen(['--check', '--declaration', decl, '--out', out]);
assert.equal(check.exitCode, 0, check.stderr);
});
test('mutating the generated page then running --check exits non-zero', () => {
const decl = path.join(tmpDir, 'decl-mutate.json');
fs.copyFileSync(REAL_DECLARATION_PATH, decl);
const out = path.join(tmpDir, 'exit-codes-mutate.md');
assert.equal(runDocsGen(['--write', '--declaration', decl, '--out', out]).exitCode, 0);
// Mutate the generated copy — e.g. a hand-edit drifting from the
// generator's own output — then observe the ACTUAL exit code.
fs.appendFileSync(out, '\n<!-- hand-edited, drifts from generated content -->\n');
const check = runDocsGen(['--check', '--declaration', decl, '--out', out]);
assert.notEqual(check.exitCode, 0, 'a drifted page must fail --check, not pass it');
});
});
// F3: `--check` exits 0 on the committed tree (the generator is idempotent).
test('F3: --check exits 0 against the real committed page', () => {
const result = runDocsGen(['--check']);
assert.equal(result.exitCode, 0, result.stderr);
});
// F4: the page is reachable from docs/README.md.
test('F4: docs/README.md links to the generated exit-code reference page', () => {
const readme = fs.readFileSync(README_PATH, 'utf8');
assert.ok(readme.includes('reference/exit-codes.md'), 'docs/README.md must index docs/reference/exit-codes.md');
});
// Real parity assertion (replaces a near-tautological pair of `.includes()`
// checks — `md.includes('3')` matches "ADR-3889", not the Node-reserved
// band). Enumerates the FULL scanned code space and asserts the rendered
// "Reserved bands" table's ranges and row grouping are exactly what
// computeBandRanges/classifyBand — themselves composed only from
// isAllocatableCode/bandFor — return today. This fails the moment the
// generator's band table is re-hardcoded as a literal that stops tracking
// gen-exit-code-registry.cjs's own band logic.
test('parity: every rendered band range and status is DERIVED from isAllocatableCode/bandFor, not retyped', () => {
const md = fs.readFileSync(REAL_DOC_PATH, 'utf8');
const ranges = generator.computeBandRanges(500);
for (const { category, ranges: subRanges } of ranges) {
for (const range of subRanges) {
// Every individual code in every derived range must actually
// classify into that category right now — i.e. the derivation is
// self-consistent over the enumerated space, not just internally
// coherent by construction.
for (let code = range.start; code <= range.end; code += 1) {
assert.equal(generator.classifyBand(code), category, `code ${code} must classify as ${category}`);
}
// And the rendered page must actually contain a band-table token
// for this range's boundary (its start or its formatted label),
// so a hand-edited/stale table (the #1 defect: a literal that
// never changed when the band logic did) is caught here too.
const label = range.openEnded ? `${range.start}+` : (range.start === range.end ? `${range.start}` : `${range.start}\`–\`${range.end}`);
assert.ok(md.includes(`\`${label}\``), `rendered page must contain the derived band label for ${category}: \`${label}\``);
}
}
});
// Regression for #1: proves the band table is genuinely DERIVED from
// isAllocatableCode/bandFor rather than a second hand-typed literal. Widens
// the band logic in a SCRATCH COPY of both generator scripts (never the
// real committed files) so that 14-63 becomes allocatable, then runs
// `--check` against the REAL committed page with that widened logic. If
// the band table were still hand-typed (the pre-fix defect), --check would
// stay green because the template string never changed; with the fix, the
// freshly-rendered table for the widened logic diverges from the committed
// page's band table and --check must exit non-zero.
test('band table is DERIVED: widening isAllocatableCode without regenerating fails --check (regression, #1)', () => {
const tmp = createTempDir('gsd-exit-code-docs-band-parity-');
try {
const registrySrc = fs.readFileSync(GEN_SCRIPT, 'utf8');
// Widen the SAME BANDS table isAllocatableCode/bandFor/classifyBand are
// all derived from — this is the realistic "someone widens a band"
// edit the reviewer demonstrated, not a change to a derived function.
const NEEDLE = "{ category: 'generic', allocatable: true, test: (code) => code >= 64 && code <= 78 }";
assert.ok(registrySrc.includes(NEEDLE), 'precondition: gen-exit-code-registry.cjs must still contain the BANDS entry this test widens');
const widened = registrySrc.replace(
NEEDLE,
"{ category: 'generic', allocatable: true, test: (code) => (code >= 64 && code <= 78) || (code >= 14 && code <= 63) }",
);
assert.notEqual(widened, registrySrc, 'precondition: the widen replacement must actually change the source');
fs.writeFileSync(path.join(tmp, 'gen-exit-code-registry.cjs'), widened, 'utf8');
const docsSrc = fs.readFileSync(DOCS_GEN_SCRIPT, 'utf8');
fs.writeFileSync(path.join(tmp, 'gen-exit-code-docs.cjs'), docsSrc, 'utf8');
fs.mkdirSync(path.join(tmp, 'lib'), { recursive: true });
fs.copyFileSync(path.join(REPO_ROOT, 'scripts', 'lib', 'cli-exit.cjs'), path.join(tmp, 'lib', 'cli-exit.cjs'));
const check = runNode([path.join(tmp, 'gen-exit-code-docs.cjs'), '--check'], { timeoutMs: PROBE_TIMEOUT_MS });
assert.notEqual(check.exitCode, 0, 'a widened band (14-63 admitted) must invalidate the committed page — if this passes, the band table is a hand-typed literal again, not derived from isAllocatableCode/bandFor');
} finally {
cleanup(tmp);
}
});
// T1 (#3913 P9 review follow-up): a BANDS category present in neither
// CATEGORY_ROW_ORDER nor CATEGORY_MERGE_INTO must make generation THROW —
// not silently omit the new band from the rendered table while --check
// stays green (the exact defect assertBandCategoriesConsistent in
// gen-exit-code-docs.cjs closes). Driven via a scratch copy of both
// generator scripts, mirroring the widen-a-band regression test above —
// never the real committed files.
// Every T1-T3 scratch copy needs the same three sibling files
// gen-exit-code-docs.cjs's own `require`s resolve relative to itself:
// gen-exit-code-registry.cjs (source of BANDS), lib/cli-exit.cjs, and
// lib/exit-code-registry.cjs (cli-exit.cjs's own dependency). Loading the
// scratch module directly (never via `--check`/`--write`) and calling
// `loadEntries`/`buildDoc` against the REAL committed declaration isolates
// the assertion under test from the doc-page-drift machinery entirely.
function scaffoldScratchDocsModule(tmp, docsSrc) {
fs.mkdirSync(path.join(tmp, 'lib'), { recursive: true });
fs.copyFileSync(path.join(REPO_ROOT, 'scripts', 'lib', 'cli-exit.cjs'), path.join(tmp, 'lib', 'cli-exit.cjs'));
fs.copyFileSync(path.join(REPO_ROOT, 'scripts', 'lib', 'exit-code-registry.cjs'), path.join(tmp, 'lib', 'exit-code-registry.cjs'));
const entryPath = path.join(tmp, 'docsgen-entry.cjs');
fs.writeFileSync(entryPath, docsSrc, 'utf8');
return entryPath;
}
test('T1: a BANDS category with no CATEGORY_ROW_ORDER/CATEGORY_MERGE_INTO entry throws generation, not a silent omission', () => {
const tmp = createTempDir('gsd-exit-code-docs-p9cat-');
try {
const registrySrc = fs.readFileSync(GEN_SCRIPT, 'utf8');
const NEEDLE = " { category: 'shell-signal', allocatable: false, test: (code) => code >= 126 },\n]);";
assert.ok(registrySrc.includes(NEEDLE), 'precondition: BANDS closing entry must still match');
const mutated = registrySrc.replace(
NEEDLE,
" { category: 'shell-signal', allocatable: false, test: (code) => code >= 126 },\n"
+ " { category: 'brand-new-band', allocatable: true, test: (code) => code === 40 },\n]);",
);
assert.notEqual(mutated, registrySrc, 'precondition: the BANDS injection must actually change the source');
fs.writeFileSync(path.join(tmp, 'gen-exit-code-registry.cjs'), mutated, 'utf8');
const entryPath = scaffoldScratchDocsModule(tmp, fs.readFileSync(DOCS_GEN_SCRIPT, 'utf8'));
const scratchDocs = require(entryPath);
const entries = scratchDocs.loadEntries(REAL_DECLARATION_PATH);
// Pre-fix (RED): buildDoc succeeds and silently omits the new band —
// it never appears anywhere in the rendered table. Post-fix (GREEN):
// buildDoc throws naming the unaccounted category.
assert.throws(
() => scratchDocs.buildDoc(entries),
/fail_band_category_unaccounted:.*brand-new-band/,
'an unaccounted BANDS category must fail generation loudly, not render a table that silently omits it',
);
} finally {
cleanup(tmp);
}
});
// T2: a CATEGORY_ROW_ORDER entry with no BAND_PROSE entry must throw rather
// than rendering the literal string "undefined" into the table.
test('T2: a CATEGORY_ROW_ORDER entry with no BAND_PROSE entry throws rather than rendering undefined', () => {
const tmp = createTempDir('gsd-exit-code-docs-p9cat-');
try {
fs.copyFileSync(GEN_SCRIPT, path.join(tmp, 'gen-exit-code-registry.cjs'));
const docsSrc = fs.readFileSync(DOCS_GEN_SCRIPT, 'utf8');
const NEEDLE = " domain: '**Domain band.**";
assert.ok(docsSrc.includes(NEEDLE), 'precondition: BAND_PROSE.domain entry must still match');
// Delete the `domain` prose entry entirely while leaving `domain` in
// CATEGORY_ROW_ORDER — the exact stale-in-one-list-not-the-other shape.
// Line-filtered via splitLines (not a bare-`\n` regex) so this stays
// correct under Windows git-autocrlf CRLF line endings too.
const mutated = splitLines(docsSrc).filter((line) => !line.startsWith(NEEDLE)).join('\n');
assert.notEqual(mutated, docsSrc, 'precondition: the BAND_PROSE deletion must actually change the source');
assert.ok(!mutated.includes("domain: '**Domain band.**"), 'precondition: BAND_PROSE.domain must actually be gone');
const entryPath = scaffoldScratchDocsModule(tmp, mutated);
const scratchDocs = require(entryPath);
const entries = scratchDocs.loadEntries(REAL_DECLARATION_PATH);
assert.throws(
() => scratchDocs.buildDoc(entries),
/fail_band_prose_missing:.*domain/,
'a CATEGORY_ROW_ORDER entry missing from BAND_PROSE must fail generation loudly, not render "undefined"',
);
} finally {
cleanup(tmp);
}
});
// T3: a stale BAND_PROSE category that no BANDS entry (directly, or via
// CATEGORY_MERGE_INTO) produces must throw — dead prose for a band that no
// longer exists is the same drift in the other direction.
test('T3: a stale BAND_PROSE category no BANDS entry produces throws', () => {
const tmp = createTempDir('gsd-exit-code-docs-p9cat-');
try {
fs.copyFileSync(GEN_SCRIPT, path.join(tmp, 'gen-exit-code-registry.cjs'));
const docsSrc = fs.readFileSync(DOCS_GEN_SCRIPT, 'utf8');
const NEEDLE = "const BAND_PROSE = Object.freeze({\n";
assert.ok(docsSrc.includes(NEEDLE), 'precondition: BAND_PROSE opening must still match');
const mutated = docsSrc.replace(
NEEDLE,
`${NEEDLE} 'long-retired-band': 'This band was retired and no BANDS entry produces it any more.',\n`,
);
assert.notEqual(mutated, docsSrc, 'precondition: the stale BAND_PROSE injection must actually change the source');
const entryPath = scaffoldScratchDocsModule(tmp, mutated);
const scratchDocs = require(entryPath);
const entries = scratchDocs.loadEntries(REAL_DECLARATION_PATH);
assert.throws(
() => scratchDocs.buildDoc(entries),
/fail_band_category_stale:.*long-retired-band/,
'a stale BAND_PROSE category with no producing BANDS entry must fail generation loudly',
);
} finally {
cleanup(tmp);
}
});
// T4 (positive control): the REAL, unmodified configuration renders all six
// rows with no literal "undefined" anywhere in the page. Without this, a
// fix that throws unconditionally (rather than only on genuine drift) would
// still pass T1-T3 by accident.
test('T4: the real unmodified configuration renders all six band rows with no "undefined" in the page', () => {
const result = runDocsGen(['--check']);
assert.equal(result.exitCode, 0, result.stderr);
const md = fs.readFileSync(REAL_DOC_PATH, 'utf8');
assert.ok(!md.includes('undefined'), 'the generated page must never contain the literal string "undefined"');
const bandTableSection = md.slice(md.indexOf('| Band | Meaning |'), md.indexOf('## The v1/v2 exit contract'));
// `|---|---|` starts with `|-`, not `| `, so it is already excluded by
// this pattern — only the header line (`| Band | Meaning |`) needs
// subtracting to leave just the data rows.
const rowCount = (bandTableSection.match(/^\| /gm) || []).length - 1;
assert.equal(rowCount, 6, 'the Reserved bands table must render exactly six rows (free, hook-only, node-reserved, outside-every-band, generic, domain)');
});
// Forward guard, not a regression test: buildDoc has no source of
// non-determinism (no Date.now/Math.random/env read), so this cannot
// currently fail — it exists to catch a FUTURE change that introduces one.
test('forward guard: buildDoc stays pure if a future change adds a non-deterministic input', () => {
const once = docsGenerator.buildDoc(registry.EXIT_CODES);
const twice = docsGenerator.buildDoc(registry.EXIT_CODES);
assert.equal(once, twice);
});
});
// ── fast-check properties ─────────────────────────────────────────────────────
describe('exit-code-registry: fast-check properties', () => {
test('nameForExitCode(exitCodeFor(name)) round-trips for every registered name', () => {