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:
@@ -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
|
||||
|
||||
318
scripts/gen-exit-code-docs.cjs
Normal file
318
scripts/gen-exit-code-docs.cjs
Normal 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 };
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user