* enhance(#3911): give hooks an exit seam that needs no build ADR-3889 Phase 7 foundation. The 19 shipped enforcement hooks hold 91 of the epic's 128 terminators and cannot reach `terminateNow` today. The obvious route — requiring `gsd-core/bin/lib/cli-exit.cjs`, as gsd-agent-isolation-guard.js already does for two other modules — is rejected. That precedent carries its own warning (#3582): those files are tsc output, gitignored and absent on a raw plugin-marketplace or git-clone install, so the hook must first call ensureRuntimeBuild() to self-heal. Making the module a hook needs IN ORDER TO TERMINATE depend on a build inverts the dependency, and its failure mode is precisely the fail-open this phase exists to remove: a guard that cannot terminate cannot deny. `lint-hooks-runtime-build-seam` already encodes that concern, and Design B would have had to add an ensureRuntimeBuild() call to all 19 hooks to satisfy it. So `hooks/lib/` becomes a third emit location for cli-exit and a fifth for the registry, preserving the invariant `src/cli-exit.cts`'s own header states: it imports nothing but node:fs and its sibling registry, and the generator dual-emits that sibling alongside each copy so a relative require resolves next to whichever copy loaded it. Shipping needed no change — build-hooks.js already declares HOOKS_SUBDIRS_TO_COPY = ['lib']. Proven, not asserted: the two files are copied into an otherwise-empty tmpdir and a child process requires them and terminates — PASS exits 0, HOOK_DENY exits 2 with the payload on both stdout and stderr. That test fails the moment the hooks copy gains a require reaching outside hooks/lib/. Also fixed inline: the registry's fifth target let any `--write` test overwrite the real committed hooks/lib/exit-code-registry.js, because the test helper derived only three of the other output paths. It now redirects all five, and a regression test asserts every committed artifact is byte-identical after a redirected write. Install-tree goldens pick up the two new shipped paths across 11 runtimes — insertions only, no removals. lint:ci was green while they were stale, so this was found by regenerating rather than by a gate. Verification runs on the remote runner. Refs #3911 * enhance(#3911): declare a crash policy, and migrate the write guard Adds `hooks/lib/hook-exit.js` — the hook-facing vocabulary over `terminateNow`, hand-written because the cli-exit copy beside it is generated: allow(payload) exit 0 deny(payload, stderr?) exit 2 crash(onCrash, payload) whichever the hook DECLARED `crash()` takes the policy as a required argument with no default, which is the whole mechanism: fail-open by accident stops being expressible. A hook must name ALLOW or DENY at the call site, and an unrecognized value terminates INTERNAL rather than guessing. Fail-open stays legal; fail-open by omission does not. `gsd-write-guard.js` is the first hook migrated, all 12 sites, and it exposed a gap in the seam. `terminateNow`'s doc comment justified its fd-2 write by citing this hook's `emitBlock` — but modeled it as sending the same bytes to both streams, when `emitBlock` actually sends full JSON to stdout and only the bare `reason` string to stderr, because Kimi's hook bus feeds stderr verbatim back to the model. Migrating as written would have turned a readable sentence into a JSON blob for Kimi-backed agents. #3911 requires both "all 19 hooks terminate through terminateNow" and "no hook's effective default changes". Those are jointly satisfiable only by teaching the seam to carry a distinct stderr payload, so `terminateNow` gains an optional third argument: omitted, behavior is byte-for-byte what it was; a string is written raw, which is exactly the Kimi case. The doc comment's inaccurate claim about emitBlock is corrected in place. Proven rather than asserted: the pre-migration file is reconstructed from HEAD and driven with the same catastrophic-shrink payload as the migrated one — exit code, stdout and stderr all byte-identical. Verification runs on the remote runner. Refs #3911 * enhance(#3911): all 19 hooks terminate through the seam Migrates the remaining 18 enforcement hooks onto allow/deny/crash. An AST walk now reports zero `process.exit(` call sites across every `hooks/*.js` — down from the 91 the census measured. Each hook with an outer catch declares its policy once, at module top, with the reason that policy is right for that specific guard: a read guard that cannot scan must not block the read; a statusline that renders every prompt must degrade rather than crash; an injection scanner must not retroactively block a result already returned. Those sentences are the deliverable — they are what turns fail-open-by-accident into fail-open-on-purpose. No hook's effective default changed. Wiring exposed two defects, both fixed here rather than noted. A SECOND stdout/stderr-splitting site turned up in `gsd-workflow-guard.js`'s `emitForceAddBlock`, matching the pattern already known from the write guard — full JSON to stdout, bare reason to stderr for the Kimi bus. It uses the `stderrPayload` argument added in the previous commit, which is now carrying its second real caller rather than one special case. More seriously, `terminateNow` emitted both streams inside ONE try, so a payload that failed to serialize aborted before the stderr write ever ran. The two windsurf guards write nothing to stdout on a block and only a reason string to stderr, so `deny(undefined, reason)` exited 2 with EMPTY stderr — a deny that silently loses its reason, which is the exact "fails with success" class this epic exists to close. The streams are now emitted independently, each with its own guard, and `undefined` means "nothing to write for this stream" rather than an error. Regression tests inject a throwing write on one fd and assert the other still receives its payload; they fail against the single-try version. Byte-identity was proven per hook, not assumed: each pre-change file is reconstructed from HEAD and driven side by side with the migrated one across its normal path, its deny path, malformed stdin and empty stdin — exit code, stdout and stderr compared. Verification runs on the remote runner. Refs #3911 * enhance(#3911): harden the three shell hooks, and pin every hook's policy `gsd-phase-boundary.sh`, `gsd-session-state.sh` and `gsd-validate-commit.sh` gain `set -euo pipefail`. The expected hazard did not materialize, and that is worth recording: every intentionally-non-zero command in all three is already the condition of an `if`/`elif`, which `set -e` never fires on, and none of them reads a possibly-unset variable or pipes through a grep that may legitimately match nothing. No `|| true` guards were needed. Each hook was still checked command-by-command before the flags went in rather than after. Twenty-one before/after cases across the three hooks — disabled and enabled, planning and non-planning, missing STATE.md, malformed JSON, the Kimi payload shape, quoted and unquoted `-m`, valid and over-long Conventional Commits — all match on exit code, stdout and stderr. The hardening is shown to actually fire, not merely added: with a stubbed `node` that fails at the JSON-emit step, phase-boundary and session-state go from silently exiting 0 with empty stdout to failing visibly with the error surfaced. No such case could be constructed for `gsd-validate-commit.sh`, whose every statement already sits inside an if-condition — recorded as unproven rather than claimed. `tests/hooks-crash-policy.test.cjs` adds the per-hook coverage the issue asks for, table-driven over all 19 hooks rather than 76 hand-written cases: normal allow, deny where a deny path exists, crash-honors-the-declared-policy, and an unclosed-stdin case — the one `process.exitCode` structurally cannot serve. The deny assertions encode each hook's ACTUAL stream split rather than a uniform shape, since four of the six deliberately differ. A drift guard enumerates `hooks/*.js` and fails if a terminating hook is ever added without a row. Writing those tests surfaced two hooks that emit a block decision in their JSON body and exit 0. Both were checked rather than assumed, and neither is a fails-with-success: `gsd-read-injection-scanner.js` is PostToolUse, where the tool has already run and exit 2 has no meaning, and `gsd-cursor-subagent-start.js` follows Cursor's JSON-body protocol. They are deliberately left alone — a mechanical sweep to `deny()` would have broken exactly these two. Verification runs on the remote runner. Refs #3911 * fix(#3838): the commit validator says when it could not validate #3911 claims to subsume #3838. Measurement said otherwise, so this closes it for real rather than by assertion. `set -euo pipefail`, added earlier on this branch, does NOT fix #3838: bash exempts a command used as an `if` condition from `set -e`, and all three of the hook's swallow-and-pass sites are exactly that shape. Verified against the hardened hook with a node shim that fails only the classifier call — a non-conforming commit still exited 0 with empty stdout AND empty stderr, indistinguishable from "your commit conforms". That is the defect verbatim. All three sites named in #3838 now capture the real exit status instead of consuming it as a condition, and each distinguishes its genuine negative from "could not run": - the classifier: 0 = is a git commit, 1 = genuinely not one, anything else = could not classify. Its `node -e` now wraps the require and the call in try/catch and exits 3 on a throw, so a broken require chain can never be mistaken for `isGitSubcommand` legitimately returning false — which is the arm that matters, since `token-scanner.cjs` is a gitignored build artifact and a fresh checkout lands there. - the opt-in config read and the JSON command extraction get the same treatment. On "could not run" the hook emits a diagnostic to stderr naming which check failed and why, then exits 0. The issue confirms this is safe — it is a PreToolUse hook, so stderr does not disturb the JSON protocol — and ranks it the smallest sufficient fix. The gate still fails open, but it can no longer do so silently, which is the whole complaint: a validator that disables itself quietly costs more than one that is absent, because it is trusted. Both controls are unchanged and pinned by tests: a conforming commit still passes silently, a non-conforming one still exits 2 with its existing block payload. The defect test asserts stderr is non-empty and names the failure; it fails against the pre-fix hook. Verification runs on the remote runner. Refs #3911, #3838 * docs(#3911): document the hook crash-policy contract Reference and Explanation via a new docs/features fragment (FEATURES.md is generated from it), INVENTORY rows for the three new hooks/lib files, and an ARCHITECTURE note on the hooks section. How-To: docs/how-to/declare-a-hook-crash-policy.md, indexed from docs/README.md — a hook author now has to choose and declare a crash policy, which is more than one step and crosses into which harness protocol their hook speaks. It covers allow/deny/crash, writing an ON_CRASH reason that is actually useful, when a deny needs a distinct stderr payload, the two hooks whose harness reads a JSON-body decision and must NOT use deny(), and what to do when a check cannot run at all — with #3838 as the worked example. Refs #3911 * test(#3911): prove the seam actually ships, and stop hand-rolling temp cleanup Two review findings. The acceptance criterion 'hooks/dist/** stays in parity via the build seam (lint:hooks-runtime-build-seam)' was misstated and unmet: that lint checks something else — that a hook requiring a compiled gsd-core/bin/lib module also calls ensureRuntimeBuild(). Nothing exercised that the three new hooks/lib files reach hooks/dist/lib at all. That gap is not theoretical: #770 is a recorded ship-blocking bug where a new hook never shipped because a copy list missed it. The suite now builds dist through the repo's own ensureBuiltHooks(), byte-compares each shipped copy against its source, and spawns a child that requires the SHIPPED dist copy and denies — which is what catches a copy that exists but cannot resolve its sibling registry. gsd-validate-commit.sh hand-duplicated mktemp/run/rm three times; one idempotent trap on EXIT replaces them, guarded so cleanup cannot alter the exit status. Behavior-neutral across five cases, with temp-file counts taken before and after each run. Refs #3911 * fix(#3911): stage transitive hook lib requires, not just one level The remote run returned 7 failures across 3 real causes. The important one is a PRODUCTION bug this phase exposed rather than caused. `writeCursorHooksJson` scanned each hook script for `./lib/X` requires exactly one level deep and never re-scanned the lib files it staged for their own sibling requires. Nothing had a transitive lib dependency before, so the gap was invisible. Adding hook-exit.js -> cli-exit.js -> exit-code-registry.js made real Cursor installs ship a bundle that dies at require time with MODULE_NOT_FOUND. It now walks to a fixed point, and a real installed Cursor hook runs to completion. The staging harness in shared-hooks-dir-resolution hand-copied its fixture, so the injection scanner crashed at require time and its exit-1 was being read as a policy decision. Migrated to copyScriptWithDeps, which walks the require graph — the repo's recorded rule for this class, since adding another copyFileSync keeps it alive for the next person. The missing-lib-source test in cursor-hook-workspace-roots hardcoded which lib file it expected to be named in the abort message; the same throw now fires for a different file first. Its assertion is unchanged in substance — staging still must abort rather than ship a broken hook — only the name is no longer pinned. The last one was my own test asserting an uppercase reason code. Measured against origin/next: the pre-change hook emits the same lowercase 'config_unreadable', so the test was wrong, not the migration. Corrected to the real value rather than making the code match the test. Verification runs on the remote runner. Refs #3911 * chore(#3911): regenerate the cursor install-tree golden The staging fix means a Cursor install now correctly carries the two transitive lib files it was silently missing. Additive only — no path was removed. The golden diff is the evidence the packaging defect was real. Refs #3911 * chore(#3911): backfill the changeset PR number Refs #3911 * fix(#3911): a git probe that timed out is not a negative A macOS CI lane failed three deny cases at 2084ms, 2112ms and 2177ms — just past the 2000ms budget these hooks give their git probes. The three that passed took 72ms, 595ms and 651ms. Under shard contention `git rev-parse` overruns, the hook reads the non-zero result as "not a git repo", and allows with exit 0 and empty stdout AND empty stderr. Under load, the guards silently stop guarding. That is ADR-3889's thesis exactly, sitting inside the security hooks this phase is about. The repo had already recognized the class in one place — gsd-cursor-subagent-start.js fail-closed-denies on `git_timed_out` (#3045) — but nowhere else. `hooks/lib/git-probe.js` classifies a probe's outcome, distinguishing a real non-zero exit from ETIMEDOUT, a signal kill, and a spawn failure, rather than folding all four into `status !== 0`. Three guards route their eight git probes through it. The resolution is the same shape #3838 took, and the same one that issue endorsed as smallest-sufficient: fail open, but loudly. **No exit code changes on any path** — a developer on a loaded machine is still not blocked, which keeps #3911's declaration-pass contract intact for exit codes. What changes is that the hook now says on stderr which probe could not answer, instead of presenting silence as a clean verdict. Scope was checked across every hooks/*.js, not just the three that failed: gsd-agent-isolation-guard spawns no git; gsd-statusline's two probes gate only a cosmetic display segment, not an allow/deny decision, and are left alone. The C2 deny assertion was a real-race test — it demanded exit 2 while a slow git legitimately yields 0. It now requires the hook to either deny, or allow with a diagnostic naming the probe that could not run; a silent allow still fails, so the assertion is not vacuous. A deterministic regression stubs git on PATH to sleep past the budget rather than waiting for load to reproduce it. Verification runs on the remote runner. Refs #3911 * test(#3911): a PATH shim cannot intercept the hooks' git spawn on Windows The deterministic timeout regression stubbed git on PATH and asserted the guard reports rather than silently allows. It passes on Linux and macOS and failed on Windows in 83ms and 176ms — the stub was never invoked at all. Mechanism: the hooks call spawnSync('git', args) with no shell:true, so on Windows CreateProcess resolves git.exe only and never a PATH .cmd shim. The git.cmd branch could not have worked and is removed rather than left implying a Windows path that does. Adding shell:true to the hooks to serve a test would change product behavior and widen an injection surface, so the case is skipped on win32 only, with the mechanism written into the skip reason so a future reader does not 'fix' it that way. Linux and macOS keep the coverage, and macOS is where the underlying fail-open was actually caught. Refs #3911 --------- Co-authored-by: sim <sim@local>
721 lines
31 KiB
JavaScript
721 lines
31 KiB
JavaScript
'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 REAL_SCRIPTS_ARTIFACT_PATH = path.join(REPO_ROOT, 'scripts', 'lib', 'exit-code-registry.cjs');
|
|
const REAL_HOOKS_ARTIFACT_PATH = path.join(REPO_ROOT, 'hooks', 'lib', 'exit-code-registry.js');
|
|
const REAL_DTS_ARTIFACT_PATH = path.join(REPO_ROOT, 'src', 'exit-code-registry.d.cts');
|
|
const REAL_SH_ARTIFACT_PATH = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared', 'exit-codes.sh');
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* #3906 (ADR-3889 Phase 2): the generator now emits FIVE artifacts — a
|
|
* primary (gsd-core/bin/lib), a secondary (scripts/lib), and the ambient
|
|
* `.d.cts` type declaration (src/exit-code-registry.d.cts). #3908 (Phase 4)
|
|
* added a FOURTH: the shell-sourceable fragment (gsd-core/bin/shared/
|
|
* exit-codes.sh). #3911 (Phase 7) added a FIFTH: the hooks/lib/ copy
|
|
* (hooks/lib/exit-code-registry.js). Every existing call site below only
|
|
* overrides the PRIMARY path via `--out`; without matching
|
|
* `--scripts-out`/`--hooks-out`/`--dts-out`/`--sh-out` overrides, a
|
|
* `--write` here would clobber the real committed
|
|
* `scripts/lib/exit-code-registry.cjs`, `hooks/lib/exit-code-registry.js`,
|
|
* `src/exit-code-registry.d.cts`, and `gsd-core/bin/shared/exit-codes.sh` —
|
|
* dangerous since test files in this repo run in parallel. Rather than
|
|
* touch every call site, this single seam derives co-located,
|
|
* per-call-unique secondary/hooks/dts/sh paths from whatever `--out` value
|
|
* the test already supplies, whenever the caller has not already supplied
|
|
* its own `--scripts-out`/`--hooks-out`/`--dts-out`/`--sh-out`. Calls with
|
|
* no explicit `--out` (the "real committed set" checks) are left untouched.
|
|
*/
|
|
function ensureScriptsOut(args) {
|
|
const outIdx = args.indexOf('--out');
|
|
if (outIdx === -1) return args;
|
|
const outValue = args[outIdx + 1];
|
|
const extra = [];
|
|
if (!args.includes('--scripts-out')) extra.push('--scripts-out', `${outValue}.secondary.cjs`);
|
|
if (!args.includes('--hooks-out')) extra.push('--hooks-out', `${outValue}.hooks.js`);
|
|
if (!args.includes('--dts-out')) extra.push('--dts-out', `${outValue}.d.cts`);
|
|
if (!args.includes('--sh-out')) extra.push('--sh-out', `${outValue}.sh`);
|
|
return extra.length === 0 ? args : [...args, ...extra];
|
|
}
|
|
|
|
function runGen(args, opts = {}) {
|
|
return runNode([GEN_SCRIPT, ...ensureScriptsOut(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 set (both .cjs artifacts and the .d.cts)', () => {
|
|
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);
|
|
});
|
|
|
|
// Review finding (#3906 follow-up): the ambient .d.cts was hand-maintained
|
|
// with no gate verifying it against serializeRegistry()'s actual shape.
|
|
// These pin the SAME write/check/drift contract already proven for the two
|
|
// .cjs artifacts above, but for the .d.cts specifically.
|
|
test('--write emits a matching .d.cts artifact', () => {
|
|
const decl = validDeclarationPath(tmpDir, 'dts-decl.json');
|
|
const out = path.join(tmpDir, 'dts-out.cjs');
|
|
const dtsOut = path.join(tmpDir, 'dts-out.d.cts');
|
|
const write = runGen(['--write', '--declaration', decl, '--out', out, '--dts-out', dtsOut]);
|
|
assert.equal(write.exitCode, 0, write.stderr);
|
|
assert.ok(fs.existsSync(dtsOut), 'expected the .d.cts artifact to be written');
|
|
const dtsContent = fs.readFileSync(dtsOut, 'utf8');
|
|
assert.ok(dtsContent.includes('export interface ExitCodeEntry'));
|
|
assert.ok(dtsContent.includes('export = exitCodeRegistry;'));
|
|
|
|
const check = runGen(['--check', '--declaration', decl, '--out', out, '--dts-out', dtsOut]);
|
|
assert.equal(check.exitCode, 0, check.stderr);
|
|
});
|
|
|
|
test('--check on a hand-edited .d.cts artifact -> DRIFTED', () => {
|
|
const decl = validDeclarationPath(tmpDir, 'dts-drift-decl.json');
|
|
const out = path.join(tmpDir, 'dts-drift-out.cjs');
|
|
const dtsOut = path.join(tmpDir, 'dts-drift-out.d.cts');
|
|
assert.equal(runGen(['--write', '--declaration', decl, '--out', out, '--dts-out', dtsOut]).exitCode, 0);
|
|
fs.appendFileSync(dtsOut, '\n// hand-edited, drifts from generated content\n');
|
|
const { result, report } = runGenJson(['--check', '--declaration', decl, '--out', out, '--dts-out', dtsOut]);
|
|
assert.equal(result.exitCode, 1);
|
|
assert.equal(report.ok, false);
|
|
assert.equal(report.reason, generator.REASON.DRIFTED);
|
|
assert.equal(report.context.artifact, 'dts');
|
|
});
|
|
|
|
test('--check with the .d.cts artifact absent -> MISSING_ARTIFACT', () => {
|
|
const decl = validDeclarationPath(tmpDir, 'dts-missing-decl.json');
|
|
const out = path.join(tmpDir, 'dts-missing-out.cjs');
|
|
const dtsOut = path.join(tmpDir, 'dts-missing-out.d.cts');
|
|
const { result, report } = runGenJson(['--check', '--declaration', decl, '--out', out, '--dts-out', dtsOut]);
|
|
assert.equal(result.exitCode, 1);
|
|
assert.equal(report.ok, false);
|
|
assert.equal(report.reason, generator.REASON.MISSING_ARTIFACT);
|
|
assert.equal(fs.existsSync(dtsOut), false);
|
|
});
|
|
|
|
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'));
|
|
// 81, not 80: the real declaration already allocates 80 to DEGRADED, and
|
|
// this fixture copies the REAL declaration (validDeclarationPath) — an
|
|
// appended entry must pick a code neither of the two committed entries
|
|
// already own, or the generator correctly reports fail_duplicate_code
|
|
// instead of the DRIFTED this test means to exercise.
|
|
entries.push({ code: 81, 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);
|
|
});
|
|
|
|
// Regression (#3911 follow-up): ensureScriptsOut derived --scripts-out/
|
|
// --dts-out/--sh-out from --out but did not derive --hooks-out, so any
|
|
// --write test here silently clobbered the real committed
|
|
// hooks/lib/exit-code-registry.js. Assert over ALL FIVE committed
|
|
// artifacts so the next added target is covered by construction.
|
|
test('a --write run redirected to a tmpdir leaves every committed artifact untouched', () => {
|
|
const before = {
|
|
out: fs.readFileSync(REAL_ARTIFACT_PATH, 'utf8'),
|
|
scripts: fs.readFileSync(REAL_SCRIPTS_ARTIFACT_PATH, 'utf8'),
|
|
hooks: fs.readFileSync(REAL_HOOKS_ARTIFACT_PATH, 'utf8'),
|
|
dts: fs.readFileSync(REAL_DTS_ARTIFACT_PATH, 'utf8'),
|
|
sh: fs.readFileSync(REAL_SH_ARTIFACT_PATH, 'utf8'),
|
|
};
|
|
|
|
const decl = validDeclarationPath(tmpDir, 'l-decl.json');
|
|
const out = path.join(tmpDir, 'l-out.cjs');
|
|
const write = runGen(['--write', '--declaration', decl, '--out', out]);
|
|
assert.equal(write.exitCode, 0, write.stderr);
|
|
|
|
assert.equal(fs.readFileSync(REAL_ARTIFACT_PATH, 'utf8'), before.out, 'primary artifact must be untouched');
|
|
assert.equal(fs.readFileSync(REAL_SCRIPTS_ARTIFACT_PATH, 'utf8'), before.scripts, 'scripts artifact must be untouched');
|
|
assert.equal(fs.readFileSync(REAL_HOOKS_ARTIFACT_PATH, 'utf8'), before.hooks, 'hooks artifact must be untouched');
|
|
assert.equal(fs.readFileSync(REAL_DTS_ARTIFACT_PATH, 'utf8'), before.dts, '.d.cts artifact must be untouched');
|
|
assert.equal(fs.readFileSync(REAL_SH_ARTIFACT_PATH, 'utf8'), before.sh, '.sh artifact must be untouched');
|
|
|
|
assert.ok(fs.existsSync(`${out}.hooks.js`), 'expected the redirected hooks copy to land in the tmpdir');
|
|
assert.ok(fs.existsSync(`${out}.secondary.cjs`), 'expected the redirected scripts copy to land in the tmpdir');
|
|
assert.ok(fs.existsSync(`${out}.d.cts`), 'expected the redirected .d.cts copy to land in the tmpdir');
|
|
assert.ok(fs.existsSync(`${out}.sh`), 'expected the redirected .sh copy to land in the tmpdir');
|
|
});
|
|
});
|
|
|
|
// ── 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 },
|
|
);
|
|
});
|
|
});
|