diff --git a/.changeset/sturdy-deer-frolic.md b/.changeset/sturdy-deer-frolic.md new file mode 100644 index 000000000..8755d9aa5 --- /dev/null +++ b/.changeset/sturdy-deer-frolic.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 3920 +--- +**Exit codes are now allocated from one registry instead of invented per module** — a generated table records every non-standard exit code with its meaning, owning module and authorizing decision, and the build fails if two modules claim the same number or a code lands in a range Node or the shell reserves. Nothing emits a registered code yet; this is the allocator the following phases draw from. (#3905) diff --git a/CONTEXT.md b/CONTEXT.md index 1656943c8..9fa3ca190 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -520,6 +520,9 @@ An injectable time abstraction accepted as an optional parameter by production c ### Process seam The single subprocess-spawning primitive test code uses (`tests/helpers/process-seam.cjs`, #3055): `runNode` / `runGit` / `runHook`, each returning one discriminated union `{ outcome, exitCode, stdout, stderr, timedOut, signal, killed, code }` where `outcome` is the frozen `OUTCOME` enum (`EXITED` / `KILLED` / `TIMED_OUT` / `BUFFER_OVERFLOW` / `SPAWN_FAILED`). Every call is timeout-bounded — there is no unbounded code path — and nothing throws for a child's exit code, kill, timeout, buffer overflow, or spawn failure; all five are data. KILLED is a child terminated by a signal the seam did not send (a genuine OOM kill): `spawnSync` reports no `error` for that case, so it must be distinguished from EXITED, and the `runGsdTools` adapter retries it exactly as the pre-seam `isKilled()` did. This is what makes `timedOut` and `signal` assertable, so a fail-open guard's degraded verdict can be tested instead of merely observing that the call did not throw. Discrimination order is forced by runtime behavior: a timeout and a maxBuffer overflow are identical on both `status` (`null`) and `signal` (`SIGTERM`) and differ only by `code` (`ETIMEDOUT` vs `ENOBUFS`), so overflow is classified first — but that ordering assumes `status === null`, which is checked ahead of it: at the exact timeout boundary `spawnSync` can report `error.code === 'ETIMEDOUT'` on a result that ALSO carries a real `status` (the child finished on its own just as the timer fired), so `status !== null` is classified EXITED before any error-code branch runs, keeping `exitCode` coherent with the reported outcome. Per-suite wrappers remain and bind fixtures (cwd, env, payload); only the spawn body delegates here. Deliberately **not** a fault-injection surface — it cannot distinguish an injected timeout from a genuine bench OOM and would retry it; injection is in-process via `deps` (#3056). `runGsdTools` is an adapter over it that preserves its own legacy `{ success, output, error, exitCode }` shape and retry-once-on-kill behavior. +### Exit Code Registry Module +Generated central allocator for this repo's process exit codes (ADR-3889, epic #3889 — "nothing fails with success"; Phase 1, #3905). Declaration source of truth: `gsd-core/bin/shared/exit-codes.json` (append-only entries: `code`, `name`, `meaning`, `owner`, `authorizedBy`); generated artifact `gsd-core/bin/lib/exit-code-registry.cjs` (`EXIT_CODES`, `exitCodeFor(name)`, `nameForExitCode(code)`) is produced by `scripts/gen-exit-code-registry.cjs --write` and byte-guarded by `npm run lint:generated-sync` — the same declaration → generator → `--check` gate pattern as the Capability Registry, the Model Catalog, and the ADR index. Band contract (ADR-3889 §1): `0` and `1` are free and never allocatable here; `2` is reserved to the Claude Code hook-adapter protocol (owner must be `hook-adapter`); `3`-`13` are Node-reserved; `64`-`78` are the generic band; `80`-`125` are the domain band; every other value (`14`-`63`, `79`, `126`+) sits outside every band and is rejected — so every registered code is non-zero by construction. The generator also enforces one-number-one-meaning and one-name-one-code across the whole declaration. This module is the ALLOCATOR only: nothing in the generator or the generated artifact emits a registered exit code itself — wiring real call sites onto the registry is later work that does not land until #3906. + ### Git fixture wrapper The throw-preserving companion to the process seam (`tests/helpers/git-fixture.cjs`, #3143): `gitOrThrow(args, options)` runs `runGit` and returns `stdout` as a string on a clean exit, but throws on any other outcome. It exists because the seam **deliberately never throws** while `execSync` and `execFileSync` — the two forms 237 migrating call sites use — both throw on a non-zero exit. Migrating those mechanically onto `runGit` would convert a loud failure into a silent one: fixture setup that failed would return an empty string and surface as a baffling assertion failure further down. The thrown error carries `status` **and** `exitCode` as deliberate aliases (`status` is what the legacy `execSync` catch idiom reads, e.g. `tests/worktree-safety.test.cjs:1361`), plus `stdout`, `stderr`, `signal`, `timedOut` and `outcome`. Use `runGit` when every outcome is data you branch on; use `gitOrThrow` for fixture setup that must abort loudly. The seam module is **not** modified to add this — a throwing export would falsify the never-throws contract stated in its own header and in the `### Process seam` entry above. The module also exports `throwIfFailed(result, displayName)`, the single implementation of that throw shape: `gitOrThrow` itself is `throwIfFailed` specialized to `runGit`, so it routes through the same code path and the two cannot drift apart. Per-suite wrappers driving non-git targets — a node CLI via `runNode`, a bash snippet via `runHook` — call `throwIfFailed` directly rather than hand-rolling their own copy of this shape, which is exactly how five call sites had drifted from each other before this module exported it (#3144). It also exports `toLegacyResult(result)`, the non-throwing counterpart: a bare mapping onto the legacy `{ status, stdout, stderr }` shape (`status` aliasing the seam's `exitCode`) for call sites that already branch on exit status as data rather than wanting a throw — ~8 test files hand-rolled that identical three-line mapping before this module exported it too (#3147). Callers needing an extra field beyond that shape (e.g. a parsed-JSON body, a fixture-specific path) compose it — `{ ...toLegacyResult(result), extra }` — rather than folding the extra behavior into the shared helper. diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 37dabf830..0af02c692 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -371,6 +371,7 @@ "estimate-cli.cjs", "eval-command-router.cjs", "eval.cjs", + "exit-code-registry.cjs", "external-descriptor-trust.cjs", "external-job.cjs", "fallow-runner.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index e20e1fe45..6521c8107 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -519,6 +519,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `eval-command-router.cjs` | Routes the `eval.score` verb (compiled from `src/eval-command-router.cts`, gitignored) — thin dispatcher into the eval scoring module (#1579) | | `eval.cjs` | Deterministic eval scoring (compiled from `src/eval.cts`, gitignored) — `computeEvalScore` (coverage*0.6 + infra*0.4, bands 80/60/40) + `cmdEvalScore` CLI domain guard; moves the gsd-eval-auditor's weighted arithmetic out of the prompt into code (#10 / #1579) | | `estimate-cli.cjs` | I/O seam over `phase-estimation.cjs` — the `estimate-check` and `estimate-calibration` query verbs; reads the `workflow.smart_zone_tokens` budget and `.planning/estimation-calibration.json`, both degrading to defaults rather than failing planning (#2630) | +| `exit-code-registry.cjs` | Generated exit-code allocator — one number, one meaning table of registered process exit codes (band rules: `2` hook-adapter only, `64`-`78` generic, `80`-`125` domain); emitted by `scripts/gen-exit-code-registry.cjs --write` (ADR-3889 §1/§2); exports `EXIT_CODES` and the pure, total `exitCodeFor`/`nameForExitCode` | | `observability/event.cjs` | DispatchEvent shape factory for every Hub dispatch — traceId/parentTraceId/command/result/timestamp record consumed by DispatchLogger (#177, ADR-0174 P1.3/P1.4) | | `external-descriptor-trust.cjs` | Defense-in-depth path-containment check for third-party plugin descriptors (#1681) | | `external-job.cjs` | Produces scheduler manifests for asynchronous external jobs; SLURM is the first backend (#1164) | diff --git a/gsd-core/bin/lib/exit-code-registry.cjs b/gsd-core/bin/lib/exit-code-registry.cjs new file mode 100644 index 000000000..b0247b193 --- /dev/null +++ b/gsd-core/bin/lib/exit-code-registry.cjs @@ -0,0 +1,87 @@ +'use strict'; + +// GENERATED FILE — DO NOT EDIT BY HAND. +// Source of truth: gsd-core/bin/shared/exit-codes.json. Regenerate with: +// node scripts/gen-exit-code-registry.cjs --write +// Byte-compared by `npm run lint:generated-sync` (#3905, ADR-3889 Phase 1). +// +// exitCodeFor(name) / nameForExitCode(code) are pure and total over this +// closed table — each throws for anything not registered here. + +const EXIT_CODES = Object.freeze([ + Object.freeze({ + code: 2, + name: "HOOK_DENY", + meaning: "Claude Code hook protocol — deny the tool call", + owner: "hook-adapter", + authorizedBy: "ADR-3889", + }), + Object.freeze({ + code: 64, + name: "USAGE", + meaning: "Caller error — bad argv, unknown subcommand, missing argument", + owner: "generic", + authorizedBy: "ADR-3889", + }), + Object.freeze({ + code: 66, + name: "NO_INPUT", + meaning: "Ran; zero units were in scope, and that emptiness is known to be genuine", + owner: "generic", + authorizedBy: "ADR-3889", + }), + Object.freeze({ + code: 69, + name: "UNAVAILABLE", + meaning: "Could not run — prerequisite absent, input unreadable, scope unestablished", + owner: "generic", + authorizedBy: "ADR-3889", + }), + Object.freeze({ + code: 70, + name: "INTERNAL", + meaning: "Self-failure — crash, timeout, killed subprocess", + owner: "generic", + authorizedBy: "ADR-3889", + }) +]); + +const NAME_TO_CODE = new Map(EXIT_CODES.map((entry) => [entry.name, entry.code])); +const CODE_TO_NAME = new Map(EXIT_CODES.map((entry) => [entry.code, entry.name])); + +/** + * Resolve the registered exit code for a symbolic name. Pure, total: throws + * for anything not an exact, registered, exact-case key — including + * non-strings, the empty string, untrimmed strings, wrong case, and + * prototype-chain names like `__proto__`/`constructor`/`toString` (a Map + * lookup never touches the prototype chain, so these are indistinguishable + * from any other unregistered name). + * + * @param {string} name + * @returns {number} + */ +function exitCodeFor(name) { + if (typeof name !== 'string' || name.length === 0) { + throw new Error(`exitCodeFor: name must be a non-empty string, received ${JSON.stringify(name)}`); + } + if (!NAME_TO_CODE.has(name)) { + throw new Error(`exitCodeFor: unregistered exit code name: ${JSON.stringify(name)}`); + } + return NAME_TO_CODE.get(name); +} + +/** + * Reverse of exitCodeFor: resolve the symbolic name for a registered exit + * code. Pure, total: throws for anything not an exact, registered code. + * + * @param {number} code + * @returns {string} + */ +function nameForExitCode(code) { + if (!CODE_TO_NAME.has(code)) { + throw new Error(`nameForExitCode: unregistered exit code: ${JSON.stringify(code)}`); + } + return CODE_TO_NAME.get(code); +} + +module.exports = { EXIT_CODES, exitCodeFor, nameForExitCode }; diff --git a/gsd-core/bin/shared/exit-codes.json b/gsd-core/bin/shared/exit-codes.json new file mode 100644 index 000000000..279fe4dff --- /dev/null +++ b/gsd-core/bin/shared/exit-codes.json @@ -0,0 +1,7 @@ +[ + { "code": 2, "name": "HOOK_DENY", "meaning": "Claude Code hook protocol — deny the tool call", "owner": "hook-adapter", "authorizedBy": "ADR-3889" }, + { "code": 64, "name": "USAGE", "meaning": "Caller error — bad argv, unknown subcommand, missing argument", "owner": "generic", "authorizedBy": "ADR-3889" }, + { "code": 66, "name": "NO_INPUT", "meaning": "Ran; zero units were in scope, and that emptiness is known to be genuine", "owner": "generic", "authorizedBy": "ADR-3889" }, + { "code": 69, "name": "UNAVAILABLE", "meaning": "Could not run — prerequisite absent, input unreadable, scope unestablished", "owner": "generic", "authorizedBy": "ADR-3889" }, + { "code": 70, "name": "INTERNAL", "meaning": "Self-failure — crash, timeout, killed subprocess", "owner": "generic", "authorizedBy": "ADR-3889" } +] diff --git a/package.json b/package.json index 67f41bc96..635b5a8c4 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ "gen:registry": "node scripts/gen-registry.cjs --write", "gen:install-tree": "node scripts/gen-install-tree-fixtures.cjs", "gen:section-manifest": "node scripts/gen-section-manifest.cjs --write", - "regen:derived": "npm run build && npm run gen:registry && node scripts/gen-adr-index.cjs --write && node scripts/gen-features.cjs --write && node scripts/gen-capability-matrix.cjs --write && node scripts/gen-inventory-manifest.cjs --write && node scripts/gen-context-index.cjs --write && node scripts/gen-state-md-docs.cjs --write && npm run gen:section-manifest && node scripts/sync-manifest-versions.cjs && npm run gen:install-tree && node scripts/gen-scripts-cli-exit.cjs --write", + "regen:derived": "npm run build && npm run gen:registry && node scripts/gen-adr-index.cjs --write && node scripts/gen-features.cjs --write && node scripts/gen-capability-matrix.cjs --write && node scripts/gen-inventory-manifest.cjs --write && node scripts/gen-context-index.cjs --write && node scripts/gen-state-md-docs.cjs --write && npm run gen:section-manifest && node scripts/sync-manifest-versions.cjs && npm run gen:install-tree && node scripts/gen-scripts-cli-exit.cjs --write && node scripts/gen-exit-code-registry.cjs --write", "validate:registry": "node scripts/validate-registry.cjs", "prepack": "npm run build:lib", "prepare": "npm run build:lib", @@ -129,7 +129,7 @@ "lint:test-file-count": "node scripts/lint-test-file-count.cjs", "lint:pr-checks": "node scripts/lint-pr-check-project-dir.cjs", "lint:changeset": "node scripts/changeset/lint.cjs", - "lint:generated-sync": "node scripts/gen-capability-registry.cjs --check && node scripts/gen-loop-host-contract.cjs --check && node scripts/gen-capability-matrix.cjs --check && node scripts/sync-manifest-versions.cjs --check && node scripts/gen-inventory-manifest.cjs --check && node scripts/generate-package-identity.cjs --check && node scripts/gen-plugin-skills.cjs --check && node scripts/gen-registry.cjs --check && node scripts/gen-adr-index.cjs --check && node scripts/gen-features.cjs --check && node scripts/check-glossary-refs.cjs --check && node scripts/lint-compiled-artifact-sync.cjs --check && node scripts/gen-context-index.cjs --check && node scripts/gen-section-manifest.cjs --check && node scripts/gen-health-docs.cjs --check && node scripts/gen-state-md-docs.cjs --check && node scripts/gen-scripts-cli-exit.cjs --check", + "lint:generated-sync": "node scripts/gen-capability-registry.cjs --check && node scripts/gen-loop-host-contract.cjs --check && node scripts/gen-capability-matrix.cjs --check && node scripts/sync-manifest-versions.cjs --check && node scripts/gen-inventory-manifest.cjs --check && node scripts/generate-package-identity.cjs --check && node scripts/gen-plugin-skills.cjs --check && node scripts/gen-registry.cjs --check && node scripts/gen-adr-index.cjs --check && node scripts/gen-features.cjs --check && node scripts/check-glossary-refs.cjs --check && node scripts/lint-compiled-artifact-sync.cjs --check && node scripts/gen-context-index.cjs --check && node scripts/gen-section-manifest.cjs --check && node scripts/gen-health-docs.cjs --check && node scripts/gen-state-md-docs.cjs --check && node scripts/gen-scripts-cli-exit.cjs --check && node scripts/gen-exit-code-registry.cjs --check", "lint:docs": "node scripts/lint-docs-required.cjs", "lint:qa-smells": "node scripts/qa-smell-ratchet.cjs", "lint:legacy-name": "node scripts/lint-legacy-dir-name.cjs", diff --git a/scripts/gen-exit-code-registry.cjs b/scripts/gen-exit-code-registry.cjs new file mode 100644 index 000000000..c3c2cdfc0 --- /dev/null +++ b/scripts/gen-exit-code-registry.cjs @@ -0,0 +1,519 @@ +#!/usr/bin/env node +/** + * gen-exit-code-registry.cjs — generates gsd-core/bin/lib/exit-code-registry.cjs + * from the declaration at gsd-core/bin/shared/exit-codes.json. + * + * ADR-3889 ("One exit-code registry — 0 and 1 are free, everything else is + * allocated") Phase 1 (#3905): this script builds the ALLOCATOR. It owns + * validating the declaration (band rules, one-number-one-meaning, one-owner) + * and hand-serializing the generated lookup module — the same + * declaration -> generator -> `--check` gate pattern this repo already uses + * for capability-registry.cjs, the model catalog, and the ADR index. + * + * Nothing in this script emits a registered exit code itself; wiring + * consumers onto the registry is a later phase (#3906). + * + * Usage: + * node scripts/gen-exit-code-registry.cjs # same as --write + * node scripts/gen-exit-code-registry.cjs --write # write the artifact + * node scripts/gen-exit-code-registry.cjs --check # exit 1 if the committed artifact is stale + * node scripts/gen-exit-code-registry.cjs --declaration --out # override for tests + * node scripts/gen-exit-code-registry.cjs --json # emit ONE JSON report on stdout instead of human prose + */ + +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +const REPO_ROOT = path.resolve(__dirname, '..'); +const DEFAULT_DECLARATION_PATH = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared', 'exit-codes.json'); +const DEFAULT_OUTPUT_PATH = path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'exit-code-registry.cjs'); + +/** Frozen reason codes so tests assert on structure, not prose. */ +const REASON = Object.freeze({ + OK: 'ok_generated_sync', + DRIFTED: 'fail_generated_drifted', + USAGE: 'fail_usage', + MISSING_DECLARATION: 'fail_missing_declaration', + MALFORMED_DECLARATION: 'fail_malformed_declaration', + NOT_AN_ARRAY: 'fail_not_an_array', + EMPTY_DECLARATION: 'fail_empty_declaration', + INVALID_ENTRY: 'fail_invalid_entry', + DUPLICATE_CODE: 'fail_duplicate_code', + DUPLICATE_NAME: 'fail_duplicate_name', + RESERVED_CODE: 'fail_reserved_code', + FORBIDDEN_OWNER: 'fail_forbidden_owner', + MISSING_ARTIFACT: 'fail_missing_artifact', +}); + +const USAGE_MESSAGE = [ + 'Usage: node scripts/gen-exit-code-registry.cjs [--write|--check] [--declaration ] [--out ] [--json]', + ' (no flag) same as --write', + ' --write write the generated registry artifact', + ' --check exit 1 if the committed artifact is stale', + ' --declaration override the declaration path (default: gsd-core/bin/shared/exit-codes.json)', + ' --out override the output artifact path (default: gsd-core/bin/lib/exit-code-registry.cjs)', + ' --json emit ONE JSON report ({ok, reason, context, detail?}) on stdout instead of human-readable prose', +].join('\n'); + +/** SCREAMING_SNAKE_CASE: starts with a letter, only uppercase letters/digits/underscores. */ +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']; + +/** + * Bands, per ADR-3889 §1: + * 0, 1 free (not allocatable here) + * 2 hook-adapter only + * 3-13 Node-reserved + * 14-63, 79, 126+ outside every band + * 64-78 generic + * 80-125 domain + */ +function isAllocatableCode(code) { + if (code === 2) return true; + if (code >= 64 && code <= 78) return true; + if (code >= 80 && code <= 125) return true; + return false; +} + +/** + * 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. + * @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 +} + +/** + * Validate a single declaration entry's shape and band membership. + * @returns {{ok:true}|{ok:false,reason:string,message:string,context:object}} + */ +function validateEntry(entry, index) { + if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) { + return { + ok: false, + reason: REASON.INVALID_ENTRY, + message: `entry[${index}] is not an object: ${JSON.stringify(entry)}`, + context: { field: 'entry', index }, + }; + } + + const { code, name } = entry; + if (!Number.isInteger(code) || code < 0) { + return { + ok: false, + reason: REASON.INVALID_ENTRY, + message: `entry[${index}].code must be a non-negative integer (no coercion), received ${JSON.stringify(code)}`, + context: { field: 'code', index, code }, + }; + } + + if (typeof name !== 'string' || name.trim() === '' || !NAME_RE.test(name)) { + return { + ok: false, + reason: REASON.INVALID_ENTRY, + message: `entry[${index}].name must be a non-empty SCREAMING_SNAKE_CASE string, received ${JSON.stringify(name)}`, + context: { field: 'name', index, code, name }, + }; + } + + for (const field of REQUIRED_STRING_FIELDS) { + const value = entry[field]; + if (typeof value !== 'string' || value.trim() === '') { + return { + ok: false, + reason: REASON.INVALID_ENTRY, + message: `entry[${index}] (${name}).${field} must be a non-empty string, received ${JSON.stringify(value)}`, + context: { field, index, code, name }, + }; + } + } + + if (!isAllocatableCode(code)) { + return { + ok: false, + reason: REASON.RESERVED_CODE, + message: `entry[${index}] (${name}) declares code ${code}, which is outside every allocatable band ` + + `(2 hook-adapter only; 64-78 generic; 80-125 domain) — see ADR-3889 §1`, + context: { code, band: bandFor(code), index, name }, + }; + } + + if (code === 2 && entry.owner !== 'hook-adapter') { + return { + ok: false, + reason: REASON.FORBIDDEN_OWNER, + message: `entry[${index}] (${name}) declares code 2 with owner "${entry.owner}" — code 2 is reserved to ` + + `the Claude Code hook protocol and may only be owned by "hook-adapter"`, + context: { code, owner: entry.owner, requiredOwner: 'hook-adapter', index, name }, + }; + } + + return { ok: true }; +} + +/** + * Validate the whole declaration: every entry individually, then the + * cross-entry invariants (one number one meaning; one owner emits a given + * code — but the SAME owner may legitimately own several distinct codes). + * @returns {{ok:true}|{ok:false,reason:string,message:string,context:object}} + */ +function validateEntries(entries) { + for (let i = 0; i < entries.length; i++) { + const result = validateEntry(entries[i], i); + if (!result.ok) return result; + } + + const byCode = new Map(); + const byName = new Map(); + for (const entry of entries) { + if (byCode.has(entry.code)) { + const other = byCode.get(entry.code); + return { + ok: false, + reason: REASON.DUPLICATE_CODE, + message: `code ${entry.code} is declared twice: "${other.name}" and "${entry.name}"`, + context: { code: entry.code, names: [other.name, entry.name] }, + }; + } + byCode.set(entry.code, entry); + + if (byName.has(entry.name)) { + const other = byName.get(entry.name); + return { + ok: false, + reason: REASON.DUPLICATE_NAME, + message: `name "${entry.name}" is declared twice: code ${other.code} and code ${entry.code}`, + context: { name: entry.name, codes: [other.code, entry.code] }, + }; + } + byName.set(entry.name, entry); + } + + return { ok: true }; +} + +/** + * Load and parse the declaration file. + * @returns {{ok:true,entries:Array}|{ok:false,reason:string,message:string}} + */ +function loadDeclaration(declarationPath) { + if (!fs.existsSync(declarationPath)) { + return { + ok: false, + reason: REASON.MISSING_DECLARATION, + message: `declaration not found at ${declarationPath}`, + context: { path: declarationPath }, + }; + } + + let raw; + try { + raw = fs.readFileSync(declarationPath, 'utf8'); + } catch (err) { + return { + ok: false, + reason: REASON.MISSING_DECLARATION, + message: `could not read ${declarationPath}: ${err.message}`, + context: { path: declarationPath }, + }; + } + + let parsed; + try { + parsed = JSON.parse(raw); + } catch (err) { + return { + ok: false, + reason: REASON.MALFORMED_DECLARATION, + message: `${declarationPath} is not valid JSON: ${err.message}`, + context: { path: declarationPath }, + }; + } + + if (!Array.isArray(parsed)) { + return { + ok: false, + reason: REASON.NOT_AN_ARRAY, + message: `${declarationPath} must be a JSON array, received ${parsed === null ? 'null' : typeof parsed}`, + context: { path: declarationPath }, + }; + } + + if (parsed.length === 0) { + return { + ok: false, + reason: REASON.EMPTY_DECLARATION, + message: `${declarationPath} is an empty array — declare at least one exit code`, + context: { path: declarationPath }, + }; + } + + return { ok: true, entries: parsed }; +} + +/** + * Hand-serialize the generated registry module (string concatenation, like + * gsd-core/bin/lib/capability-registry.cjs — no build step, no template + * engine, so the emitted bytes are exactly what `--check` re-derives). + */ +function serializeRegistry(entries, declarationPath) { + const relDeclaration = path.relative(REPO_ROOT, declarationPath).split(path.sep).join('/'); + const banner = [ + '\'use strict\';', + '', + '// GENERATED FILE — DO NOT EDIT BY HAND.', + `// Source of truth: ${relDeclaration}. Regenerate with:`, + '// node scripts/gen-exit-code-registry.cjs --write', + '// Byte-compared by `npm run lint:generated-sync` (#3905, ADR-3889 Phase 1).', + '//', + '// exitCodeFor(name) / nameForExitCode(code) are pure and total over this', + '// closed table — each throws for anything not registered here.', + '', + ].join('\n'); + + const entryLiterals = entries.map((e) => { + return ' Object.freeze({\n' + + ` code: ${JSON.stringify(e.code)},\n` + + ` name: ${JSON.stringify(e.name)},\n` + + ` meaning: ${JSON.stringify(e.meaning)},\n` + + ` owner: ${JSON.stringify(e.owner)},\n` + + ` authorizedBy: ${JSON.stringify(e.authorizedBy)},\n` + + ' })'; + }).join(',\n'); + + const body = [ + 'const EXIT_CODES = Object.freeze([', + entryLiterals, + ']);', + '', + 'const NAME_TO_CODE = new Map(EXIT_CODES.map((entry) => [entry.name, entry.code]));', + 'const CODE_TO_NAME = new Map(EXIT_CODES.map((entry) => [entry.code, entry.name]));', + '', + '/**', + ' * Resolve the registered exit code for a symbolic name. Pure, total: throws', + ' * for anything not an exact, registered, exact-case key — including', + ' * non-strings, the empty string, untrimmed strings, wrong case, and', + ' * prototype-chain names like `__proto__`/`constructor`/`toString` (a Map', + ' * lookup never touches the prototype chain, so these are indistinguishable', + ' * from any other unregistered name).', + ' *', + ' * @param {string} name', + ' * @returns {number}', + ' */', + 'function exitCodeFor(name) {', + ' if (typeof name !== \'string\' || name.length === 0) {', + ' throw new Error(`exitCodeFor: name must be a non-empty string, received ${JSON.stringify(name)}`);', + ' }', + ' if (!NAME_TO_CODE.has(name)) {', + ' throw new Error(`exitCodeFor: unregistered exit code name: ${JSON.stringify(name)}`);', + ' }', + ' return NAME_TO_CODE.get(name);', + '}', + '', + '/**', + ' * Reverse of exitCodeFor: resolve the symbolic name for a registered exit', + ' * code. Pure, total: throws for anything not an exact, registered code.', + ' *', + ' * @param {number} code', + ' * @returns {string}', + ' */', + 'function nameForExitCode(code) {', + ' if (!CODE_TO_NAME.has(code)) {', + ' throw new Error(`nameForExitCode: unregistered exit code: ${JSON.stringify(code)}`);', + ' }', + ' return CODE_TO_NAME.get(code);', + '}', + '', + 'module.exports = { EXIT_CODES, exitCodeFor, nameForExitCode };', + '', + ].join('\n'); + + return banner + '\n' + body; +} + +/** + * Load, validate, and serialize the declaration in one step. + * @returns {{ok:true,content:string}|{ok:false,reason:string,message:string}} + */ +function buildRegistryContent(declarationPath) { + const loaded = loadDeclaration(declarationPath); + if (!loaded.ok) return loaded; + + const validated = validateEntries(loaded.entries); + if (!validated.ok) return validated; + + return { ok: true, content: serializeRegistry(loaded.entries, declarationPath) }; +} + +function printFail(result) { + console.error(`FAIL gen-exit-code-registry: ${result.reason}`); + console.error(` ${result.message}`); +} + +/** + * Emit a failure outcome: structured JSON on stdout (and NO stderr prose) + * when `json` is set, otherwise the legacy human-readable stderr report. + * `context` carries the specifics (offending code/name/field/path) that the + * `detail` prose currently embeds, so a `--json` consumer never needs to + * parse prose to recover them — it defaults to `null` for reasons (USAGE, + * DRIFTED, MISSING_ARTIFACT) that carry no structured specifics. + * @param {{reason:string, message?:string, context?:object}} result + * @param {boolean} json + */ +function emitFail(result, json) { + if (json) { + process.stdout.write(JSON.stringify({ ok: false, reason: result.reason, context: result.context ?? null, detail: result.message }) + '\n'); + return; + } + printFail(result); +} + +/** + * Emit a success outcome: structured JSON on stdout when `json` is set, + * otherwise the legacy human-readable stdout line. + * @param {string} reason + * @param {string} humanMessage + * @param {boolean} json + */ +function emitOk(reason, humanMessage, json) { + if (json) { + process.stdout.write(JSON.stringify({ ok: true, reason }) + '\n'); + return; + } + console.log(humanMessage); +} + +function doWrite(declarationPath, outPath, json) { + const result = buildRegistryContent(declarationPath); + if (!result.ok) { + emitFail(result, json); + return 1; + } + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + fs.writeFileSync(outPath, result.content, 'utf8'); + emitOk(REASON.OK, `ok gen-exit-code-registry: wrote ${outPath}`, json); + return 0; +} + +function doCheck(declarationPath, outPath, json) { + const result = buildRegistryContent(declarationPath); + if (!result.ok) { + emitFail(result, json); + return 1; + } + + if (!fs.existsSync(outPath)) { + emitFail( + { + reason: REASON.MISSING_ARTIFACT, + message: `${outPath} does not exist. Run:\n node scripts/gen-exit-code-registry.cjs --write`, + }, + json, + ); + return 1; + } + + const committed = fs.readFileSync(outPath, 'utf8'); + if (committed !== result.content) { + emitFail( + { + reason: REASON.DRIFTED, + message: + `${outPath} (${committed.length} bytes) != freshly generated content (${result.content.length} bytes)\n\n` + + 'Regenerate with:\n node scripts/gen-exit-code-registry.cjs --write', + }, + json, + ); + return 1; + } + + emitOk(REASON.OK, `ok gen-exit-code-registry: ${outPath} matches ${declarationPath}`, json); + return 0; +} + +/** + * @returns {{mode:'write'|'check', declarationPath:?string, outPath:?string, json:boolean}} + */ +function parseArgs(argv) { + let mode = null; + let declarationPath = null; + let outPath = null; + let json = false; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--write' || arg === '--check') { + if (mode !== null) { + throw new Error(`conflicting mode flags: --${mode} and ${arg}`); + } + mode = arg === '--write' ? 'write' : 'check'; + } else if (arg === '--json') { + json = true; + } else if (arg === '--declaration') { + const value = argv[++i]; + if (value === undefined) throw new Error('--declaration requires a value'); + declarationPath = value; + } else if (arg.startsWith('--declaration=')) { + declarationPath = arg.slice('--declaration='.length); + } else if (arg === '--out') { + const value = argv[++i]; + if (value === undefined) throw new Error('--out requires a value'); + outPath = value; + } else if (arg.startsWith('--out=')) { + outPath = arg.slice('--out='.length); + } else { + throw new Error(`unrecognized argument: ${arg}`); + } + } + + return { mode: mode || 'write', declarationPath, outPath, json }; +} + +function main() { + // --json must be honored even on a parse failure (e.g. an unrecognized + // flag alongside --json), so it is detected from the raw argv rather + // than from parseArgs's return value, which may never be produced. + const rawArgv = process.argv.slice(2); + const jsonRequested = rawArgv.includes('--json'); + + let args; + try { + args = parseArgs(rawArgv); + } catch (err) { + emitFail({ reason: REASON.USAGE, message: jsonRequested ? err.message : `${err.message}\n${USAGE_MESSAGE}` }, jsonRequested); + return 1; + } + + const declarationPath = args.declarationPath || DEFAULT_DECLARATION_PATH; + const outPath = args.outPath || DEFAULT_OUTPUT_PATH; + + return args.mode === 'check' ? doCheck(declarationPath, outPath, args.json) : doWrite(declarationPath, outPath, args.json); +} + +if (require.main === module) process.exitCode = main(); + +module.exports = { + REASON, + USAGE_MESSAGE, + DEFAULT_DECLARATION_PATH, + DEFAULT_OUTPUT_PATH, + isAllocatableCode, + bandFor, + validateEntry, + validateEntries, + loadDeclaration, + serializeRegistry, + buildRegistryContent, + parseArgs, + main, +}; diff --git a/tests/exit-code-registry.test.cjs b/tests/exit-code-registry.test.cjs new file mode 100644 index 000000000..d5a408631 --- /dev/null +++ b/tests/exit-code-registry.test.cjs @@ -0,0 +1,606 @@ +'use strict'; + +/** + * tests/exit-code-registry.test.cjs + * + * ADR-3889 ("One exit-code registry — 0 and 1 are free, everything else is + * allocated") Phase 1 (#3905): behavioral tests for the allocator — + * gsd-core/bin/shared/exit-codes.json (declaration), scripts/gen-exit-code-registry.cjs + * (generator + validator), and the generated gsd-core/bin/lib/exit-code-registry.cjs + * artifact (`EXIT_CODES`, `exitCodeFor`, `nameForExitCode`). + * + * Every test that needs a mutated declaration or artifact operates on a + * temp-dir copy driven via --declaration/--out — the real repo files under + * gsd-core/bin/shared and gsd-core/bin/lib are never mutated, since test + * files in this repo run in parallel. + * + * fast-check is confirmed present in package.json devDependencies (^4.8.0); + * property tests below pin { seed: 2704, numRuns: 200 } per-call so a + * failure replays deterministically regardless of this suite's global fc + * default. + */ + +const { test, describe, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +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 REPO_ROOT = path.resolve(__dirname, '..'); +const GEN_SCRIPT = path.join(REPO_ROOT, 'scripts', 'gen-exit-code-registry.cjs'); +const REAL_DECLARATION_PATH = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared', 'exit-codes.json'); +const REAL_ARTIFACT_PATH = path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'exit-code-registry.cjs'); + +const generator = require(GEN_SCRIPT); +const registry = require(REAL_ARTIFACT_PATH); + +const REGISTERED_NAMES = new Set(registry.EXIT_CODES.map((e) => e.name)); + +/** A minimal, otherwise-valid entry template, overridable per field. */ +function makeEntry(overrides) { + return { + code: 64, + name: 'T_ENTRY', + meaning: 'a test meaning', + owner: 'generic', + authorizedBy: 'ADR-3889', + ...overrides, + }; +} + +function runGen(args, opts = {}) { + return runNode([GEN_SCRIPT, ...args], { timeoutMs: PROBE_TIMEOUT_MS, ...opts }); +} + +/** + * Run the generator CLI with `--json` and parse its single stdout JSON + * report. Per CONTRIBUTING.md's "Prohibited: Raw Text Matching on Test + * Outputs", CLI-subprocess assertions in this suite key off this structured + * `{ok, reason, context, detail?}` report — never a regex against human-readable + * stdout/stderr prose. + * @returns {{result: object, report: {ok:boolean, reason:string, detail?:string}}} + */ +function runGenJson(args, opts = {}) { + const result = runGen(['--json', ...args], opts); + let report; + try { + report = JSON.parse(result.stdout); + } catch (err) { + throw new Error(`runGenJson: stdout did not parse as JSON: ${err.message}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`); + } + return { result, report }; +} + +// ── exitCodeFor / nameForExitCode ───────────────────────────────────────────── +describe('exit-code-registry: exitCodeFor', () => { + test('resolves each of the 5 registered names to its code', () => { + assert.equal(registry.exitCodeFor('HOOK_DENY'), 2); + assert.equal(registry.exitCodeFor('USAGE'), 64); + assert.equal(registry.exitCodeFor('NO_INPUT'), 66); + assert.equal(registry.exitCodeFor('UNAVAILABLE'), 69); + assert.equal(registry.exitCodeFor('INTERNAL'), 70); + }); + + const badNames = [ + ['unknown name', 'NOT_A_REAL_NAME'], + ['empty string', ''], + ['null', null], + ['undefined', undefined], + ['number 0', 0], + ['plain object', {}], + ['array', []], + ['wrong case', 'usage'], + ['untrimmed', ' USAGE '], + ['__proto__', '__proto__'], + ['constructor', 'constructor'], + ['toString', 'toString'], + ]; + for (const [label, value] of badNames) { + test(`throws for ${label}`, () => { + assert.throws(() => registry.exitCodeFor(value)); + }); + } +}); + +describe('exit-code-registry: nameForExitCode', () => { + test('resolves each of the 5 registered codes to its name', () => { + assert.equal(registry.nameForExitCode(2), 'HOOK_DENY'); + assert.equal(registry.nameForExitCode(64), 'USAGE'); + assert.equal(registry.nameForExitCode(66), 'NO_INPUT'); + assert.equal(registry.nameForExitCode(69), 'UNAVAILABLE'); + assert.equal(registry.nameForExitCode(70), 'INTERNAL'); + }); + + const badCodes = [ + ['unregistered code', 999], + ['0 (free, unregistered)', 0], + ['1 (free, unregistered)', 1], + ['negative', -1], + ['string', '64'], + ['null', null], + ['undefined', undefined], + ]; + for (const [label, value] of badCodes) { + test(`throws for ${label}`, () => { + assert.throws(() => registry.nameForExitCode(value)); + }); + } +}); + +describe('exit-code-registry: shipped table invariants', () => { + test('EXIT_CODES is frozen and every entry is frozen', () => { + assert.ok(Object.isFrozen(registry.EXIT_CODES)); + for (const entry of registry.EXIT_CODES) { + assert.ok(Object.isFrozen(entry), `entry ${JSON.stringify(entry)} must be frozen`); + } + }); + + test('every shipped code is non-zero and inside an allocatable band', () => { + assert.ok(registry.EXIT_CODES.length > 0); + for (const entry of registry.EXIT_CODES) { + assert.ok(Number.isInteger(entry.code)); + assert.notEqual(entry.code, 0); + assert.ok( + generator.isAllocatableCode(entry.code), + `code ${entry.code} (${entry.name}) must be inside an allocatable band`, + ); + } + }); + + test('code 2 is owned only by hook-adapter in the shipped table', () => { + const hookDeny = registry.EXIT_CODES.find((e) => e.code === 2); + assert.ok(hookDeny); + assert.equal(hookDeny.owner, 'hook-adapter'); + }); + + test('generic owns four distinct codes in the shipped table (ACCEPTED negative-space case)', () => { + const genericCodes = registry.EXIT_CODES.filter((e) => e.owner === 'generic').map((e) => e.code); + assert.equal(genericCodes.length, 4); + assert.equal(new Set(genericCodes).size, 4); + }); +}); + +// ── Generator: REASON ───────────────────────────────────────────────────────── +describe('gen-exit-code-registry: REASON', () => { + const expectedKeys = [ + 'OK', 'DRIFTED', 'USAGE', 'MISSING_DECLARATION', 'MALFORMED_DECLARATION', + 'NOT_AN_ARRAY', 'EMPTY_DECLARATION', 'INVALID_ENTRY', 'DUPLICATE_CODE', + 'DUPLICATE_NAME', 'RESERVED_CODE', 'FORBIDDEN_OWNER', 'MISSING_ARTIFACT', + ]; + + test('is frozen', () => { + assert.ok(Object.isFrozen(generator.REASON)); + }); + + test('key set matches exactly', () => { + assert.deepEqual(Object.keys(generator.REASON).sort(), [...expectedKeys].sort()); + }); +}); + +// ── Generator: per-entry band validation (limit-1/limit/limit+1 for every edge) ── +describe('gen-exit-code-registry: band validation', () => { + const cases = [ + [0, 'RESERVED_CODE'], + [1, 'RESERVED_CODE'], + [2, 'OK'], + [3, 'RESERVED_CODE'], + [13, 'RESERVED_CODE'], + [14, 'RESERVED_CODE'], + [63, 'RESERVED_CODE'], + [64, 'OK'], + [78, 'OK'], + [79, 'RESERVED_CODE'], + [80, 'OK'], + [125, 'OK'], + [126, 'RESERVED_CODE'], + [127, 'RESERVED_CODE'], + [128, 'RESERVED_CODE'], + [-1, 'INVALID_ENTRY'], + [1.5, 'INVALID_ENTRY'], + ['64', 'INVALID_ENTRY'], + [NaN, 'INVALID_ENTRY'], + [Infinity, 'INVALID_ENTRY'], + ]; + + for (const [code, expected] of cases) { + test(`code ${String(code)} -> ${expected}`, () => { + const entry = makeEntry({ + code, + name: `T_${String(code).replace(/[^A-Za-z0-9]/g, '_').toUpperCase()}`, + // code 2 is only accepted with owner hook-adapter; every other + // fixture code here uses 'generic' and is unaffected by that rule. + owner: code === 2 ? 'hook-adapter' : 'generic', + }); + const result = generator.validateEntry(entry, 0); + if (expected === 'OK') { + assert.deepEqual(result, { ok: true }); + } else { + assert.equal(result.ok, false); + assert.equal(result.reason, generator.REASON[expected]); + } + }); + } +}); + +describe('gen-exit-code-registry: forbidden owner for code 2', () => { + test('code 2 with owner "hook-adapter" is accepted', () => { + const result = generator.validateEntry(makeEntry({ code: 2, name: 'HOOK_DENY_2', owner: 'hook-adapter' }), 0); + assert.deepEqual(result, { ok: true }); + }); + + test('code 2 with any other owner is FORBIDDEN_OWNER', () => { + const result = generator.validateEntry(makeEntry({ code: 2, name: 'HOOK_DENY_2', owner: 'generic' }), 0); + assert.equal(result.ok, false); + assert.equal(result.reason, generator.REASON.FORBIDDEN_OWNER); + }); +}); + +describe('gen-exit-code-registry: required string fields', () => { + const fields = ['meaning', 'owner', 'authorizedBy']; + const badValues = [undefined, '', ' ']; + + for (const field of fields) { + for (const bad of badValues) { + test(`missing/empty/whitespace "${field}" (${JSON.stringify(bad)}) -> INVALID_ENTRY`, () => { + const entry = makeEntry({ [field]: bad }); + const result = generator.validateEntry(entry, 0); + assert.equal(result.ok, false); + assert.equal(result.reason, generator.REASON.INVALID_ENTRY); + }); + } + } + + test('non-SCREAMING_SNAKE_CASE name -> INVALID_ENTRY', () => { + const result = generator.validateEntry(makeEntry({ name: 'not_screaming' }), 0); + assert.equal(result.ok, false); + assert.equal(result.reason, generator.REASON.INVALID_ENTRY); + }); + + test('empty name -> INVALID_ENTRY', () => { + const result = generator.validateEntry(makeEntry({ name: '' }), 0); + assert.equal(result.ok, false); + assert.equal(result.reason, generator.REASON.INVALID_ENTRY); + }); +}); + +describe('gen-exit-code-registry: cross-entry invariants', () => { + test('duplicate code -> DUPLICATE_CODE, context carries the code and both names', () => { + const entries = [ + makeEntry({ code: 64, name: 'FIRST_NAME' }), + makeEntry({ code: 64, name: 'SECOND_NAME' }), + ]; + const result = generator.validateEntries(entries); + assert.equal(result.ok, false); + assert.equal(result.reason, generator.REASON.DUPLICATE_CODE); + assert.deepEqual(result.context, { code: 64, names: ['FIRST_NAME', 'SECOND_NAME'] }); + }); + + test('duplicate name -> DUPLICATE_NAME, context carries the name and both codes', () => { + const entries = [ + makeEntry({ code: 64, name: 'SAME_NAME' }), + makeEntry({ code: 70, name: 'SAME_NAME' }), + ]; + const result = generator.validateEntries(entries); + assert.equal(result.ok, false); + assert.equal(result.reason, generator.REASON.DUPLICATE_NAME); + assert.deepEqual(result.context, { name: 'SAME_NAME', codes: [64, 70] }); + }); + + test('same owner, different codes -> ACCEPTED', () => { + const entries = [ + makeEntry({ code: 64, name: 'OWNER_A', owner: 'generic' }), + makeEntry({ code: 70, name: 'OWNER_B', owner: 'generic' }), + ]; + const result = generator.validateEntries(entries); + assert.deepEqual(result, { ok: true }); + }); +}); + +// ── Generator: declaration-file handling (pure loadDeclaration, temp files) ── +describe('gen-exit-code-registry: declaration file handling', () => { + let tmpDir; + before(() => { + tmpDir = createTempDir('gsd-exit-code-decl-'); + }); + after(() => { + cleanup(tmpDir); + }); + + test('absent declaration -> MISSING_DECLARATION', () => { + const missing = path.join(tmpDir, 'does-not-exist.json'); + const result = generator.loadDeclaration(missing); + assert.equal(result.ok, false); + assert.equal(result.reason, generator.REASON.MISSING_DECLARATION); + }); + + test('unparseable JSON -> MALFORMED_DECLARATION', () => { + const bad = path.join(tmpDir, 'malformed.json'); + fs.writeFileSync(bad, '{ this is not json', 'utf8'); + const result = generator.loadDeclaration(bad); + assert.equal(result.ok, false); + assert.equal(result.reason, generator.REASON.MALFORMED_DECLARATION); + }); + + const notArrayCases = [ + ['object', '{}'], + ['string', '"s"'], + ['number', '0'], + ['null', 'null'], + ]; + for (const [label, json] of notArrayCases) { + test(`valid JSON but not an array (${label}) -> NOT_AN_ARRAY`, () => { + const p = path.join(tmpDir, `not-array-${label}.json`); + fs.writeFileSync(p, json, 'utf8'); + const result = generator.loadDeclaration(p); + assert.equal(result.ok, false); + assert.equal(result.reason, generator.REASON.NOT_AN_ARRAY); + }); + } + + test('empty array -> EMPTY_DECLARATION', () => { + const p = path.join(tmpDir, 'empty.json'); + fs.writeFileSync(p, '[]', 'utf8'); + const result = generator.loadDeclaration(p); + assert.equal(result.ok, false); + assert.equal(result.reason, generator.REASON.EMPTY_DECLARATION); + }); +}); + +// ── Generator CLI ────────────────────────────────────────────────────────────── +describe('gen-exit-code-registry: CLI', () => { + let tmpDir; + before(() => { + tmpDir = createTempDir('gsd-exit-code-cli-'); + }); + after(() => { + cleanup(tmpDir); + }); + + function validDeclarationPath(dir, filename = 'exit-codes.json') { + const p = path.join(dir, filename); + fs.copyFileSync(REAL_DECLARATION_PATH, p); + return p; + } + + test('--check is in sync against the real committed pair', () => { + const result = runGen(['--check']); + assert.equal(result.exitCode, 0, result.stderr); + }); + + test('--write then --check on temp paths both exit 0', () => { + const decl = validDeclarationPath(tmpDir, 'a-decl.json'); + const out = path.join(tmpDir, 'a-out.cjs'); + const write = runGen(['--write', '--declaration', decl, '--out', out]); + assert.equal(write.exitCode, 0, write.stderr); + const check = runGen(['--check', '--declaration', decl, '--out', out]); + assert.equal(check.exitCode, 0, check.stderr); + }); + + test('--write is idempotent (byte-identical on a second run)', () => { + const decl = validDeclarationPath(tmpDir, 'b-decl.json'); + const out = path.join(tmpDir, 'b-out.cjs'); + const first = runGen(['--write', '--declaration', decl, '--out', out]); + assert.equal(first.exitCode, 0, first.stderr); + const firstBytes = fs.readFileSync(out, 'utf8'); + const second = runGen(['--write', '--declaration', decl, '--out', out]); + assert.equal(second.exitCode, 0, second.stderr); + const secondBytes = fs.readFileSync(out, 'utf8'); + assert.equal(secondBytes, firstBytes); + }); + + test('--check on a hand-edited artifact -> DRIFTED', () => { + const decl = validDeclarationPath(tmpDir, 'c-decl.json'); + const out = path.join(tmpDir, 'c-out.cjs'); + assert.equal(runGen(['--write', '--declaration', decl, '--out', out]).exitCode, 0); + fs.appendFileSync(out, '\n// hand-edited, drifts from generated content\n'); + const { result, report } = runGenJson(['--check', '--declaration', decl, '--out', out]); + assert.equal(result.exitCode, 1); + assert.equal(report.ok, false); + assert.equal(report.reason, generator.REASON.DRIFTED); + }); + + test('--check with a stale artifact (declaration changed after write) -> DRIFTED', () => { + const decl = validDeclarationPath(tmpDir, 'd-decl.json'); + const out = path.join(tmpDir, 'd-out.cjs'); + assert.equal(runGen(['--write', '--declaration', decl, '--out', out]).exitCode, 0); + const entries = JSON.parse(fs.readFileSync(decl, 'utf8')); + entries.push({ code: 80, name: 'DOMAIN_X', meaning: 'm', owner: 'domain-x', authorizedBy: 'ADR-3889' }); + fs.writeFileSync(decl, JSON.stringify(entries, null, 2), 'utf8'); + const { result, report } = runGenJson(['--check', '--declaration', decl, '--out', out]); + assert.equal(result.exitCode, 1); + assert.equal(report.ok, false); + assert.equal(report.reason, generator.REASON.DRIFTED); + }); + + test('--check with the artifact absent -> MISSING_ARTIFACT', () => { + const decl = validDeclarationPath(tmpDir, 'e-decl.json'); + const out = path.join(tmpDir, 'e-out-absent.cjs'); + const { result, report } = runGenJson(['--check', '--declaration', decl, '--out', out]); + assert.equal(result.exitCode, 1); + assert.equal(report.ok, false); + assert.equal(report.reason, generator.REASON.MISSING_ARTIFACT); + assert.equal(fs.existsSync(out), false); + }); + + test('unknown flag -> USAGE, exit 1, artifact unchanged on disk', () => { + const decl = validDeclarationPath(tmpDir, 'f-decl.json'); + const out = path.join(tmpDir, 'f-out.cjs'); + assert.equal(runGen(['--write', '--declaration', decl, '--out', out]).exitCode, 0); + const before = fs.readFileSync(out, 'utf8'); + const { result, report } = runGenJson(['--bogus-flag', '--declaration', decl, '--out', out]); + assert.equal(result.exitCode, 1); + assert.equal(report.ok, false); + assert.equal(report.reason, generator.REASON.USAGE); + const after = fs.readFileSync(out, 'utf8'); + assert.equal(after, before); + }); + + test('second positional argument -> USAGE', () => { + const decl = validDeclarationPath(tmpDir, 'g-decl.json'); + const out = path.join(tmpDir, 'g-out.cjs'); + const { result, report } = runGenJson(['--check', 'extra-positional', '--declaration', decl, '--out', out]); + assert.equal(result.exitCode, 1); + assert.equal(report.ok, false); + assert.equal(report.reason, generator.REASON.USAGE); + }); + + test('missing declaration -> MISSING_DECLARATION, exit 1', () => { + const decl = path.join(tmpDir, 'does-not-exist-h.json'); + const out = path.join(tmpDir, 'h-out.cjs'); + const { result, report } = runGenJson(['--write', '--declaration', decl, '--out', out]); + assert.equal(result.exitCode, 1); + assert.equal(report.ok, false); + assert.equal(report.reason, generator.REASON.MISSING_DECLARATION); + }); + + test('malformed JSON declaration -> MALFORMED_DECLARATION, exit 1', () => { + const decl = path.join(tmpDir, 'i-decl.json'); + fs.writeFileSync(decl, '{ not json', 'utf8'); + const out = path.join(tmpDir, 'i-out.cjs'); + const { result, report } = runGenJson(['--write', '--declaration', decl, '--out', out]); + assert.equal(result.exitCode, 1); + assert.equal(report.ok, false); + assert.equal(report.reason, generator.REASON.MALFORMED_DECLARATION); + }); + + test('valid JSON, not an array -> NOT_AN_ARRAY, exit 1', () => { + const decl = path.join(tmpDir, 'j-decl.json'); + fs.writeFileSync(decl, '{}', 'utf8'); + const out = path.join(tmpDir, 'j-out.cjs'); + const { result, report } = runGenJson(['--write', '--declaration', decl, '--out', out]); + assert.equal(result.exitCode, 1); + assert.equal(report.ok, false); + assert.equal(report.reason, generator.REASON.NOT_AN_ARRAY); + }); + + test('empty array declaration -> EMPTY_DECLARATION, exit 1', () => { + const decl = path.join(tmpDir, 'k-decl.json'); + fs.writeFileSync(decl, '[]', 'utf8'); + const out = path.join(tmpDir, 'k-out.cjs'); + const { result, report } = runGenJson(['--write', '--declaration', decl, '--out', out]); + assert.equal(result.exitCode, 1); + assert.equal(report.ok, false); + assert.equal(report.reason, generator.REASON.EMPTY_DECLARATION); + }); +}); + +// ── Generator CLI: positive controls (each guard actually FAILS the build) ──── +describe('gen-exit-code-registry: CLI positive controls for the ten guard rows', () => { + let tmpDir; + before(() => { + tmpDir = createTempDir('gsd-exit-code-positive-'); + }); + after(() => { + cleanup(tmpDir); + }); + + function writeFixture(name, entries) { + const decl = path.join(tmpDir, `${name}.json`); + fs.writeFileSync(decl, JSON.stringify(entries, null, 2), 'utf8'); + return decl; + } + + const validBase = () => ({ meaning: 'm', owner: 'generic', authorizedBy: 'ADR-3889' }); + + const rows = [ + ['duplicate code', () => [ + { ...validBase(), code: 64, name: 'DUP_A' }, + { ...validBase(), code: 64, name: 'DUP_B' }, + ], 'DUPLICATE_CODE'], + ['duplicate name', () => [ + { ...validBase(), code: 64, name: 'SAME' }, + { ...validBase(), code: 70, name: 'SAME' }, + ], 'DUPLICATE_NAME'], + ['code 2 wrong owner', () => [ + { ...validBase(), code: 2, name: 'HOOK_DENY', owner: 'not-hook-adapter' }, + ], 'FORBIDDEN_OWNER'], + ['code 0', () => [{ ...validBase(), code: 0, name: 'ZERO' }], 'RESERVED_CODE'], + ['code 13', () => [{ ...validBase(), code: 13, name: 'THIRTEEN' }], 'RESERVED_CODE'], + ['code 79', () => [{ ...validBase(), code: 79, name: 'SEVENTYNINE' }], 'RESERVED_CODE'], + ['code 126', () => [{ ...validBase(), code: 126, name: 'ONETWENTYSIX' }], 'RESERVED_CODE'], + ['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'], + ]; + + for (const [label, buildEntries, expectedReasonKey] of rows) { + test(`${label} -> ${expectedReasonKey}, exit 1`, () => { + const decl = writeFixture(label.replace(/[^a-z0-9]+/gi, '-'), buildEntries()); + const out = path.join(tmpDir, `${label.replace(/[^a-z0-9]+/gi, '-')}-out.cjs`); + const { result, report } = runGenJson(['--write', '--declaration', decl, '--out', out]); + assert.equal(result.exitCode, 1, `expected exit 1 for ${label}, got stderr: ${result.stderr}`); + assert.equal(report.ok, false); + assert.equal(report.reason, generator.REASON[expectedReasonKey]); + }); + } + + test('duplicate-code fixture: --json payload carries a structured context, not just the reason', () => { + const decl = writeFixture('json-duplicate-code', [ + { ...validBase(), code: 64, name: 'DUP_A' }, + { ...validBase(), code: 64, name: 'DUP_B' }, + ]); + const out = path.join(tmpDir, 'json-duplicate-code-out.cjs'); + const { result, report } = runGenJson(['--write', '--declaration', decl, '--out', out]); + assert.equal(result.exitCode, 1, result.stderr); + assert.equal(report.ok, false); + assert.equal(report.reason, generator.REASON.DUPLICATE_CODE); + // A --json consumer must be able to learn WHICH code collided and WHICH + // names collided without parsing the `detail` prose string. + assert.deepEqual(report.context, { code: 64, names: ['DUP_A', 'DUP_B'] }); + }); +}); + +// ── fast-check properties ───────────────────────────────────────────────────── +describe('exit-code-registry: fast-check properties', () => { + test('nameForExitCode(exitCodeFor(name)) round-trips for every registered name', () => { + fc.assert( + fc.property(fc.constantFrom(...registry.EXIT_CODES.map((e) => e.name)), (name) => { + assert.equal(registry.nameForExitCode(registry.exitCodeFor(name)), name); + }), + { seed: 2704, numRuns: 200 }, + ); + }); + + test('exitCodeFor(nameForExitCode(code)) round-trips for every registered code', () => { + fc.assert( + fc.property(fc.constantFrom(...registry.EXIT_CODES.map((e) => e.code)), (code) => { + assert.equal(registry.exitCodeFor(registry.nameForExitCode(code)), code); + }), + { seed: 2704, numRuns: 200 }, + ); + }); + + test('exitCodeFor never resolves a code for an unregistered string', () => { + fc.assert( + fc.property(fc.string(), (s) => { + fc.pre(!REGISTERED_NAMES.has(s)); + assert.throws(() => registry.exitCodeFor(s)); + }), + { seed: 2704, numRuns: 200 }, + ); + }); + + // Unlike the two round-trip properties above (which replay only the 5 + // shipped constants), this one explores the full integer domain — + // negatives, every band boundary, and values far outside every band — + // rather than a closed set of examples. + test('nameForExitCode(c) either throws or returns a name that round-trips to c, for any integer c', () => { + fc.assert( + fc.property(fc.integer(), (c) => { + let name; + try { + name = registry.nameForExitCode(c); + } catch { + return; // throwing for an unregistered code is a legal outcome + } + assert.notEqual(name, undefined); + assert.equal(registry.exitCodeFor(name), c); + }), + { seed: 2704, numRuns: 200 }, + ); + }); +}); diff --git a/tests/fixtures/install-tree/antigravity.json b/tests/fixtures/install-tree/antigravity.json index e6354c6c5..930d84e4d 100644 --- a/tests/fixtures/install-tree/antigravity.json +++ b/tests/fixtures/install-tree/antigravity.json @@ -43,6 +43,7 @@ "gsd-core/bin/gsd_run", "gsd-core/bin/shared/config-defaults.manifest.json", "gsd-core/bin/shared/config-schema.manifest.json", + "gsd-core/bin/shared/exit-codes.json", "gsd-core/bin/shared/model-catalog.json", "gsd-core/bin/shared/runtime-aliases.manifest.json", "gsd-core/bin/verify-reapply-patches.cjs", diff --git a/tests/fixtures/install-tree/augment.json b/tests/fixtures/install-tree/augment.json index 30aa59c38..ed88f9acb 100644 --- a/tests/fixtures/install-tree/augment.json +++ b/tests/fixtures/install-tree/augment.json @@ -114,6 +114,7 @@ "gsd-core/bin/gsd_run", "gsd-core/bin/shared/config-defaults.manifest.json", "gsd-core/bin/shared/config-schema.manifest.json", + "gsd-core/bin/shared/exit-codes.json", "gsd-core/bin/shared/model-catalog.json", "gsd-core/bin/shared/runtime-aliases.manifest.json", "gsd-core/bin/verify-reapply-patches.cjs", diff --git a/tests/fixtures/install-tree/claude-local.json b/tests/fixtures/install-tree/claude-local.json index 2df5dd8e5..ba4533be8 100644 --- a/tests/fixtures/install-tree/claude-local.json +++ b/tests/fixtures/install-tree/claude-local.json @@ -114,6 +114,7 @@ "gsd-core/bin/gsd_run", "gsd-core/bin/shared/config-defaults.manifest.json", "gsd-core/bin/shared/config-schema.manifest.json", + "gsd-core/bin/shared/exit-codes.json", "gsd-core/bin/shared/model-catalog.json", "gsd-core/bin/shared/runtime-aliases.manifest.json", "gsd-core/bin/verify-reapply-patches.cjs", diff --git a/tests/fixtures/install-tree/claude.json b/tests/fixtures/install-tree/claude.json index 4e6aac011..962d51c75 100644 --- a/tests/fixtures/install-tree/claude.json +++ b/tests/fixtures/install-tree/claude.json @@ -43,6 +43,7 @@ "gsd-core/bin/gsd_run", "gsd-core/bin/shared/config-defaults.manifest.json", "gsd-core/bin/shared/config-schema.manifest.json", + "gsd-core/bin/shared/exit-codes.json", "gsd-core/bin/shared/model-catalog.json", "gsd-core/bin/shared/runtime-aliases.manifest.json", "gsd-core/bin/verify-reapply-patches.cjs", diff --git a/tests/fixtures/install-tree/cline.json b/tests/fixtures/install-tree/cline.json index ef603bb3b..5ad1f0f91 100644 --- a/tests/fixtures/install-tree/cline.json +++ b/tests/fixtures/install-tree/cline.json @@ -45,6 +45,7 @@ "gsd-core/bin/gsd_run", "gsd-core/bin/shared/config-defaults.manifest.json", "gsd-core/bin/shared/config-schema.manifest.json", + "gsd-core/bin/shared/exit-codes.json", "gsd-core/bin/shared/model-catalog.json", "gsd-core/bin/shared/runtime-aliases.manifest.json", "gsd-core/bin/verify-reapply-patches.cjs", diff --git a/tests/fixtures/install-tree/codebuddy.json b/tests/fixtures/install-tree/codebuddy.json index 591df36ec..eb9bc144d 100644 --- a/tests/fixtures/install-tree/codebuddy.json +++ b/tests/fixtures/install-tree/codebuddy.json @@ -114,6 +114,7 @@ "gsd-core/bin/gsd_run", "gsd-core/bin/shared/config-defaults.manifest.json", "gsd-core/bin/shared/config-schema.manifest.json", + "gsd-core/bin/shared/exit-codes.json", "gsd-core/bin/shared/model-catalog.json", "gsd-core/bin/shared/runtime-aliases.manifest.json", "gsd-core/bin/verify-reapply-patches.cjs", diff --git a/tests/fixtures/install-tree/codex.json b/tests/fixtures/install-tree/codex.json index 9e18368e0..0ea041be2 100644 --- a/tests/fixtures/install-tree/codex.json +++ b/tests/fixtures/install-tree/codex.json @@ -79,6 +79,7 @@ "gsd-core/bin/gsd_run", "gsd-core/bin/shared/config-defaults.manifest.json", "gsd-core/bin/shared/config-schema.manifest.json", + "gsd-core/bin/shared/exit-codes.json", "gsd-core/bin/shared/model-catalog.json", "gsd-core/bin/shared/runtime-aliases.manifest.json", "gsd-core/bin/verify-reapply-patches.cjs", diff --git a/tests/fixtures/install-tree/copilot.json b/tests/fixtures/install-tree/copilot.json index c9575d03b..1f1797858 100644 --- a/tests/fixtures/install-tree/copilot.json +++ b/tests/fixtures/install-tree/copilot.json @@ -44,6 +44,7 @@ "gsd-core/bin/gsd_run", "gsd-core/bin/shared/config-defaults.manifest.json", "gsd-core/bin/shared/config-schema.manifest.json", + "gsd-core/bin/shared/exit-codes.json", "gsd-core/bin/shared/model-catalog.json", "gsd-core/bin/shared/runtime-aliases.manifest.json", "gsd-core/bin/verify-reapply-patches.cjs", diff --git a/tests/fixtures/install-tree/cursor.json b/tests/fixtures/install-tree/cursor.json index 4ee95925b..6a941d73b 100644 --- a/tests/fixtures/install-tree/cursor.json +++ b/tests/fixtures/install-tree/cursor.json @@ -43,6 +43,7 @@ "gsd-core/bin/gsd_run", "gsd-core/bin/shared/config-defaults.manifest.json", "gsd-core/bin/shared/config-schema.manifest.json", + "gsd-core/bin/shared/exit-codes.json", "gsd-core/bin/shared/model-catalog.json", "gsd-core/bin/shared/runtime-aliases.manifest.json", "gsd-core/bin/verify-reapply-patches.cjs", diff --git a/tests/fixtures/install-tree/hermes.json b/tests/fixtures/install-tree/hermes.json index 0cfba059d..c846c9ddd 100644 --- a/tests/fixtures/install-tree/hermes.json +++ b/tests/fixtures/install-tree/hermes.json @@ -43,6 +43,7 @@ "gsd-core/bin/gsd_run", "gsd-core/bin/shared/config-defaults.manifest.json", "gsd-core/bin/shared/config-schema.manifest.json", + "gsd-core/bin/shared/exit-codes.json", "gsd-core/bin/shared/model-catalog.json", "gsd-core/bin/shared/runtime-aliases.manifest.json", "gsd-core/bin/verify-reapply-patches.cjs", diff --git a/tests/fixtures/install-tree/kilo.json b/tests/fixtures/install-tree/kilo.json index dffe3f5a8..6376113f4 100644 --- a/tests/fixtures/install-tree/kilo.json +++ b/tests/fixtures/install-tree/kilo.json @@ -114,6 +114,7 @@ "gsd-core/bin/gsd_run", "gsd-core/bin/shared/config-defaults.manifest.json", "gsd-core/bin/shared/config-schema.manifest.json", + "gsd-core/bin/shared/exit-codes.json", "gsd-core/bin/shared/model-catalog.json", "gsd-core/bin/shared/runtime-aliases.manifest.json", "gsd-core/bin/verify-reapply-patches.cjs", diff --git a/tests/fixtures/install-tree/kimi-code.json b/tests/fixtures/install-tree/kimi-code.json index 2baec4f3b..fc9f06d9e 100644 --- a/tests/fixtures/install-tree/kimi-code.json +++ b/tests/fixtures/install-tree/kimi-code.json @@ -44,6 +44,7 @@ "gsd-core/bin/gsd_run", "gsd-core/bin/shared/config-defaults.manifest.json", "gsd-core/bin/shared/config-schema.manifest.json", + "gsd-core/bin/shared/exit-codes.json", "gsd-core/bin/shared/model-catalog.json", "gsd-core/bin/shared/runtime-aliases.manifest.json", "gsd-core/bin/verify-reapply-patches.cjs", diff --git a/tests/fixtures/install-tree/kimi.json b/tests/fixtures/install-tree/kimi.json index 6f0f77f27..6e077cb1d 100644 --- a/tests/fixtures/install-tree/kimi.json +++ b/tests/fixtures/install-tree/kimi.json @@ -80,6 +80,7 @@ "gsd-core/bin/gsd_run", "gsd-core/bin/shared/config-defaults.manifest.json", "gsd-core/bin/shared/config-schema.manifest.json", + "gsd-core/bin/shared/exit-codes.json", "gsd-core/bin/shared/model-catalog.json", "gsd-core/bin/shared/runtime-aliases.manifest.json", "gsd-core/bin/verify-reapply-patches.cjs", diff --git a/tests/fixtures/install-tree/opencode.json b/tests/fixtures/install-tree/opencode.json index 26c0878f7..23ac2b5cf 100644 --- a/tests/fixtures/install-tree/opencode.json +++ b/tests/fixtures/install-tree/opencode.json @@ -114,6 +114,7 @@ "gsd-core/bin/gsd_run", "gsd-core/bin/shared/config-defaults.manifest.json", "gsd-core/bin/shared/config-schema.manifest.json", + "gsd-core/bin/shared/exit-codes.json", "gsd-core/bin/shared/model-catalog.json", "gsd-core/bin/shared/runtime-aliases.manifest.json", "gsd-core/bin/verify-reapply-patches.cjs", diff --git a/tests/fixtures/install-tree/pi.json b/tests/fixtures/install-tree/pi.json index 3381edb75..c348715ce 100644 --- a/tests/fixtures/install-tree/pi.json +++ b/tests/fixtures/install-tree/pi.json @@ -10,6 +10,7 @@ "gsd-core/bin/gsd_run", "gsd-core/bin/shared/config-defaults.manifest.json", "gsd-core/bin/shared/config-schema.manifest.json", + "gsd-core/bin/shared/exit-codes.json", "gsd-core/bin/shared/model-catalog.json", "gsd-core/bin/shared/runtime-aliases.manifest.json", "gsd-core/bin/verify-reapply-patches.cjs", diff --git a/tests/fixtures/install-tree/qwen.json b/tests/fixtures/install-tree/qwen.json index fd484e528..624f986cb 100644 --- a/tests/fixtures/install-tree/qwen.json +++ b/tests/fixtures/install-tree/qwen.json @@ -43,6 +43,7 @@ "gsd-core/bin/gsd_run", "gsd-core/bin/shared/config-defaults.manifest.json", "gsd-core/bin/shared/config-schema.manifest.json", + "gsd-core/bin/shared/exit-codes.json", "gsd-core/bin/shared/model-catalog.json", "gsd-core/bin/shared/runtime-aliases.manifest.json", "gsd-core/bin/verify-reapply-patches.cjs", diff --git a/tests/fixtures/install-tree/trae.json b/tests/fixtures/install-tree/trae.json index ff63fb203..e5ba00517 100644 --- a/tests/fixtures/install-tree/trae.json +++ b/tests/fixtures/install-tree/trae.json @@ -43,6 +43,7 @@ "gsd-core/bin/gsd_run", "gsd-core/bin/shared/config-defaults.manifest.json", "gsd-core/bin/shared/config-schema.manifest.json", + "gsd-core/bin/shared/exit-codes.json", "gsd-core/bin/shared/model-catalog.json", "gsd-core/bin/shared/runtime-aliases.manifest.json", "gsd-core/bin/verify-reapply-patches.cjs", diff --git a/tests/fixtures/install-tree/windsurf.json b/tests/fixtures/install-tree/windsurf.json index 5cb2ca335..74da4432a 100644 --- a/tests/fixtures/install-tree/windsurf.json +++ b/tests/fixtures/install-tree/windsurf.json @@ -43,6 +43,7 @@ "gsd-core/bin/gsd_run", "gsd-core/bin/shared/config-defaults.manifest.json", "gsd-core/bin/shared/config-schema.manifest.json", + "gsd-core/bin/shared/exit-codes.json", "gsd-core/bin/shared/model-catalog.json", "gsd-core/bin/shared/runtime-aliases.manifest.json", "gsd-core/bin/verify-reapply-patches.cjs", diff --git a/tests/fixtures/install-tree/zcode.json b/tests/fixtures/install-tree/zcode.json index 92572569a..43e1d9aca 100644 --- a/tests/fixtures/install-tree/zcode.json +++ b/tests/fixtures/install-tree/zcode.json @@ -114,6 +114,7 @@ "gsd-core/bin/gsd_run", "gsd-core/bin/shared/config-defaults.manifest.json", "gsd-core/bin/shared/config-schema.manifest.json", + "gsd-core/bin/shared/exit-codes.json", "gsd-core/bin/shared/model-catalog.json", "gsd-core/bin/shared/runtime-aliases.manifest.json", "gsd-core/bin/verify-reapply-patches.cjs",