* feat(#3905): the exit-code registry — one number, one meaning, enforced at build A generated registry replaces locally-invented exit codes. Every entry records code, name, meaning, owning module and the decision that authorized it. The generator refuses to build a table where two entries claim one code, two claim one name, a code falls in a range Node or the shell reserves, 2 is claimed by anything but the hook adapter, or an allocation carries no justification. exitCodeFor is pure and total: it throws rather than returning undefined, including for prototype-chain names. Inert by design — nothing emits a registered code until #3906. Every registered code is non-zero, asserted over the whole table, so a caller testing for failure behaves identically for pass and trips for everything else. * feat(#3905): make the registry generator's failures machine-readable Adds a --json mode carrying {ok, reason, context, detail}, where context is a typed payload naming the specifics the prose embedded - which code collided and under which names, which band rejected a code, which field was missing. The tests now assert on that structure instead of regex-matching the generator's stderr, which CONTRIBUTING prohibits, and the CONTEXT.md glossary gains the entry the issue's scope requires. * test(#3905): refresh the install-tree fixtures for the new declaration The registry declaration ships in the install tree, so all 19 golden fixtures needed regenerating. Caught by the remote matrix, not by lint:ci - the install-tree goldens are verified by a test rather than a lint, so a newly shipped file clears every local gate and fails only under the suite. * chore(#3905): backfill changeset pr number --------- Co-authored-by: sim <sim@local>
This commit is contained in:
5
.changeset/sturdy-deer-frolic.md
Normal file
5
.changeset/sturdy-deer-frolic.md
Normal file
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) |
|
||||
|
||||
87
gsd-core/bin/lib/exit-code-registry.cjs
Normal file
87
gsd-core/bin/lib/exit-code-registry.cjs
Normal file
@@ -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 };
|
||||
7
gsd-core/bin/shared/exit-codes.json
Normal file
7
gsd-core/bin/shared/exit-codes.json
Normal file
@@ -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" }
|
||||
]
|
||||
@@ -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",
|
||||
|
||||
519
scripts/gen-exit-code-registry.cjs
Normal file
519
scripts/gen-exit-code-registry.cjs
Normal file
@@ -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 <path> --out <path> # 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 <path>] [--out <path>] [--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,
|
||||
};
|
||||
606
tests/exit-code-registry.test.cjs
Normal file
606
tests/exit-code-registry.test.cjs
Normal file
@@ -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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
1
tests/fixtures/install-tree/antigravity.json
vendored
1
tests/fixtures/install-tree/antigravity.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/augment.json
vendored
1
tests/fixtures/install-tree/augment.json
vendored
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/claude.json
vendored
1
tests/fixtures/install-tree/claude.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/cline.json
vendored
1
tests/fixtures/install-tree/cline.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/codebuddy.json
vendored
1
tests/fixtures/install-tree/codebuddy.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/codex.json
vendored
1
tests/fixtures/install-tree/codex.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/copilot.json
vendored
1
tests/fixtures/install-tree/copilot.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/cursor.json
vendored
1
tests/fixtures/install-tree/cursor.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/hermes.json
vendored
1
tests/fixtures/install-tree/hermes.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/kilo.json
vendored
1
tests/fixtures/install-tree/kilo.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/kimi-code.json
vendored
1
tests/fixtures/install-tree/kimi-code.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/kimi.json
vendored
1
tests/fixtures/install-tree/kimi.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/opencode.json
vendored
1
tests/fixtures/install-tree/opencode.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/pi.json
vendored
1
tests/fixtures/install-tree/pi.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/qwen.json
vendored
1
tests/fixtures/install-tree/qwen.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/trae.json
vendored
1
tests/fixtures/install-tree/trae.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/windsurf.json
vendored
1
tests/fixtures/install-tree/windsurf.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/zcode.json
vendored
1
tests/fixtures/install-tree/zcode.json
vendored
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user