* feat(#3987): guard slug re-derivation, and record why the swallow shape cannot be guarded Epic #3473's Decision 1 requires the wrong call site be UNREPRESENTABLE. #3984 measured that two of the nine §8 rules had no guard at all and recorded both as "Shipped - test-covered". This closes one of them, proves the other cannot be closed the same way, and corrects two false claims I merged yesterday. 1. §8.3 - scripts/lint-slug-derivation-drift.cjs. generateSlugInternal (src/core-utils.cts) is the canonical owner; #3883 removed 11 inline copies. Nothing prevented a twelfth: no slug guard existed in scripts/ or eslint-rules/. The detector is STATEMENT-scoped and matches the shape the real copies took - one statement carrying BOTH .replace(<negated class>, '-') and .replace(/^-+|-+$/, ''). Statement scoping is what buys the precision: the loose LINE-level form yields 18 hits with 7 unrelated, a material false-positive rate. Measured on the tree: 5 flags, 2 TRUE, 3 SANCTIONED, 0 FALSE. The three sanctioned sites are allowlisted with a reason each, following lint-phase-enumeration-drift's form rather than a bare denylist. The owner itself is listed explicitly even though it escapes by construction - an implicit escape is a latent bug, and the next person to touch line 192 would not know the guard depended on it. 2. Both TRUE positives were live defects, not style. scripts/qa-smell-ratchet.cjs reproduced the canonical formula including the 60-cap but trimmed BEFORE truncating - the #2849 bug - and never transliterated. The divergence is total, not cosmetic: canonical "privet-mir-privet-mir-privet-mir-privet-mir-privet-mir-prive" inline "tail" Cyrillic collapsed to nothing and only the ASCII remainder survived, so the ratchet was keying on wrong identifiers for any non-ASCII input. tests/planning-inspect.test.cjs carried a helper whose comment claimed parity with getPhaseDirFromPhaseId. That function now transliterates; the helper did not, so the test asserted against a stale formula while looking correct. Both now route through the seam. 3. §8.5 - measured, and deliberately NOT shipped. A candidate detector (swallowing catch + errno-retry-set test in the same function) gives 26 flags across 11 functions: 0 TRUE, 26 FALSE. Every one is best-effort unlink/rm/close cleanup, lost-rename-race backoff, or a deliberate null fallback. The file-scoped variant is worse at 71. Worse than the noise: the only known true instance was removed by #3885, so there is NO POSITIVE CONTROL - the guard cannot be shown capable of failing, which this repo requires of every drift guard. Shipping it would add a guard nobody can trust and nobody can test. The ADR now records the measurement and the reason, keeps §8.5 at "Shipped - test-covered", and points at the #1884 regression test as what actually enforces it. An honest "not detectable at acceptable precision" beats a guard that only ever passes. 4. Two claims I merged into the ADR yesterday were wrong. §8.9 said 17 of 19 subsumed children have a test citing their issue number, and that #3364 and #3812 have none. Both halves are false, and the claim came from a NUMBER-GREP - inside an amendment whose own subject is that a text match is not a fact. #3364 IS cited: tests/runtime-marker-resolution.test.cjs:107, T3 installMarkerResolvesWhenEnvAndConfigAbsent_3897 (#3364), asserting at :115-119. #3812 IS covered: tests/gen-state-md-docs.test.cjs:374, asserting at :382. Corrected to 19 of 19. #3812 does carry a real finding, though a different one: it is PARTIALLY DELIVERED on a CLOSED issue. The shipped fix declares cardinality for frontmatter keys, but #3812's stated acceptance was about the ## Current Position BODY section, and docs/reference/state-md.md:196-208 still has no normative single-valued/overwrite sentence and no pointer to ## Performance Metrics for history. Recorded in the ADR and left for #3812 to re-open - fixing it here would bury a scope question inside an unrelated PR. Note on B6: this ADDS a guard, and B6 said the net count must fall. #3951 already amended that clause - a guard ledger is a claim about COVERAGE, not count - which is what makes adding this one honest rather than contradictory. Verified: the guard flags 0 on the fixed tree, and PROVES IT CAN FAIL - a fresh inline copy planted in src/ makes it exit 1 naming the exact statement. All three sanctioned sites were confirmed exempt BY the allowlist, not by accident of the pattern, by re-attributing each to a non-exempt path and watching it flag. build:lib, lint and lint:ci all exit 0. Refs #3987 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#3987): add the changeset fragment Doc-only, so it carries forward from the verified sha rather than costing a second matrix run. Refs #3987 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3987): §8.5 IS guardable — I was wrong, and the guard found a live defect Two orthogonal reviews. The correctness review overturned my central judgment, and it was right. 1. I concluded §8.5 was "not detectable at acceptable precision" and recorded that in the ADR. False. My evidence was 26 flags / 0 TRUE / 26 FALSE. The reviewer pointed out what I had not: all 26 false positives are CLEANUP verbs - rmSync 54, unlinkSync 43, closeSync 17, chmodSync 12 - and the obvious narrower predicate was never tried. A swallowed cleanup is legitimate best-effort. A swallowed CREATION is a precondition silently lost, which is exactly the #1884 shape. Measured properly, in three stages: swallowing catch 911 + try-block calls a CREATION verb 24 + enclosing function references a *_ERRNOS set 0 0 flags, 0 false positives. The `*_ERRNOS` naming key is empirically total - all 10 retry/tolerate sets in src/ follow it. My second claim was worse. I wrote that no positive control exists because #3885 removed the only true instance, so the guard "cannot be shown capable of failing". That is self-refuting: this very PR's slug guard proves-it-can-fail on a synthetic tree, and the pre-#3885 blob is available as exactly such a fixture. It is now the control, and it works in both directions - the rule flags 0c43d853e^:src/planning-workspace.cts at line 210, the line the fix commit's own message cites, and reports zero on the post-fix code. I stopped at the first negative result on the option that meant less work. Shipped as eslint-rules/no-swallowed-precondition.cjs, wired into the existing src/**/*.cts ESLint block rather than a scripts/lint-*-drift.cjs: no script in scripts/ requires typescript/espree/acorn, and scripts/ ships to consumers, so a .cts-parsing standalone guard would add a devDep at consumer runtime. The ESLint block already parses .cts for free. 2. The guard immediately found a live defect of the same class. src/capability-lock.cts swallowed a mkdirSync on the lock directory, then acquireLock classified the follow-on failure as `code !== 'EEXIST' → return null`. A real EACCES/EROFS makes openSync(lockPath,'wx') fail ENOENT, which is not EEXIST - so a fatal filesystem error was laundered into "lock unavailable". Same defect as #1884, different laundering target. Fixed the way #3885 fixed #1884: the creation failure propagates. Regression test proven fail-first by hand - with the fix stashed, EACCES was laundered to null; restored, it throws. The strict rule does NOT catch this shape (its errno classification is an inline literal, not a named set). The rule is deliberately left strict: the broadened form had 2 false positives - capability-lock.cts:408, the deliberate EEXIST steal protocol, and commonjs-marker.cts:131, which returns a distinct documented outcome. The gap is noted in code rather than papered over with a noisy predicate. 3. The security review found the slug guard's exemption FAILED OPEN. currentFunction was never reset, and only a column-0 `function` declaration updated it, so exemption bled from an allowlisted declaration to the next one. generateSlugInternal exempted 50 lines for an 11-line function. A re-derivation planted anywhere in that window was silently exempt - the same fail-open shape that produced a blocker in #3897, and an allowlist is a SUBTRACTION so a mismatch fails open by construction. Extent is now tracked by real brace depth, and a test plants a violation after each allowlisted function's real closing brace and asserts it IS flagged. 4. Also from the security review: the guard was a CI-DoS and narrower than I claimed. Its unbounded [^\]]* was re-scanned from every `.replace(/[^` start: 54.3s on a 1.28MB line. It imported MAX_REGEX_LITERAL_LEN and never called readRegexLiteralAt - the bounded tokenizer that exists for exactly this. Now routed through it with a 2MB file cap: ~200ms. 15 of 25 genuine re-derivations evaded. Widened to catch replaceAll, {1,}, \s*-wrapped classes, escaped ], literal new RegExp(...), five trim spellings, .split().join(), and multi-line .replace( args - still 0 false positives. Two forms still evade and are documented as deliberate gaps with negative tests: the two-statement/temp-var form and new RegExp built from a variable. Both need data flow, and guessing at it is how a guard becomes noisy. Also fixed: // inside a string truncated the line, a ; inside the collapse regex split the statement (a one-character bypass), and SCAN_EXT omitted .mjs/.tsx/.jsx. 5. A regression I introduced, caught by the same review. qa-smell-ratchet.cjs top-level-required a build output that is not git-tracked, so the script hard-failed MODULE_NOT_FOUND before build:lib - including for --help, which previously had no build dependency. The require is now lazy at the point of use. 6. Four of my own tests were vacuous or weak. T9's input yielded an identical string under the buggy formula, so it passed on the implementation it was meant to catch. T12 compared maxLen null vs 60 on an 18-char name, where they agree trivially. T9-T12 all asserted generateSlugInternal directly, so they would pass unchanged if both call-site fixes were reverted. And prove-it-can-fail was scoped to scanRepo, never the CLI - dropping main()'s exit-code line would have kept every row green. All rewritten with discriminating inputs, per-call-site rows that red when the fix is reverted, 59/60/61 boundaries, an entirely-non-alphanumeric row, and a CLI row asserting the real subprocess exit code and both sanitizeForReport sites. Verified: both guards flag 0 on the tree and both prove they can fail. The swallow rule's control is confirmed in both directions - pre-#1884 shape flagged, post-#3885 shape clean. build:lib, lint and lint:ci all exit 0. Refs #3987 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#3987): record that §8.5 IS guardable, and correct a correction that made a ledger worse Three ADR corrections, two of them to text this branch wrote hours ago. §8.5 advances to Enforced. Its previous entry said the rule was not detectable at acceptable precision. That was wrong twice: the 26 false positives were uniformly CLEANUP verbs, which is a reason to narrow the predicate rather than abandon it, and the claim that no positive control exists was self-refuting - the pre-#3885 blob is available as a fixture and this repo's own guards prove-it-can- fail on synthetic trees. Narrowed to creation verbs plus a *_ERRNOS reference: 911 -> 24 -> 0 flags, 0 false positives, control confirmed in both directions. The entry keeps the wrong reasoning visible, because a high false-positive count being evidence the predicate is wrong - not evidence the rule is unguardable - is the transferable part, and the first negative result is most seductive when it is also the answer that means less work. §8.9's correction is itself corrected. The original 17-of-19 claim was CORRECT for the predicate it stated; this branch silently swapped cited -> covered and declared 19 of 19. #3812 appears in zero test files. Changing what a word means to make a ledger read better is a worse failure than the miscount it claimed to repair. Both predicates are now reported separately - 18 of 19 cited, 19 of 19 covered - because §8.9 asks for a test NAMING each child, so 18 is the number that answers it. #3812 is also re-opened for real, rather than the first draft's promise that it could be. §8.3 stays Shipped - test-covered rather than advancing. The slug guard catches the copy-paste class and a dozen variants, but two forms still evade by decision (temp-var split, new RegExp from a variable) because both need data flow. Naming them keeps the status honest: the wrong call site is much harder to write, not unrepresentable. Closes #3987 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#3987): backfill changeset pr number Refs #3987 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3987): replace my own wall-clock assertion, and close the guard that let me write it CI went red on ubuntu shard 2/3. The failing test was mine, and the failure was the test, not the code. a 1.28MB line ... scans in well under a second (was 54.3s pre-fix) 7368ms It asserted ELAPSED TIME. ~200ms locally, 7.4s on a shared CI runner. The bound introduced for the MAJOR-2 DoS fix works - 7.4s against a 54.3s pre-fix baseline is the fix doing its job - but an absolute wall-clock threshold on shared hardware is a race, not an assertion. CLAUDE.md says so directly: "Clock Seams: Do not assert on wall-clock time." I wrote the anti-pattern the project bans, in a PR about guards. Raising the threshold would only move the flake. The row now asserts a DETERMINISTIC bound instead: an instrumentation seam on drift-scan.cjs counts readRegexLiteralAt calls and characters examined, and the test asserts charsExamined stays under an absolute ceiling. Measured on the same 1.28MB fixture: 120,000 calls, 48,000,000 chars - two orders under the ceiling. The pathological fixture is kept; only the thing being asserted changed. Proven to still discriminate: with MAX_REGEX_LITERAL_LEN raised to simulate the unbounded pre-fix behavior, the same fixture does not complete in 120 seconds, versus ~0.3s bounded. It is a real regression test, not a tautology. Then the second half, which is the same defect class as the rest of this PR. eslint-rules/no-elapsed-assertion.cjs matched only the EXACT identifiers ^(elapsed|duration|took|ms)$. I used `elapsedMs`. It evaded the rule entirely. tookMs, durationMs, elapsedTime and msElapsed evade the same way. A guard that cannot see the violation it exists to catch is exactly what this PR is about - it just happened to be an existing rule rather than one of the two I came here for, and it was found because I committed the violation it should have blocked. Widened to /^(?:elapsed|duration|took|ms)(?:[A-Z]\w*)?$/ plus a narrow start/endMs delta pair. Deliberately NOT a blanket *Ms suffix: a first draft did that and produced 2 false positives on `timeoutMs` in plan-phase-stall-detection, which is a configured timeout and not a measurement. Verified negative on params, items, forms, terms, dirnames, timeoutMs, cacheTtlMs and staleAfterMs. Measured over the five files carrying camelCase timing identifiers: 0 true positives beyond my own, so nothing else needed rewriting. The rule's own test file gains a row asserting `elapsedMs` flags, proven to fail against the pre-widening rule - the same prove-it-can-fail standard both new guards in this PR are held to. Refs #3987 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3987): a comment I added leaked a Claude reference into every runtime install The runner went red with 4 failures in tests/install.test.cjs: Leaking: .hermes/scripts/lib/drift-scan.cjs Leaking: .qwen/scripts/lib/drift-scan.cjs The instrumentation seam added for the deterministic bound carried a comment naming CLAUDE.md as the source of the no-wall-clock-assertions rule. scripts/ SHIPS to consumers, so that comment was installed verbatim into hermes and qwen trees, and the install suite scans for exactly this - a Claude-specific reference reaching a non-Claude runtime. The rule is real and worth citing; the filename is not portable. The comment now says "this repo's test rules" and states the rule inline, which is what a reader of an installed tree actually needs anyway. Worth noting what caught it: not lint, and not the two guards this PR adds - the install suite's full-tree scan, which exists precisely because a shipped file is read by runtimes that have never heard of CLAUDE.md. Same lesson as the rest of this PR from the other direction: the check that matters is the one that can see the surface where the defect actually lands. Refs #3987 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3987): a test fixture swallowed 46 git exit codes and produced a silent false negative CI red on ubuntu shard 3/3: tests/health-validation.test.cjs:2029 expected exactly one W024, got [{"code":"W006", ...}] Not caused by this branch, and the evidence is decisive rather than a hunch: the SIBLING test at :2039 builds the IDENTICAL fixture with the identical commitsAhead and asserts the same thing, and it PASSED in the same process, same file, same run. Same input, both outcomes - which rules out logic, ordering, sharding and environment, and leaves a per-invocation nondeterministic failure inside one fixture build. The mechanism is an unchecked exit code, 46 times over. The W024 fixture performs ~46 runGit spawns and never checks a single one. runGit returns failures as DATA and never throws, so one silently-failed `git commit` yields 19 commits instead of 20, or a silently-empty `git rev-parse HEAD` yields a blank state_head. Either drops readStateHeadFreshness below the advisory threshold, W024 never fires, and only W006 remains. Reproduced exactly: 20 commits -> ["W006","W024"]; 19 -> ["W006"]; blank state_head -> ["W006"] - byte-identical to the CI assertion dump. The arithmetic is what hid it. At threshold-1 and threshold+1 a lost commit still produces the asserted answer; only the exactly-at-threshold cases sit one commit from a false negative. Two of the seven tests are in that position, and CI hit one. That is why it had never been seen before, and why it surfaced now: this branch adds three test files, which reshuffles the cost-weighted shard partition and moved this file into a chunk where the latent flake fired. My files were checked as suspects first and cleared: all fixtures mkdtemp-unique, no process.chdir, no .planning/ writes, no git spawns, and node --test gives per-file process isolation regardless. Fixed at the cause, not the symptom. A mustGit wrapper throws on a non-zero exit with the command, exit code and stderr, and all nine call sites route through it. The fixture now asserts its OWN preconditions before the assertion under test runs - the seed head is non-empty, and `git rev-list --count <seed>..HEAD` equals the requested commitsAhead - so a fixture that did not build what it claims fails loudly as a FIXTURE ERROR naming got-versus-asked, instead of quietly handing a weaker input to the assertion. Proven: dropping one commit now raises FIXTURE ERROR: requested commitsAhead=19 but git rev-list --count reports 18 where it previously produced a silent ["W006"] pass-for-the-wrong-reason. 64/64 tests in that block pass unperturbed. Deliberately NOT done: no threshold change, no retry, no loosened assertion, no skip. The assertion was correct; the input was silently wrong. Worth naming, because it is the same shape from the other side: this PR ships eslint-rules/no-swallowed-precondition.cjs, whose entire subject is a swallowed precondition failure being laundered into a plausible downstream outcome. This fixture is that defect in test code - the swallowed git failure was laundered into a legitimate-looking "W024 did not fire". The rule does not cover test fixtures, so the connection is noted at the fix site rather than enforced. Refs #3987 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3987): two tests wrote to committed files; the shard packing decided when that mattered CI red on windows-latest shard 1/3 only: "gen-exit-code-registry: CLI" > "a --write run redirected to a tmpdir leaves every committed artifact untouched" AssertionError: hooks artifact must be untouched The Linux runner passed the same sha at 40425/40425. It is Linux-only, so a Windows-scheduling defect is structurally invisible to it. Root cause, established by measurement rather than inference. tests/cli-exit.test.cjs appended a corruption marker to the REAL COMMITTED hooks/lib/exit-code-registry.js, held it corrupted across a full subprocess, and restored it in a finally. tests/exit-code-registry.test.cjs reads that same real file before and after its own subprocess and asserts byte equality. If it samples while the other test holds the file corrupted, it fails. The landmine is pre-existing, from2ea5efc15(#3911). What this branch changed is WHEN the two run together. scripts/run-tests.cjs shards by cost-weighted LPT over the sorted unit list, so adding three test files repacks the bins: merge-basec3e667df3(838 files): cli-exit -> shard 1, exit-code-registry -> shard 3 HEAD 03b342601 (841 files): BOTH in shard 1, same argv chunk, one node --test process, concurrent Co-location is necessary but not sufficient - Linux shard 1/3 also had both and passed. Windows loses because TEST_CONCURRENCY defaults to 2 there against 4 elsewhere, spawn cost is ~10x, and the sibling corruptor holds one of only two slots through a ~90s tsc compile. That turns a sub-second overlap into seconds. Not a path-separator or case-sensitivity issue, and not CRLF - .gitattributes pins * text=auto eol=lf. Redirection was not at fault either: ensureScriptsOut derives all five --out flags correctly and gen-exit-code-registry.cjs honours them with no __dirname escape. Fixed at the cause: no test writes to a committed file any more. Both corruptors now copy to a mkdtempSync tmpdir, corrupt the COPY, and point the generator at it. Repinning or reordering the shards would have turned CI green while leaving the landmine armed for the next reshuffle. That required closing an inconsistency between two sibling generators. gen-exit-code-registry.cjs already accepts --out/--scripts-out/--hooks-out/--dts-out/--sh-out and honours them under --check; gen-hooks-cli-exit.cjs hardcoded OUTPUT_PATH and had no flag surface at all, so its corruptor could not be redirected anywhere. It now takes --out in the same style, honoured by both --write and --check, and is a no-op when absent - verified: a bare --check on the default path still exits 0. ensureScriptsOut moved to tests/helpers/exit-code-artifact-flags.cjs and both test files import it. Hand-rolling a second copy of the flag derivation would have been a re-derivation of exactly the kind this PR ships a guard against. Verified: both tests still detect corruption (proven by defeating the check and watching them red, with a positive control showing an uncorrupted copy exits 0); SHA-256 of hooks/lib/exit-code-registry.js and hooks/lib/cli-exit.js identical before and after running both rewritten bodies, and git reports nothing under hooks/ modified - that is the property that was violated. A repo-wide search for the corrupt-then-restore-in-finally shape against hooks/ found no other instances. One detail worth recording: the tmpdir test keeps --declaration pointed at the real committed declaration rather than copying it, because the generated banner embeds path.relative(REPO_ROOT, declarationPath) - copying it would produce a false drift unrelated to the injected corruption. The declaration is read-only on that path and never written. Refs #3987 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2095 lines
101 KiB
JavaScript
2095 lines
101 KiB
JavaScript
'use strict';
|
|
|
|
const { describe, test, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const path = require('node:path');
|
|
const fs = require('node:fs');
|
|
|
|
const {
|
|
ExitError, runMain, projectOutcome, resolveContractVersion, getContractVersion,
|
|
} = require('../scripts/lib/cli-exit.cjs');
|
|
const { runNode } = require('./helpers/process-seam.cjs');
|
|
const { toLegacyResult } = require('./helpers/git-fixture.cjs');
|
|
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
|
const { createTempDir, cleanup } = require('./helpers.cjs');
|
|
const fc = require('./helpers/fast-check-setup.cjs');
|
|
const { ensureScriptsOut } = require('./helpers/exit-code-artifact-flags.cjs');
|
|
|
|
// Paths to the compiled product seam (src/cli-exit.cts → gsd-core/bin/lib/cli-exit.cjs)
|
|
// used for json-error mode regression tests which require io.cjs integration.
|
|
const BUILT_CLI_EXIT_PATH = path.resolve(__dirname, '../gsd-core/bin/lib/cli-exit.cjs');
|
|
const IO_PATH = path.resolve(__dirname, '../gsd-core/bin/lib/io.cjs');
|
|
const SCRIPTS_CLI_EXIT_PATH = path.resolve(__dirname, '../scripts/lib/cli-exit.cjs');
|
|
const EXIT_CODE_REGISTRY_PATH = path.resolve(__dirname, '../gsd-core/bin/lib/exit-code-registry.cjs');
|
|
|
|
// #3911 (ADR-3889 Phase 7): the THIRD emitted copy, for hooks/ consumers that
|
|
// must terminate through terminateNow without depending on any build
|
|
// artifact (gsd-core/bin/lib is gitignored tsc output, absent on a raw
|
|
// plugin-marketplace or git-clone install).
|
|
const HOOKS_CLI_EXIT_PATH = path.resolve(__dirname, '../hooks/lib/cli-exit.js');
|
|
const HOOKS_EXIT_CODE_REGISTRY_PATH = path.resolve(__dirname, '../hooks/lib/exit-code-registry.js');
|
|
|
|
const { EXIT_CODES } = require(EXIT_CODE_REGISTRY_PATH);
|
|
const REGISTERED_NAMES = EXIT_CODES.map((e) => e.name);
|
|
const VERSIONS = ['v1', 'v2'];
|
|
|
|
/** Settle the runMain promise chain before asserting. */
|
|
async function settle() {
|
|
await new Promise((r) => setImmediate(r));
|
|
}
|
|
|
|
describe('ExitError', () => {
|
|
test('default code is 1', () => {
|
|
const err = new ExitError();
|
|
assert.equal(err.code, 1);
|
|
});
|
|
|
|
test('name is ExitError', () => {
|
|
const err = new ExitError();
|
|
assert.equal(err.name, 'ExitError');
|
|
});
|
|
|
|
test('instanceof Error', () => {
|
|
assert.ok(new ExitError() instanceof Error);
|
|
});
|
|
|
|
test('hasUserMessage is false when no message passed', () => {
|
|
const err = new ExitError(1);
|
|
assert.equal(err.hasUserMessage, false);
|
|
});
|
|
|
|
test('hasUserMessage is true when message passed', () => {
|
|
const err = new ExitError(1, 'something went wrong');
|
|
assert.equal(err.hasUserMessage, true);
|
|
});
|
|
|
|
test('custom code is preserved', () => {
|
|
const err = new ExitError(42, 'boom');
|
|
assert.equal(err.code, 42);
|
|
});
|
|
|
|
test('message is set to user message when provided', () => {
|
|
const err = new ExitError(2, 'user msg');
|
|
assert.equal(err.message, 'user msg');
|
|
});
|
|
|
|
test('message is synthetic when no message provided', () => {
|
|
const err = new ExitError(3);
|
|
assert.equal(err.message, 'process exit 3');
|
|
});
|
|
});
|
|
|
|
describe('runMain', () => {
|
|
test('main returns a number sets process.exitCode', async (t) => {
|
|
const saved = process.exitCode;
|
|
t.after(() => { process.exitCode = saved || 0; });
|
|
runMain(() => 42);
|
|
await settle();
|
|
assert.equal(process.exitCode, 42);
|
|
});
|
|
|
|
test('main returns undefined leaves process.exitCode unchanged', async (t) => {
|
|
const saved = process.exitCode;
|
|
// Set a known value before calling
|
|
process.exitCode = 0;
|
|
t.after(() => { process.exitCode = saved || 0; });
|
|
runMain(() => undefined);
|
|
await settle();
|
|
assert.equal(process.exitCode, 0);
|
|
});
|
|
|
|
test('main throws ExitError sets process.exitCode to err.code', async (t) => {
|
|
const saved = process.exitCode;
|
|
t.after(() => { process.exitCode = saved || 0; });
|
|
runMain(() => { throw new ExitError(2); });
|
|
await settle();
|
|
assert.equal(process.exitCode, 2);
|
|
});
|
|
|
|
test('main rejects async ExitError(0) sets process.exitCode to 0', async (t) => {
|
|
const saved = process.exitCode;
|
|
t.after(() => { process.exitCode = saved !== undefined ? saved : 0; });
|
|
runMain(async () => { throw new ExitError(0); });
|
|
await settle();
|
|
assert.equal(process.exitCode, 0);
|
|
});
|
|
|
|
test('main throws generic Error sets process.exitCode to 1 and writes stderr', async (t) => {
|
|
const saved = process.exitCode;
|
|
const stderrChunks = [];
|
|
const origWrite = process.stderr.write.bind(process.stderr);
|
|
process.stderr.write = (chunk, ...args) => {
|
|
stderrChunks.push(typeof chunk === 'string' ? chunk : chunk.toString());
|
|
return origWrite(chunk, ...args);
|
|
};
|
|
t.after(() => {
|
|
process.stderr.write = origWrite;
|
|
process.exitCode = saved || 0;
|
|
});
|
|
runMain(() => { throw new Error('kaboom'); });
|
|
await settle();
|
|
assert.equal(process.exitCode, 1);
|
|
const combined = stderrChunks.join('');
|
|
assert.ok(combined.includes('kaboom'), `expected "kaboom" in stderr: ${combined}`);
|
|
});
|
|
|
|
test('ExitError with hasUserMessage and non-zero code writes to stderr', async (t) => {
|
|
const saved = process.exitCode;
|
|
const stderrChunks = [];
|
|
const origWrite = process.stderr.write.bind(process.stderr);
|
|
process.stderr.write = (chunk, ...args) => {
|
|
stderrChunks.push(typeof chunk === 'string' ? chunk : chunk.toString());
|
|
return origWrite(chunk, ...args);
|
|
};
|
|
t.after(() => {
|
|
process.stderr.write = origWrite;
|
|
process.exitCode = saved || 0;
|
|
});
|
|
runMain(() => { throw new ExitError(1, 'user-visible error'); });
|
|
await settle();
|
|
assert.equal(process.exitCode, 1);
|
|
const combined = stderrChunks.join('');
|
|
assert.ok(combined.includes('user-visible error'), `expected message in stderr: ${combined}`);
|
|
});
|
|
|
|
test('ExitError with hasUserMessage and code 0 does NOT write to stderr', async (t) => {
|
|
const saved = process.exitCode;
|
|
const stderrChunks = [];
|
|
const origWrite = process.stderr.write.bind(process.stderr);
|
|
process.stderr.write = (chunk, ...args) => {
|
|
stderrChunks.push(typeof chunk === 'string' ? chunk : chunk.toString());
|
|
return origWrite(chunk, ...args);
|
|
};
|
|
t.after(() => {
|
|
process.stderr.write = origWrite;
|
|
process.exitCode = saved !== undefined ? saved : 0;
|
|
});
|
|
runMain(() => { throw new ExitError(0, 'silent success'); });
|
|
await settle();
|
|
assert.equal(process.exitCode, 0);
|
|
const combined = stderrChunks.join('');
|
|
assert.equal(combined.includes('silent success'), false,
|
|
`did not expect message in stderr: ${combined}`);
|
|
});
|
|
|
|
// #3906 (ADR-3889 Phase 2): runMain gained the ability to accept a declared
|
|
// outcome STRING return, projected through the same projectOutcome() the
|
|
// sibling terminator (terminateNow) uses. Every arm above this one is
|
|
// byte-for-byte unchanged — this is the only new arm.
|
|
describe('#3906: runMain accepts a declared outcome string', () => {
|
|
test('a returned registered name projects through the current contract version', async (t) => {
|
|
const saved = process.exitCode;
|
|
t.after(() => { process.exitCode = saved || 0; });
|
|
resolveContractVersion({ argv: ['node', 'x'], env: {} }); // v1 (default)
|
|
runMain(() => 'USAGE');
|
|
await settle();
|
|
assert.equal(process.exitCode, 64);
|
|
});
|
|
|
|
test('a returned DEGRADED projects to 0 under v1 and 80 under v2', async (t) => {
|
|
const saved = process.exitCode;
|
|
t.after(() => {
|
|
resolveContractVersion({ argv: ['node', 'x'], env: {} }); // restore default
|
|
process.exitCode = saved || 0;
|
|
});
|
|
resolveContractVersion({ argv: ['node', 'x', '--exit-contract=v1'], env: {} });
|
|
runMain(() => 'DEGRADED');
|
|
await settle();
|
|
assert.equal(process.exitCode, 0);
|
|
|
|
resolveContractVersion({ argv: ['node', 'x', '--exit-contract=v2'], env: {} });
|
|
runMain(() => 'DEGRADED');
|
|
await settle();
|
|
assert.equal(process.exitCode, 80);
|
|
});
|
|
|
|
test('an unregistered outcome string rejects the same way projectOutcome does (surfaces as the generic-throw arm)', async (t) => {
|
|
const saved = process.exitCode;
|
|
const origWrite = process.stderr.write.bind(process.stderr);
|
|
process.stderr.write = () => true;
|
|
t.after(() => {
|
|
process.stderr.write = origWrite;
|
|
process.exitCode = saved || 0;
|
|
});
|
|
runMain(() => 'NOT_A_REAL_OUTCOME');
|
|
await settle();
|
|
// The string arm's projectOutcome() call throws synchronously inside
|
|
// the .then() callback, which the SAME .catch() below it already
|
|
// handles as a generic (non-ExitError) throw -> exit code 1.
|
|
assert.equal(process.exitCode, 1);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── Regressions ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* bug #965 — runMain unexpected throw with --json-errors active emitted a raw
|
|
* stack trace instead of a structured { ok:false, reason, message } envelope.
|
|
* SDK consumers parsing structured errors would receive an unparseable string.
|
|
*
|
|
* Fix: src/cli-exit.cts non-ExitError catch branch now checks getJsonErrorMode()
|
|
* and emits the same structured envelope as error() when active.
|
|
*
|
|
* Tests run against the compiled product seam (gsd-core/bin/lib/cli-exit.cjs)
|
|
* via subprocess so that io.cjs module-level state is isolated per spawn.
|
|
*/
|
|
describe('regressions', () => {
|
|
/** Spawn a one-shot script that sets json-error mode and calls runMain with a throwing handler. */
|
|
function spawnJsonErrorRun({ jsonMode, errorType = 'TypeError', message = 'unexpected boom' } = {}) {
|
|
// ExitError lives in the same module as runMain; import it when the test
|
|
// wants to exercise the ExitError carve-out path. ExitError takes (code, message).
|
|
const isExitError = errorType === 'ExitError';
|
|
const destructure = isExitError ? '{ runMain, ExitError }' : '{ runMain }';
|
|
const throwExpr = isExitError
|
|
? `new ExitError(1, ${JSON.stringify(message)})`
|
|
: `new ${errorType}(${JSON.stringify(message)})`;
|
|
const script = `
|
|
const io = require(${JSON.stringify(IO_PATH)});
|
|
const ${destructure} = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});
|
|
io.setJsonErrorMode(${jsonMode ? 'true' : 'false'});
|
|
runMain(() => { throw ${throwExpr}; });
|
|
setImmediate(() => {});
|
|
`;
|
|
return toLegacyResult(runNode(['-e', script], { timeoutMs: PROBE_TIMEOUT_MS }));
|
|
}
|
|
|
|
describe('bug-965: unexpected throw in json-error mode emits structured envelope', () => {
|
|
test('stderr is a single parseable JSON object (not a raw stack trace)', () => {
|
|
const result = spawnJsonErrorRun({ jsonMode: true });
|
|
assert.strictEqual(result.status, 1,
|
|
`expected exit code 1, got ${result.status}; stderr: ${result.stderr}`);
|
|
const stderrTrimmed = result.stderr.trim();
|
|
assert.ok(stderrTrimmed.length > 0, 'expected non-empty stderr');
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(stderrTrimmed);
|
|
} catch (e) {
|
|
assert.fail(
|
|
`stderr is NOT valid JSON (raw stack trace leaked through):\n${stderrTrimmed}\nparse error: ${e.message}`
|
|
);
|
|
}
|
|
assert.strictEqual(parsed.ok, false, `expected ok:false, got: ${JSON.stringify(parsed)}`);
|
|
assert.strictEqual(parsed.reason, 'sdk_fail_fast',
|
|
`expected reason "sdk_fail_fast", got: ${parsed.reason}`);
|
|
assert.ok(
|
|
parsed.message && parsed.message.includes('unexpected boom'),
|
|
`expected message to include "unexpected boom", got: ${JSON.stringify(parsed.message)}`
|
|
);
|
|
});
|
|
|
|
test('stderr JSON works for RangeError as well as TypeError', () => {
|
|
const result = spawnJsonErrorRun({ jsonMode: true, errorType: 'RangeError', message: 'out of bounds' });
|
|
assert.strictEqual(result.status, 1);
|
|
const parsed = JSON.parse(result.stderr.trim());
|
|
assert.strictEqual(parsed.ok, false);
|
|
assert.strictEqual(parsed.reason, 'sdk_fail_fast');
|
|
assert.ok(parsed.message.includes('out of bounds'));
|
|
});
|
|
|
|
test('stdout is empty when unexpected throw emits structured error', () => {
|
|
const result = spawnJsonErrorRun({ jsonMode: true });
|
|
assert.strictEqual(result.stdout, '',
|
|
`expected empty stdout, got: ${result.stdout}`);
|
|
});
|
|
|
|
test('plain mode (json-error off) preserves raw stack trace on stderr', () => {
|
|
const result = spawnJsonErrorRun({ jsonMode: false });
|
|
assert.strictEqual(result.status, 1);
|
|
const stderrTrimmed = result.stderr.trim();
|
|
let parsed = null;
|
|
try { parsed = JSON.parse(stderrTrimmed); } catch { /* expected — not JSON */ }
|
|
assert.strictEqual(parsed, null,
|
|
`expected raw stack (non-JSON) on stderr in plain mode, but got valid JSON: ${stderrTrimmed.slice(0, 200)}`);
|
|
assert.ok(
|
|
stderrTrimmed.includes('unexpected boom'),
|
|
`expected "unexpected boom" in stderr, got: ${stderrTrimmed.slice(0, 200)}`
|
|
);
|
|
});
|
|
|
|
// #2979: characterization test pinning the two error paths under json-errors
|
|
// mode. The structured envelope covers non-ExitError failures; ExitError
|
|
// (usage errors) intentionally emits plain text with its own exit code.
|
|
// Both halves asserted together so the code cannot drift toward the doc's
|
|
// prior overstated claim that EVERY error emits JSON.
|
|
test('#2979: ExitError emits plain text (not JSON) even under --json-errors; non-ExitError emits the envelope', () => {
|
|
// ExitError path: plain text, own exit code, NOT a JSON object.
|
|
const exitResult = spawnJsonErrorRun({
|
|
jsonMode: true,
|
|
errorType: 'ExitError',
|
|
message: 'Usage: gsd-tools <command> [args]',
|
|
});
|
|
assert.strictEqual(exitResult.status, 1, 'ExitError exits with its code');
|
|
const exitStderr = exitResult.stderr.trim();
|
|
let exitParsed = null;
|
|
try { exitParsed = JSON.parse(exitStderr); } catch { /* expected — plain text */ }
|
|
assert.strictEqual(exitParsed, null,
|
|
`ExitError must emit plain text, not JSON; got: ${exitStderr.slice(0, 200)}`);
|
|
assert.ok(exitStderr.includes('Usage'),
|
|
`ExitError plain-text message must reach stderr; got: ${exitStderr.slice(0, 200)}`);
|
|
|
|
// Non-ExitError path: structured JSON envelope.
|
|
const envResult = spawnJsonErrorRun({ jsonMode: true });
|
|
assert.strictEqual(envResult.status, 1);
|
|
const envParsed = JSON.parse(envResult.stderr.trim());
|
|
assert.strictEqual(envParsed.ok, false);
|
|
assert.strictEqual(envParsed.reason, 'sdk_fail_fast');
|
|
assert.ok(envParsed.message, 'envelope must carry a message');
|
|
});
|
|
});
|
|
|
|
/**
|
|
* #3904 (epic #3889, ADR-3889 P0) — scripts/lib/cli-exit.cjs was a SECOND
|
|
* hand-written implementation of this seam, and it had no json-error arm at
|
|
* all: an unexpected throw printed a raw stack trace where the documented
|
|
* contract promises { ok:false, reason, message }. 64+ files under scripts/
|
|
* require that copy.
|
|
*
|
|
* Fix: scripts/lib/cli-exit.cjs is now GENERATED from src/cli-exit.cts's
|
|
* compiled output and byte-compared by scripts/gen-scripts-cli-exit.cjs
|
|
* --check, so the two cannot diverge again.
|
|
*
|
|
* These run against the SCRIPTS copy specifically — the sibling bug-965 block
|
|
* above deliberately targets the built copy, which is exactly how the drift
|
|
* stayed invisible.
|
|
*/
|
|
describe('bug-3904: the scripts copy is the same artifact as the built one', () => {
|
|
/** Build a one-shot driver script for whichever copy is under test. */
|
|
function driver(modulePath, { jsonMode, throwExpr }) {
|
|
return [
|
|
`const cliExit = require(${JSON.stringify(modulePath)});`,
|
|
`const { runMain, ExitError } = cliExit;`,
|
|
`void ExitError;`,
|
|
`cliExit.setJsonErrorMode(${jsonMode});`,
|
|
`runMain(() => { throw ${throwExpr}; });`,
|
|
`setImmediate(() => {});`,
|
|
].join('\n');
|
|
}
|
|
|
|
/**
|
|
* Drive the SCRIPTS copy. json-error mode is set through the scripts copy's
|
|
* own accessor, because a scripts/ consumer on an unbuilt clone has no
|
|
* io.cjs to reach for — that independence is part of what is under test.
|
|
*/
|
|
function spawnScriptsRun(opts) {
|
|
return toLegacyResult(
|
|
runNode(['-e', driver(SCRIPTS_CLI_EXIT_PATH, opts)], { timeoutMs: PROBE_TIMEOUT_MS }),
|
|
);
|
|
}
|
|
|
|
/** The same driver, pointed at the BUILT copy, for the parity row. */
|
|
function spawnBuiltRun(opts) {
|
|
return toLegacyResult(
|
|
runNode(['-e', driver(BUILT_CLI_EXIT_PATH, opts)], { timeoutMs: PROBE_TIMEOUT_MS }),
|
|
);
|
|
}
|
|
|
|
/** Run a snippet that prints JSON on stdout, and return the parsed value. */
|
|
function readJsonFromChild(lines) {
|
|
const r = toLegacyResult(runNode(['-e', lines.join('\n')], { timeoutMs: PROBE_TIMEOUT_MS }));
|
|
assert.strictEqual(r.status, 0, `child exited ${r.status}; stderr: ${r.stderr}`);
|
|
return JSON.parse(r.stdout);
|
|
}
|
|
|
|
/** Parse stderr as a single JSON object, failing with the raw text if it is not one. */
|
|
function parseEnvelope(result) {
|
|
const trimmed = result.stderr.trim();
|
|
try {
|
|
return JSON.parse(trimmed);
|
|
} catch (e) {
|
|
return assert.fail(
|
|
`stderr is NOT a single JSON object (raw stack leaked through):\n${trimmed}\nparse error: ${e.message}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Matrix rows 1-3: the reported defect, at the consumer's output ────────
|
|
test('scripts copy emits the structured envelope on an unexpected throw under json mode', () => {
|
|
const result = spawnScriptsRun({ jsonMode: true, throwExpr: `new TypeError('unexpected boom')` });
|
|
assert.strictEqual(result.status, 1, `expected exit 1; stderr: ${result.stderr}`);
|
|
const parsed = parseEnvelope(result);
|
|
assert.strictEqual(parsed.ok, false);
|
|
assert.strictEqual(parsed.reason, 'sdk_fail_fast');
|
|
assert.ok(
|
|
String(parsed.message).includes('unexpected boom'),
|
|
`expected the thrown text in message, got: ${JSON.stringify(parsed.message)}`,
|
|
);
|
|
});
|
|
|
|
test('scripts copy envelope covers RangeError as well as TypeError', () => {
|
|
const result = spawnScriptsRun({ jsonMode: true, throwExpr: `new RangeError('out of bounds')` });
|
|
assert.strictEqual(result.status, 1);
|
|
const parsed = parseEnvelope(result);
|
|
assert.strictEqual(parsed.reason, 'sdk_fail_fast');
|
|
assert.ok(String(parsed.message).includes('out of bounds'));
|
|
});
|
|
|
|
test('scripts copy writes the envelope to stderr and leaves stdout empty', () => {
|
|
const result = spawnScriptsRun({ jsonMode: true, throwExpr: `new TypeError('boom')` });
|
|
assert.strictEqual(result.stdout, '', `expected empty stdout, got: ${result.stdout}`);
|
|
});
|
|
|
|
// ── Matrix rows 4-5: negative space — what must NOT become an envelope ────
|
|
test('scripts copy preserves the raw stack trace when json mode is off', () => {
|
|
const result = spawnScriptsRun({ jsonMode: false, throwExpr: `new TypeError('unexpected boom')` });
|
|
assert.strictEqual(result.status, 1);
|
|
const trimmed = result.stderr.trim();
|
|
let parsed = null;
|
|
try { parsed = JSON.parse(trimmed); } catch { /* expected — not JSON */ }
|
|
assert.strictEqual(parsed, null, `expected a raw stack in plain mode, got JSON: ${trimmed.slice(0, 200)}`);
|
|
assert.ok(trimmed.includes('unexpected boom'), `expected the thrown text; got: ${trimmed.slice(0, 200)}`);
|
|
});
|
|
|
|
test('scripts copy keeps ExitError plain-text under json mode', () => {
|
|
const result = spawnScriptsRun({
|
|
jsonMode: true,
|
|
throwExpr: `new ExitError(1, 'Usage: gsd-tools <command> [args]')`,
|
|
});
|
|
assert.strictEqual(result.status, 1, 'ExitError exits with its own code');
|
|
const trimmed = result.stderr.trim();
|
|
let parsed = null;
|
|
try { parsed = JSON.parse(trimmed); } catch { /* expected — plain text */ }
|
|
assert.strictEqual(parsed, null, `ExitError must stay plain text; got JSON: ${trimmed.slice(0, 200)}`);
|
|
assert.ok(trimmed.includes('Usage'), `plain-text message must reach stderr; got: ${trimmed.slice(0, 200)}`);
|
|
});
|
|
|
|
// ── Matrix rows 6-10: non-Error throws reach String(err) ─────────────────
|
|
for (const [label, throwExpr, expectedMessage] of [
|
|
['a thrown string', `'a bare string'`, 'a bare string'],
|
|
['a thrown null', `null`, 'null'],
|
|
['a thrown undefined', `undefined`, 'undefined'],
|
|
['an Error with an empty message', `new Error('')`, 'Error'],
|
|
]) {
|
|
test(`scripts copy envelope handles ${label}`, () => {
|
|
const result = spawnScriptsRun({ jsonMode: true, throwExpr });
|
|
assert.strictEqual(result.status, 1, `expected exit 1; stderr: ${result.stderr}`);
|
|
const parsed = parseEnvelope(result);
|
|
assert.strictEqual(parsed.ok, false);
|
|
assert.strictEqual(parsed.reason, 'sdk_fail_fast');
|
|
assert.ok(
|
|
String(parsed.message).includes(expectedMessage),
|
|
`expected ${JSON.stringify(expectedMessage)} in message, got ${JSON.stringify(parsed.message)}`,
|
|
);
|
|
});
|
|
}
|
|
|
|
test('scripts copy envelope stays parseable when the message contains quotes and newlines', () => {
|
|
// Proves JSON.stringify is doing the encoding rather than string concatenation:
|
|
// an unescaped quote or newline would split stderr into something JSON.parse rejects.
|
|
const hostile = 'he said "hi"\nthen \\left\ttab';
|
|
const result = spawnScriptsRun({ jsonMode: true, throwExpr: `new Error(${JSON.stringify(hostile)})` });
|
|
assert.strictEqual(result.status, 1);
|
|
const parsed = parseEnvelope(result);
|
|
assert.strictEqual(parsed.message, hostile, 'the message must round-trip byte-for-byte');
|
|
});
|
|
|
|
// ── Matrix row 11: the two copies are one artifact ───────────────────────
|
|
// Stack-trace bytes are NOT the contract here: on the json=false path, stderr
|
|
// is a raw stack trace, and the generated scripts/ copy carries an 11-line
|
|
// provenance banner that the built copy does not, so every frame line number
|
|
// is offset by exactly that banner length, and the two files necessarily sit
|
|
// at different absolute paths. The two copies share one compiled BODY —
|
|
// the banner is the only difference — so what actually must match is the
|
|
// VERDICT: same exit code, and (json mode) the same structured envelope, or
|
|
// (plain-text mode) the same unqualified error header line with no path or
|
|
// line number in it.
|
|
test('the built copy and the scripts copy produce identical verdicts for every throw class', () => {
|
|
const cases = [
|
|
{ jsonMode: true, throwExpr: `new TypeError('same boom')`, compare: 'json' },
|
|
{ jsonMode: false, throwExpr: `new TypeError('same boom')`, compare: 'firstLine' },
|
|
{ jsonMode: true, throwExpr: `new ExitError(3, 'same usage')`, compare: 'exact' },
|
|
];
|
|
for (const c of cases) {
|
|
const fromScripts = spawnScriptsRun(c);
|
|
const fromBuilt = spawnBuiltRun(c);
|
|
assert.strictEqual(
|
|
fromScripts.status, fromBuilt.status,
|
|
`exit status must match for ${c.throwExpr} (json=${c.jsonMode})`,
|
|
);
|
|
const label = `${c.throwExpr} (json=${c.jsonMode})`;
|
|
if (c.compare === 'json') {
|
|
// Structured output: parse both and compare the resulting objects.
|
|
assert.deepStrictEqual(
|
|
parseEnvelope(fromScripts), parseEnvelope(fromBuilt),
|
|
`parsed envelopes must match for ${label}`,
|
|
);
|
|
} else if (c.compare === 'firstLine') {
|
|
// Plain-text stack trace: only the header line (e.g. "TypeError: same
|
|
// boom") is path/line-number-free and therefore comparable; the frame
|
|
// lines below it are expected to diverge per the banner offset above.
|
|
for (const r of [fromScripts, fromBuilt]) {
|
|
assert.throws(() => JSON.parse(r.stderr.trim()), `stderr for ${label} must NOT be JSON`);
|
|
}
|
|
const firstLine = (s) => s.trim().split('\n')[0];
|
|
assert.strictEqual(
|
|
firstLine(fromScripts.stderr), firstLine(fromBuilt.stderr),
|
|
`stderr first line must match for ${label}`,
|
|
);
|
|
} else {
|
|
// ExitError: plain prose with no stack trace, so it is byte-identical.
|
|
assert.strictEqual(
|
|
fromScripts.stderr.trim(), fromBuilt.stderr.trim(),
|
|
`stderr must match for ${label}`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
// ── Matrix rows 13-15: ONE json-error-mode cell, not two ─────────────────
|
|
// This is the hazard the fix INTRODUCES and must therefore be tested rather
|
|
// than reasoned about: after generation there are two module instances of
|
|
// the same artifact, and a module-level `let` would give them two flags.
|
|
test('the mode set through io is visible through the scripts copy', () => {
|
|
assert.deepStrictEqual(
|
|
readJsonFromChild([
|
|
`const io = require(${JSON.stringify(IO_PATH)});`,
|
|
`const cliExit = require(${JSON.stringify(SCRIPTS_CLI_EXIT_PATH)});`,
|
|
`io.setJsonErrorMode(true);`,
|
|
`process.stdout.write(JSON.stringify({ viaCliExit: cliExit.getJsonErrorMode() }));`,
|
|
]),
|
|
{ viaCliExit: true },
|
|
);
|
|
});
|
|
|
|
test('the mode set through the scripts copy is visible through io', () => {
|
|
assert.deepStrictEqual(
|
|
readJsonFromChild([
|
|
`const io = require(${JSON.stringify(IO_PATH)});`,
|
|
`const cliExit = require(${JSON.stringify(SCRIPTS_CLI_EXIT_PATH)});`,
|
|
`cliExit.setJsonErrorMode(true);`,
|
|
`process.stdout.write(JSON.stringify({ viaIo: io.getJsonErrorMode() }));`,
|
|
]),
|
|
{ viaIo: true },
|
|
);
|
|
});
|
|
|
|
test('both copies of the exit module share one json-error-mode cell', () => {
|
|
assert.deepStrictEqual(
|
|
readJsonFromChild([
|
|
`const io = require(${JSON.stringify(IO_PATH)});`,
|
|
`const built = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`const scripts = require(${JSON.stringify(SCRIPTS_CLI_EXIT_PATH)});`,
|
|
// Two distinct module instances of the same artifact.
|
|
`if (built === scripts) throw new Error('expected two distinct module instances');`,
|
|
`io.setJsonErrorMode(true);`,
|
|
`process.stdout.write(JSON.stringify({`,
|
|
` built: built.getJsonErrorMode(),`,
|
|
` scripts: scripts.getJsonErrorMode(),`,
|
|
` io: io.getJsonErrorMode(),`,
|
|
`}));`,
|
|
]),
|
|
{ built: true, scripts: true, io: true },
|
|
'all three views must read one cell — two module-level flags would diverge here',
|
|
);
|
|
});
|
|
|
|
// ── Matrix rows 16-17: coercion and default, preserved exactly ───────────
|
|
test('setJsonErrorMode keeps its truthiness coercion', () => {
|
|
const seen = readJsonFromChild([
|
|
`const c = require(${JSON.stringify(SCRIPTS_CLI_EXIT_PATH)});`,
|
|
`const seen = [];`,
|
|
`for (const v of [0, '', 'false', null, undefined, 1, 'x']) {`,
|
|
` c.setJsonErrorMode(v); seen.push(c.getJsonErrorMode());`,
|
|
`}`,
|
|
`process.stdout.write(JSON.stringify(seen));`,
|
|
]);
|
|
// `!!v` — note 'false' is a NON-EMPTY string and is therefore true.
|
|
assert.deepStrictEqual(seen, [false, false, true, false, false, true, true]);
|
|
});
|
|
|
|
test('json-error mode defaults to false when never set', () => {
|
|
assert.deepStrictEqual(
|
|
readJsonFromChild([
|
|
`const c = require(${JSON.stringify(SCRIPTS_CLI_EXIT_PATH)});`,
|
|
`const v = c.getJsonErrorMode();`,
|
|
`process.stdout.write(JSON.stringify({ v, type: typeof v }));`,
|
|
]),
|
|
{ v: false, type: 'boolean' },
|
|
'an unset cell must read as boolean false, never undefined',
|
|
);
|
|
});
|
|
|
|
// ── Matrix rows 18-21: io's export surface must not move (Hyrum) ─────────
|
|
test('io still exports both json-error-mode accessors and an unchanged ERROR_REASON', () => {
|
|
const seen = readJsonFromChild([
|
|
`const io = require(${JSON.stringify(IO_PATH)});`,
|
|
`process.stdout.write(JSON.stringify({`,
|
|
` setter: typeof io.setJsonErrorMode,`,
|
|
` getter: typeof io.getJsonErrorMode,`,
|
|
` failFast: io.ERROR_REASON.SDK_FAIL_FAST,`,
|
|
` frozen: Object.isFrozen(io.ERROR_REASON),`,
|
|
` reasonCount: Object.keys(io.ERROR_REASON).length,`,
|
|
` keys: Object.keys(io.ERROR_REASON).sort(),`,
|
|
`}));`,
|
|
]);
|
|
assert.strictEqual(seen.setter, 'function');
|
|
assert.strictEqual(seen.getter, 'function');
|
|
assert.strictEqual(seen.failFast, 'sdk_fail_fast', 'the literal must survive moving to cli-exit');
|
|
assert.strictEqual(seen.frozen, true);
|
|
// #3884 (ADR-3473 §8.4) legitimately added two new codes —
|
|
// PICK_FIELD_ABSENT and PICK_OUTPUT_NOT_JSON — for the `--pick`
|
|
// absence contract (see .gsd/phase/feat-3884-failure-is-a-value/40-design.md
|
|
// rows B6/B11). 23 -> 25 is an intentional, documented growth of the
|
|
// enum, not drift; bump the golden count rather than treat this as a
|
|
// Hyrum violation.
|
|
assert.strictEqual(seen.reasonCount, 25, 'ERROR_REASON must keep all 25 members (23 + #3884 PICK_FIELD_ABSENT/PICK_OUTPUT_NOT_JSON)');
|
|
assert.ok(
|
|
seen.keys.includes('SDK_FAIL_FAST'),
|
|
`ERROR_REASON must still include SDK_FAIL_FAST, got: ${JSON.stringify(seen.keys)}`,
|
|
);
|
|
});
|
|
|
|
// ── Matrix rows 22-23: the unbuilt-clone constraint ──────────────────────
|
|
test('the scripts copy loads with no gsd-core tree in scope at all', (t) => {
|
|
// The generated file is COMMITTED and 64+ scripts/ consumers require it,
|
|
// including scripts/check-env.cjs which runs before any build. It must
|
|
// therefore not reach into gsd-core/bin/lib/, which is gitignored tsc
|
|
// output absent on a fresh clone.
|
|
//
|
|
// Proven by copying the file into an isolated temp directory that has no
|
|
// gsd-core sibling and no node_modules — a require of the built tree is
|
|
// MODULE_NOT_FOUND there. Deliberately NOT done by renaming the real
|
|
// gsd-core/bin/lib: test files run in parallel, so mutating a shared
|
|
// production directory would break every sibling suite mid-run.
|
|
//
|
|
// This is the sole guard of the "depends on node: builtins only"
|
|
// constraint: it proves the property by real module resolution in an
|
|
// isolated directory, rather than by inspecting require() specifiers.
|
|
//
|
|
// #3906 (ADR-3889 Phase 2): scripts/lib/cli-exit.cjs now also requires
|
|
// its OWN generated sibling, ./exit-code-registry.cjs (dual-emitted by
|
|
// scripts/gen-exit-code-registry.cjs to this exact directory) — so the
|
|
// standalone set this test proves is now TWO files, not one. Copying
|
|
// only cli-exit.cjs here would (correctly) MODULE_NOT_FOUND on the
|
|
// registry require; that failure mode is exercised on its own by the
|
|
// dedicated #3906 standalone-load test below, which is the one that
|
|
// asserts the CORRECT two-file set loads clean.
|
|
const dir = createTempDir('gsd-3904-standalone-');
|
|
t.after(() => cleanup(dir));
|
|
const copied = path.join(dir, 'cli-exit.cjs');
|
|
fs.copyFileSync(SCRIPTS_CLI_EXIT_PATH, copied);
|
|
fs.copyFileSync(path.resolve(__dirname, '../scripts/lib/exit-code-registry.cjs'), path.join(dir, 'exit-code-registry.cjs'));
|
|
|
|
const r = toLegacyResult(runNode(['-e', [
|
|
`const c = require(${JSON.stringify(copied)});`,
|
|
`c.setJsonErrorMode(true);`,
|
|
`c.runMain(() => { throw new TypeError('still works'); });`,
|
|
`setImmediate(() => {});`,
|
|
].join('\n')], { cwd: dir, timeoutMs: PROBE_TIMEOUT_MS }));
|
|
|
|
assert.ok(
|
|
!r.stderr.includes('MODULE_NOT_FOUND'),
|
|
`the scripts copy must not require anything outside node: builtins; got: ${r.stderr.slice(0, 400)}`,
|
|
);
|
|
assert.strictEqual(r.status, 1, `expected exit 1; stderr: ${r.stderr}`);
|
|
assert.strictEqual(JSON.parse(r.stderr.trim()).reason, 'sdk_fail_fast');
|
|
});
|
|
|
|
test('the build sentinel is still emitted', () => {
|
|
// gsd-core/bin/ensure-runtime-build.cjs keys isBuilt() on this exact filename.
|
|
assert.ok(
|
|
fs.statSync(BUILT_CLI_EXIT_PATH).isFile(),
|
|
'gsd-core/bin/lib/cli-exit.cjs must remain tsc output — it is the build sentinel',
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── #3906 (ADR-3889 Phase 2): two terminators over one registry ────────────
|
|
//
|
|
// projectOutcome/resolveContractVersion are pure (no process.exit, no real
|
|
// I/O) and are exercised IN-PROCESS. runMain/terminateNow are exercised as
|
|
// SUBPROCESSES via tests/helpers/process-seam.cjs — terminateNow really
|
|
// calls process.exit(), which would kill the test runner if called in-process.
|
|
|
|
const NON_DEGRADED_REGISTERED_NAMES = REGISTERED_NAMES.filter((n) => n !== 'DEGRADED');
|
|
|
|
describe('#3906: projectOutcome', () => {
|
|
test('PASS projects to 0 under both versions', () => {
|
|
for (const v of VERSIONS) assert.equal(projectOutcome('PASS', v), 0);
|
|
});
|
|
|
|
test('FAIL projects to 1 under both versions', () => {
|
|
for (const v of VERSIONS) assert.equal(projectOutcome('FAIL', v), 1);
|
|
});
|
|
|
|
test('DEGRADED projects to 0 under v1 and 80 under v2', () => {
|
|
assert.equal(projectOutcome('DEGRADED', 'v1'), 0);
|
|
assert.equal(projectOutcome('DEGRADED', 'v2'), 80);
|
|
});
|
|
|
|
test('every other registered name is version-invariant', () => {
|
|
for (const name of NON_DEGRADED_REGISTERED_NAMES) {
|
|
assert.equal(
|
|
projectOutcome(name, 'v1'), projectOutcome(name, 'v2'),
|
|
`${name} must project identically under v1 and v2`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('a registered name resolves through the registry, not a hardcoded table', () => {
|
|
// HOOK_DENY=2, USAGE=64, NO_INPUT=66, UNAVAILABLE=69, INTERNAL=70 — pinned
|
|
// to the shipped table so a future re-allocation is caught here too.
|
|
assert.equal(projectOutcome('HOOK_DENY', 'v1'), 2);
|
|
assert.equal(projectOutcome('USAGE', 'v2'), 64);
|
|
assert.equal(projectOutcome('NO_INPUT', 'v1'), 66);
|
|
assert.equal(projectOutcome('UNAVAILABLE', 'v2'), 69);
|
|
assert.equal(projectOutcome('INTERNAL', 'v1'), 70);
|
|
});
|
|
|
|
const badOutcomes = [
|
|
['unregistered name', 'NOT_A_REAL_OUTCOME'],
|
|
['empty string', ''],
|
|
['null', null],
|
|
['undefined', undefined],
|
|
['number', 0],
|
|
['plain object', {}],
|
|
['wrong case', 'pass'],
|
|
['wrong case registered name', 'usage'],
|
|
['untrimmed', ' PASS '],
|
|
];
|
|
for (const [label, value] of badOutcomes) {
|
|
test(`throws for ${label} outcome`, () => {
|
|
assert.throws(() => projectOutcome(value, 'v1'));
|
|
assert.throws(() => projectOutcome(value, 'v2'));
|
|
});
|
|
}
|
|
|
|
const badVersions = [
|
|
['v3', 'v3'],
|
|
['garbage', 'garbage'],
|
|
['empty string', ''],
|
|
['null', null],
|
|
['undefined', undefined],
|
|
['uppercase V1', 'V1'],
|
|
['number', 1],
|
|
];
|
|
for (const [label, value] of badVersions) {
|
|
test(`throws for ${label} version`, () => {
|
|
assert.throws(() => projectOutcome('PASS', value));
|
|
});
|
|
}
|
|
|
|
test('every projection is an integer', () => {
|
|
for (const v of VERSIONS) {
|
|
for (const outcome of ['PASS', 'FAIL', ...REGISTERED_NAMES]) {
|
|
const result = projectOutcome(outcome, v);
|
|
assert.equal(Number.isInteger(result), true, `${outcome}/${v} -> ${result} must be an integer`);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('every v2 projection except PASS is non-zero', () => {
|
|
for (const outcome of ['FAIL', ...REGISTERED_NAMES]) {
|
|
assert.notEqual(projectOutcome(outcome, 'v2'), 0, `${outcome} must be non-zero under v2`);
|
|
}
|
|
});
|
|
|
|
test('fast-check: every projection over the closed outcome/version space is a non-negative integer', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.constantFrom('PASS', 'FAIL', ...REGISTERED_NAMES),
|
|
fc.constantFrom(...VERSIONS),
|
|
(outcome, version) => {
|
|
const result = projectOutcome(outcome, version);
|
|
assert.equal(Number.isInteger(result), true);
|
|
assert.ok(result >= 0);
|
|
},
|
|
),
|
|
{ seed: 3906, numRuns: 200 },
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('#3906: resolveContractVersion', () => {
|
|
// Every test in this describe leaves the shared globalThis cell restored to
|
|
// the documented default so later describes (and other test files requiring
|
|
// either copy of this module in the SAME worker) do not observe a version
|
|
// some earlier test selected.
|
|
afterEach(() => { resolveContractVersion({ argv: ['node', 'x'], env: {} }); });
|
|
|
|
test('no flag, no env -> v1 (documented default)', () => {
|
|
assert.equal(resolveContractVersion({ argv: ['node', 'x'], env: {} }), 'v1');
|
|
});
|
|
|
|
test('--exit-contract=v2 flag -> v2', () => {
|
|
assert.equal(resolveContractVersion({ argv: ['node', 'x', '--exit-contract=v2'], env: {} }), 'v2');
|
|
});
|
|
|
|
test('GSD_EXIT_CONTRACT=v2 env -> v2', () => {
|
|
assert.equal(resolveContractVersion({ argv: ['node', 'x'], env: { GSD_EXIT_CONTRACT: 'v2' } }), 'v2');
|
|
});
|
|
|
|
test('flag v1 beats env v2', () => {
|
|
assert.equal(
|
|
resolveContractVersion({ argv: ['node', 'x', '--exit-contract=v1'], env: { GSD_EXIT_CONTRACT: 'v2' } }),
|
|
'v1',
|
|
);
|
|
});
|
|
|
|
test('flag v2 beats env v1 (both directions)', () => {
|
|
assert.equal(
|
|
resolveContractVersion({ argv: ['node', 'x', '--exit-contract=v2'], env: { GSD_EXIT_CONTRACT: 'v1' } }),
|
|
'v2',
|
|
);
|
|
});
|
|
|
|
test('an empty GSD_EXIT_CONTRACT reads as unset, not as an explicit selection', () => {
|
|
assert.equal(resolveContractVersion({ argv: ['node', 'x'], env: { GSD_EXIT_CONTRACT: '' } }), 'v1');
|
|
});
|
|
|
|
for (const bad of ['v3', 'garbage', '--exit-contract=']) {
|
|
const flagArg = bad === '--exit-contract=' ? bad : `--exit-contract=${bad}`;
|
|
test(`--exit-contract=${bad === '--exit-contract=' ? '<empty>' : bad} is REJECTED, not silently defaulted`, () => {
|
|
assert.throws(() => resolveContractVersion({ argv: ['node', 'x', flagArg], env: {} }));
|
|
});
|
|
}
|
|
|
|
test('GSD_EXIT_CONTRACT=v3 (env garbage) is rejected the same way', () => {
|
|
assert.throws(() => resolveContractVersion({ argv: ['node', 'x'], env: { GSD_EXIT_CONTRACT: 'v3' } }));
|
|
});
|
|
|
|
test('casing is decided: uppercase V2 is rejected, not silently accepted', () => {
|
|
assert.throws(() => resolveContractVersion({ argv: ['node', 'x', '--exit-contract=V2'], env: {} }));
|
|
assert.throws(() => resolveContractVersion({ argv: ['node', 'x'], env: { GSD_EXIT_CONTRACT: 'V2' } }));
|
|
});
|
|
|
|
test('resolveContractVersion persists into the shared cell read by getContractVersion', () => {
|
|
resolveContractVersion({ argv: ['node', 'x', '--exit-contract=v2'], env: {} });
|
|
assert.equal(getContractVersion(), 'v2');
|
|
resolveContractVersion({ argv: ['node', 'x'], env: {} });
|
|
assert.equal(getContractVersion(), 'v1');
|
|
});
|
|
});
|
|
|
|
describe('#3906: parity — runMain and terminateNow project identically (mandatory per ADR-3889 §3)', () => {
|
|
// #3906 follow-up: this MUST drive the version through the REAL ambient
|
|
// mechanism (GSD_EXIT_CONTRACT in the child's env, never touched by the
|
|
// script body itself), not by calling resolveContractVersion() explicitly
|
|
// inside the child. A test that pre-seeds the shared cell before invoking
|
|
// either terminator can pass even if getContractVersion() never actually
|
|
// wires the ambient process in at all — which is exactly the defect this
|
|
// matrix exists to catch (both terminators reading the SAME un-wired
|
|
// default 'v1' would still "agree", 16/16, while GSD_EXIT_CONTRACT was
|
|
// silently ignored). Neither script below calls resolveContractVersion or
|
|
// passes --exit-contract; the version reaches the process ONLY via env.
|
|
function runMainExit(outcome, version) {
|
|
const script = [
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`c.runMain(() => ${JSON.stringify(outcome)});`,
|
|
`setImmediate(() => {});`,
|
|
].join('\n');
|
|
return toLegacyResult(runNode(['-e', script], {
|
|
timeoutMs: PROBE_TIMEOUT_MS,
|
|
env: { ...process.env, GSD_EXIT_CONTRACT: version },
|
|
}));
|
|
}
|
|
|
|
function terminateNowExit(outcome, version) {
|
|
const script = [
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`c.terminateNow(${JSON.stringify(outcome)}, { outcome: ${JSON.stringify(outcome)} });`,
|
|
].join('\n');
|
|
return toLegacyResult(runNode(['-e', script], {
|
|
timeoutMs: PROBE_TIMEOUT_MS,
|
|
env: { ...process.env, GSD_EXIT_CONTRACT: version },
|
|
}));
|
|
}
|
|
|
|
// HOOK_DENY is EXCLUDED from the "both accept" set below on purpose: it is
|
|
// the one outcome runMain refuses (ADR-3889 §3 — code 2 is terminateNow-only).
|
|
// A cross-product that included it here would (as review found) run
|
|
// runMain('HOOK_DENY'), observe exit 2, and call that "parity" — which is
|
|
// exactly the false claim the restriction is meant to prevent. The
|
|
// dedicated divergence test below this loop asserts the real contract for
|
|
// HOOK_DENY instead: runMain refuses it, terminateNow alone produces 2.
|
|
const NAMES_ACCEPTED_BY_BOTH_TERMINATORS = REGISTERED_NAMES.filter((n) => n !== 'HOOK_DENY');
|
|
|
|
for (const version of VERSIONS) {
|
|
for (const outcome of ['PASS', 'FAIL', ...NAMES_ACCEPTED_BY_BOTH_TERMINATORS]) {
|
|
test(`${outcome} under ${version}: runMain and terminateNow agree`, () => {
|
|
const fromRunMain = runMainExit(outcome, version);
|
|
const fromTerminateNow = terminateNowExit(outcome, version);
|
|
assert.equal(
|
|
fromRunMain.status, fromTerminateNow.status,
|
|
`runMain exited ${fromRunMain.status} (stderr: ${fromRunMain.stderr}) but terminateNow exited `
|
|
+ `${fromTerminateNow.status} (stderr: ${fromTerminateNow.stderr}) for ${outcome}/${version}`,
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
// The restriction itself, made executable: HOOK_DENY (exit code 2) is the
|
|
// ONE outcome the two terminators must NOT agree on. runMain must refuse to
|
|
// produce it (a diagnosable, non-2 exit, never a silent drain to 2), while
|
|
// terminateNow — the only sanctioned write-then-terminate path — still
|
|
// delivers it. Run under both contract versions: the refusal is gated on
|
|
// the PROJECTED code (version-invariant for HOOK_DENY per projectOutcome),
|
|
// not on version, so it must hold identically under v1 and v2.
|
|
for (const version of VERSIONS) {
|
|
test(`HOOK_DENY under ${version}: runMain refuses it (non-2, diagnostic naming HOOK_DENY and terminateNow); terminateNow still exits 2`, () => {
|
|
const fromRunMain = runMainExit('HOOK_DENY', version);
|
|
assert.notEqual(
|
|
fromRunMain.status, 2,
|
|
`runMain must NEVER produce exit code 2 — that is terminateNow-only; stderr: ${fromRunMain.stderr}`,
|
|
);
|
|
assert.ok(
|
|
fromRunMain.stderr.includes('HOOK_DENY'),
|
|
`expected the refusal diagnostic to name HOOK_DENY; got: ${fromRunMain.stderr}`,
|
|
);
|
|
assert.ok(
|
|
fromRunMain.stderr.includes('terminateNow'),
|
|
`expected the refusal diagnostic to name terminateNow; got: ${fromRunMain.stderr}`,
|
|
);
|
|
|
|
const fromTerminateNow = terminateNowExit('HOOK_DENY', version);
|
|
assert.equal(
|
|
fromTerminateNow.status, 2,
|
|
`terminateNow must still exit 2 for HOOK_DENY; stderr: ${fromTerminateNow.stderr}`,
|
|
);
|
|
});
|
|
}
|
|
|
|
// #3906 follow-up: a matrix where every row merely agrees between the two
|
|
// terminators is satisfiable by a build that ignores GSD_EXIT_CONTRACT
|
|
// entirely (both terminators would then agree on the un-wired v1 default
|
|
// for every row, 16/16, and the matrix above would still read green). This
|
|
// block is the non-vacuousness proof the brief demands: it asserts the ONE
|
|
// row that MUST be version-sensitive actually differs by version, and
|
|
// spot-checks a control row that must NOT.
|
|
test('non-vacuousness: DEGRADED is version-sensitive via ambient GSD_EXIT_CONTRACT', () => {
|
|
const runMainV1 = runMainExit('DEGRADED', 'v1');
|
|
const runMainV2 = runMainExit('DEGRADED', 'v2');
|
|
const terminateNowV1 = terminateNowExit('DEGRADED', 'v1');
|
|
const terminateNowV2 = terminateNowExit('DEGRADED', 'v2');
|
|
|
|
assert.equal(runMainV1.status, 0, `runMain DEGRADED under v1 must be 0; stderr: ${runMainV1.stderr}`);
|
|
assert.equal(runMainV2.status, 80, `runMain DEGRADED under v2 must be 80; stderr: ${runMainV2.stderr}`);
|
|
assert.equal(
|
|
terminateNowV1.status, 0,
|
|
`terminateNow DEGRADED under v1 must be 0; stderr: ${terminateNowV1.stderr}`,
|
|
);
|
|
assert.equal(
|
|
terminateNowV2.status, 80,
|
|
`terminateNow DEGRADED under v2 must be 80; stderr: ${terminateNowV2.stderr}`,
|
|
);
|
|
assert.notEqual(
|
|
runMainV1.status, runMainV2.status,
|
|
'the matrix above is vacuous unless at least one outcome actually differs by version',
|
|
);
|
|
});
|
|
|
|
test('control: a non-DEGRADED outcome (FAIL) stays version-invariant via ambient GSD_EXIT_CONTRACT', () => {
|
|
const runMainV1 = runMainExit('FAIL', 'v1');
|
|
const runMainV2 = runMainExit('FAIL', 'v2');
|
|
assert.equal(runMainV1.status, 1);
|
|
assert.equal(runMainV2.status, 1);
|
|
});
|
|
});
|
|
|
|
// ─── #3906 acceptance criterion, made executable ────────────────────────────
|
|
//
|
|
// "user can run the gsd-tools command with --exit-contract=v2 (or
|
|
// GSD_EXIT_CONTRACT=v2 in the environment) and observe the v2 registry
|
|
// projection on the exit status; absent both, the same command yields the v1
|
|
// integers." Nothing in the module wired the ambient process to the
|
|
// terminators before this: getContractVersion() only ever read the shared
|
|
// cell, and nothing populated that cell absent an explicit
|
|
// resolveContractVersion()/setContractVersion() call — so a bare
|
|
// `GSD_EXIT_CONTRACT=v2 node -e "...terminateNow('DEGRADED')..."` exited 0,
|
|
// not 80. These tests spawn a fresh child per case (a fresh process has an
|
|
// empty cell) and touch NOTHING but the documented public surface.
|
|
describe('#3906: ambient GSD_EXIT_CONTRACT/--exit-contract wiring (acceptance criterion)', () => {
|
|
test('terminateNow: GSD_EXIT_CONTRACT=v2 -> DEGRADED exits 80', () => {
|
|
const r = toLegacyResult(runNode(['-e', [
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`c.terminateNow('DEGRADED', {});`,
|
|
].join('\n')], { timeoutMs: PROBE_TIMEOUT_MS, env: { ...process.env, GSD_EXIT_CONTRACT: 'v2' } }));
|
|
assert.equal(r.status, 80, `stderr: ${r.stderr}`);
|
|
});
|
|
|
|
test('terminateNow: no env, no flag -> DEGRADED exits 0 (v1 default unchanged)', () => {
|
|
const env = { ...process.env };
|
|
delete env.GSD_EXIT_CONTRACT;
|
|
const r = toLegacyResult(runNode(['-e', [
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`c.terminateNow('DEGRADED', {});`,
|
|
].join('\n')], { timeoutMs: PROBE_TIMEOUT_MS, env }));
|
|
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
});
|
|
|
|
test('runMain: GSD_EXIT_CONTRACT=v2 -> DEGRADED exits 80', () => {
|
|
const r = toLegacyResult(runNode(['-e', [
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`c.runMain(() => 'DEGRADED');`,
|
|
`setImmediate(() => {});`,
|
|
].join('\n')], { timeoutMs: PROBE_TIMEOUT_MS, env: { ...process.env, GSD_EXIT_CONTRACT: 'v2' } }));
|
|
assert.equal(r.status, 80, `stderr: ${r.stderr}`);
|
|
});
|
|
|
|
test('runMain: no env, no flag -> DEGRADED exits 0 (v1 default unchanged)', () => {
|
|
const env = { ...process.env };
|
|
delete env.GSD_EXIT_CONTRACT;
|
|
const r = toLegacyResult(runNode(['-e', [
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`c.runMain(() => 'DEGRADED');`,
|
|
`setImmediate(() => {});`,
|
|
].join('\n')], { timeoutMs: PROBE_TIMEOUT_MS, env }));
|
|
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
});
|
|
|
|
// `node -e "<script>" --exit-contract=v2` makes NODE itself consume the
|
|
// trailing flag (Node's own CLI parser reports an unknown-option usage
|
|
// error, exit 9) rather than leaving it in the CHILD's process.argv. A
|
|
// real CLI entrypoint is invoked as `node <file> --exit-contract=v2`, so
|
|
// the flag path is proven with a real temp script file, not `-e`.
|
|
test('flag path: node <file> --exit-contract=v2 -> terminateNow DEGRADED exits 80', (t) => {
|
|
const dir = createTempDir('gsd-3906-flag-');
|
|
t.after(() => cleanup(dir));
|
|
const scriptPath = path.join(dir, 'terminate-degraded.cjs');
|
|
fs.writeFileSync(scriptPath, [
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`c.terminateNow('DEGRADED', {});`,
|
|
'',
|
|
].join('\n'));
|
|
const env = { ...process.env };
|
|
delete env.GSD_EXIT_CONTRACT;
|
|
const r = toLegacyResult(runNode([scriptPath, '--exit-contract=v2'], { timeoutMs: PROBE_TIMEOUT_MS, env }));
|
|
assert.equal(r.status, 80, `stderr: ${r.stderr}`);
|
|
});
|
|
|
|
test('flag path: node <file> with no flag -> terminateNow DEGRADED exits 0', (t) => {
|
|
const dir = createTempDir('gsd-3906-flag-');
|
|
t.after(() => cleanup(dir));
|
|
const scriptPath = path.join(dir, 'terminate-degraded.cjs');
|
|
fs.writeFileSync(scriptPath, [
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`c.terminateNow('DEGRADED', {});`,
|
|
'',
|
|
].join('\n'));
|
|
const env = { ...process.env };
|
|
delete env.GSD_EXIT_CONTRACT;
|
|
const r = toLegacyResult(runNode([scriptPath], { timeoutMs: PROBE_TIMEOUT_MS, env }));
|
|
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
});
|
|
|
|
// setContractVersion itself is internal (not exported); resolveContractVersion
|
|
// is the module's own sanctioned way to write an explicit version into the
|
|
// shared cell, and it is what this precedence rests on: getContractVersion's
|
|
// lazy ambient-resolution path must be skipped entirely once the cell is
|
|
// already populated, so a later read of GSD_EXIT_CONTRACT must NOT unseat it.
|
|
test('an explicit resolveContractVersion() call beats a later ambient GSD_EXIT_CONTRACT read (precedence)', () => {
|
|
const r = toLegacyResult(runNode(['-e', [
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`c.resolveContractVersion({ argv: ['node','x','--exit-contract=v1'], env: {} });`,
|
|
`c.terminateNow('DEGRADED', {});`,
|
|
].join('\n')], { timeoutMs: PROBE_TIMEOUT_MS, env: { ...process.env, GSD_EXIT_CONTRACT: 'v2' } }));
|
|
assert.equal(
|
|
r.status, 0,
|
|
`an explicit resolveContractVersion('v1') call must beat a later ambient GSD_EXIT_CONTRACT=v2 read; stderr: ${r.stderr}`,
|
|
);
|
|
});
|
|
|
|
test('GSD_EXIT_CONTRACT=v3 (invalid) still THROWS on the lazy ambient path, never silently v1 (runMain: generic-throw arm, exit 1)', () => {
|
|
const r = toLegacyResult(runNode(['-e', [
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`c.runMain(() => 'DEGRADED');`,
|
|
`setImmediate(() => {});`,
|
|
].join('\n')], { timeoutMs: PROBE_TIMEOUT_MS, env: { ...process.env, GSD_EXIT_CONTRACT: 'v3' } }));
|
|
assert.equal(r.status, 1, `expected the generic-throw exit code 1; stderr: ${r.stderr}`);
|
|
assert.ok(
|
|
/unrecognized exit-contract version/.test(r.stderr),
|
|
`expected the resolveContractVersion rejection message on stderr; got: ${r.stderr.slice(0, 300)}`,
|
|
);
|
|
});
|
|
|
|
// #3906 follow-up (P7/#3911 hardening): terminateNow is TOTAL — unlike
|
|
// runMain above, an invalid ambient contract version must NOT propagate a
|
|
// throw at all (an enforcement-hook caller's own outer catch could turn
|
|
// that into a fail-open exit 0). It must terminate deterministically with
|
|
// INTERNAL (70) and diagnose on stderr instead.
|
|
test('terminateNow: GSD_EXIT_CONTRACT=v3 (invalid) exits 70 (INTERNAL), not silently v1 and not a propagated throw', () => {
|
|
const r = toLegacyResult(runNode(['-e', [
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`c.terminateNow('DEGRADED', {});`,
|
|
].join('\n')], { timeoutMs: PROBE_TIMEOUT_MS, env: { ...process.env, GSD_EXIT_CONTRACT: 'v3' } }));
|
|
assert.equal(r.status, 70, `expected INTERNAL (70); stderr: ${r.stderr}`);
|
|
assert.ok(
|
|
/unrecognized exit-contract version/.test(r.stderr),
|
|
`expected the resolveContractVersion rejection message on stderr; got: ${r.stderr.slice(0, 300)}`,
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── #3912 regression: `--exit-contract=<v>` in LEADING argv position ──────
|
|
//
|
|
// resolveContractVersion() scans argv non-destructively (findExitContractFlag,
|
|
// gsd-core/bin/lib/cli-exit.cjs). Nothing previously spliced the flag out of
|
|
// the `gsd-tools` CLI dispatcher's own argv before it fell through to command
|
|
// dispatch — the `--json-errors` block did this splice for itself, but
|
|
// `--exit-contract=<v>` never got the same treatment. The dispatcher treats
|
|
// argv[0] as the command name, so:
|
|
// `gsd-tools --exit-contract=v2 state validate --strict` (leading) died
|
|
// with "Unknown command: --exit-contract=v2" (exit 64) — the flag was never
|
|
// consumed and squatted on the command-name slot.
|
|
// `gsd-tools state-snapshot --exit-contract=v2` (trailing) worked, because
|
|
// the flag landed after the command name and never collided with dispatch.
|
|
describe('#3912: gsd-tools dispatcher splices --exit-contract=<v> regardless of argv position', () => {
|
|
const GSD_TOOLS_BIN = path.resolve(__dirname, '../gsd-core/bin/gsd-tools.cjs');
|
|
|
|
function run(args, options = {}) {
|
|
return toLegacyResult(runNode([GSD_TOOLS_BIN, ...args], { timeoutMs: PROBE_TIMEOUT_MS, ...options }));
|
|
}
|
|
|
|
// Fixture: a temp dir with a `.planning/` directory but no `STATE.md`, so
|
|
// `state-snapshot` takes the "STATE.md not found" branch — which the
|
|
// outcome cell resolves to exit 80 under v2 and exit 0 under v1. Pinning
|
|
// these exact numbers (rather than "not 64" / "leading == trailing")
|
|
// catches the case where both positions dispatch but land on the SAME
|
|
// wrong contract.
|
|
let fixtureDir;
|
|
afterEach(() => {
|
|
if (fixtureDir) {
|
|
cleanup(fixtureDir);
|
|
fixtureDir = undefined;
|
|
}
|
|
});
|
|
function makeFixture() {
|
|
fixtureDir = createTempDir();
|
|
fs.mkdirSync(path.join(fixtureDir, '.planning'), { recursive: true });
|
|
return fixtureDir;
|
|
}
|
|
|
|
test('leading position dispatches (no "Unknown command", not exit 64)', () => {
|
|
const r = run(['--exit-contract=v2', 'state', 'validate', '--strict']);
|
|
assert.notEqual(r.status, 64, `must not fall into the unknown-command path; stderr: ${r.stderr}`);
|
|
assert.ok(
|
|
!/Unknown command/.test(r.stderr),
|
|
`leading --exit-contract=v2 must not be treated as the command name; stderr: ${r.stderr}`,
|
|
);
|
|
});
|
|
|
|
test('trailing position still dispatches (no regression)', () => {
|
|
const r = run(['state-snapshot', '--exit-contract=v2']);
|
|
assert.notEqual(r.status, 64, `must not regress into the unknown-command path; stderr: ${r.stderr}`);
|
|
assert.ok(
|
|
!/Unknown command/.test(r.stderr),
|
|
`trailing --exit-contract=v2 must keep dispatching; stderr: ${r.stderr}`,
|
|
);
|
|
});
|
|
|
|
test('leading and trailing position agree on the SAME pinned exit code, per contract version', () => {
|
|
const dir = makeFixture();
|
|
const leadingV2 = run(['--exit-contract=v2', 'state-snapshot', `--cwd=${dir}`]);
|
|
const trailingV2 = run(['state-snapshot', `--cwd=${dir}`, '--exit-contract=v2']);
|
|
const leadingV1 = run(['--exit-contract=v1', 'state-snapshot', `--cwd=${dir}`]);
|
|
const trailingV1 = run(['state-snapshot', `--cwd=${dir}`, '--exit-contract=v1']);
|
|
assert.equal(leadingV2.status, 80, `leading v2 must exit 80; stderr: ${leadingV2.stderr}`);
|
|
assert.equal(trailingV2.status, 80, `trailing v2 must exit 80; stderr: ${trailingV2.stderr}`);
|
|
assert.equal(leadingV1.status, 0, `leading v1 must exit 0; stderr: ${leadingV1.stderr}`);
|
|
assert.equal(trailingV1.status, 0, `trailing v1 must exit 0; stderr: ${trailingV1.stderr}`);
|
|
});
|
|
|
|
test('an invalid leading value (v3) fails loudly rather than silently defaulting to v1', () => {
|
|
const r = run(['--exit-contract=v3', 'state-snapshot']);
|
|
assert.notEqual(r.status, 80, 'an invalid contract version must not silently resolve to a valid v2 exit code');
|
|
assert.ok(
|
|
/unrecognized exit-contract version/.test(r.stderr),
|
|
`expected the resolveContractVersion rejection message on stderr; got: ${r.stderr.slice(0, 300)}`,
|
|
);
|
|
// Distinguishes this from the PRE-FIX build, where the leading flag was
|
|
// never spliced: pre-fix stderr contains BOTH "Unknown command:
|
|
// --exit-contract=v3" (from the dispatcher rejecting the leading token)
|
|
// AND the resolve error (raised lazily via error() -> getContractVersion
|
|
// later in the same run). Post-fix, the flag is spliced before dispatch,
|
|
// so only the resolve error appears.
|
|
assert.ok(
|
|
!/Unknown command/.test(r.stderr),
|
|
`leading --exit-contract=v3 must be spliced before dispatch, not treated as the command name; stderr: ${r.stderr}`,
|
|
);
|
|
});
|
|
|
|
test('multiple --exit-contract= tokens: first match wins, no leftover token reaches the dispatcher', () => {
|
|
const dir = makeFixture();
|
|
const r = run(['--exit-contract=v2', '--exit-contract=v1', 'state-snapshot', `--cwd=${dir}`]);
|
|
assert.equal(r.status, 80, `first-match (v2) must win; stderr: ${r.stderr}`);
|
|
assert.ok(
|
|
!/Unknown command/.test(r.stderr),
|
|
`every --exit-contract= token must be spliced, not just the first; stderr: ${r.stderr}`,
|
|
);
|
|
});
|
|
|
|
test('leading --exit-contract=v2 does not break run-with-timeout dispatch (#3912 P1 regression)', () => {
|
|
const r = run(['--exit-contract=v2', 'run-with-timeout', '5', '--', 'node', '-e', 'process.exit(0)']);
|
|
assert.equal(r.status, 0, `child must run and exit 0, not die with Unknown command; stderr: ${r.stderr}`);
|
|
assert.ok(
|
|
!/Unknown command/.test(r.stderr),
|
|
`leading --exit-contract=v2 must not intercept run-with-timeout's own dispatch; stderr: ${r.stderr}`,
|
|
);
|
|
});
|
|
|
|
test('leading --json-errors does not break run-with-timeout dispatch (#3912 P1 regression)', () => {
|
|
const r = run(['--json-errors', 'run-with-timeout', '5', '--', 'node', '-e', 'process.exit(0)']);
|
|
assert.equal(r.status, 0, `child must run and exit 0, not die with Unknown command; stderr: ${r.stderr}`);
|
|
assert.ok(
|
|
!/Unknown command/.test(r.stderr),
|
|
`leading --json-errors must not intercept run-with-timeout's own dispatch; stderr: ${r.stderr}`,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('#3906: terminateNow', () => {
|
|
function spawnTerminateNow(lines) {
|
|
return toLegacyResult(runNode(['-e', lines.join('\n')], { timeoutMs: PROBE_TIMEOUT_MS }));
|
|
}
|
|
|
|
test('HOOK_DENY exits 2 with the payload written to stdout', () => {
|
|
const r = spawnTerminateNow([
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`c.terminateNow('HOOK_DENY', { reason: 'blocked-by-guard', detail: 'x' });`,
|
|
]);
|
|
assert.equal(r.status, 2);
|
|
assert.deepEqual(JSON.parse(r.stdout), { reason: 'blocked-by-guard', detail: 'x' });
|
|
});
|
|
|
|
test('HOOK_DENY writes the SAME payload to both stdout and stderr (Kimi reads exit-2 output from stderr)', () => {
|
|
const r = spawnTerminateNow([
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`c.terminateNow('HOOK_DENY', { reason: 'blocked' });`,
|
|
]);
|
|
assert.equal(r.status, 2);
|
|
assert.deepEqual(JSON.parse(r.stdout), { reason: 'blocked' });
|
|
assert.deepEqual(JSON.parse(r.stderr), { reason: 'blocked' });
|
|
});
|
|
|
|
test('a non-deny outcome writes stdout only, not stderr', () => {
|
|
const r = spawnTerminateNow([
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`c.terminateNow('FAIL', { reason: 'not-a-deny' });`,
|
|
]);
|
|
assert.equal(r.status, 1);
|
|
assert.deepEqual(JSON.parse(r.stdout), { reason: 'not-a-deny' });
|
|
assert.equal(r.stderr, '');
|
|
});
|
|
|
|
test('#3911: HOOK_DENY with NO stderrPayload arg — backward-compatible default: fd1 and fd2 both get the serialized payload', () => {
|
|
const r = spawnTerminateNow([
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`c.terminateNow('HOOK_DENY', { reason: 'blocked-default' });`,
|
|
]);
|
|
assert.equal(r.status, 2);
|
|
assert.deepEqual(JSON.parse(r.stdout), { reason: 'blocked-default' });
|
|
assert.deepEqual(JSON.parse(r.stderr), { reason: 'blocked-default' });
|
|
});
|
|
|
|
test('#3911: HOOK_DENY with a STRING stderrPayload — fd1 gets JSON, fd2 gets the raw string verbatim', () => {
|
|
const r = spawnTerminateNow([
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`c.terminateNow('HOOK_DENY', { decision: 'block', reason: 'shrink too large' }, 'shrink too large');`,
|
|
]);
|
|
assert.equal(r.status, 2);
|
|
assert.deepEqual(JSON.parse(r.stdout), { decision: 'block', reason: 'shrink too large' });
|
|
assert.equal(r.stderr, 'shrink too large', `expected raw string on stderr, got: ${r.stderr}`);
|
|
assert.throws(() => JSON.parse(r.stderr), SyntaxError,
|
|
'a raw reason string must NOT itself be JSON-parseable as an object');
|
|
});
|
|
|
|
test('#3911: HOOK_DENY with an OBJECT stderrPayload — fd2 gets that object serialized, not the fd1 payload', () => {
|
|
const r = spawnTerminateNow([
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`c.terminateNow('HOOK_DENY', { full: 'stdout-payload' }, { distinct: 'stderr-payload' });`,
|
|
]);
|
|
assert.equal(r.status, 2);
|
|
assert.deepEqual(JSON.parse(r.stdout), { full: 'stdout-payload' });
|
|
assert.deepEqual(JSON.parse(r.stderr), { distinct: 'stderr-payload' });
|
|
});
|
|
|
|
test('#3911: a PASS outcome with a stderrPayload writes NOTHING to stderr — stderr is a deny-only channel', () => {
|
|
const r = spawnTerminateNow([
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`c.terminateNow('PASS', { ok: true }, 'should never reach stderr');`,
|
|
]);
|
|
assert.equal(r.status, 0);
|
|
assert.deepEqual(JSON.parse(r.stdout), { ok: true });
|
|
assert.equal(r.stderr, '', `expected empty stderr for a non-deny outcome; got: ${r.stderr}`);
|
|
});
|
|
|
|
test('#3911: terminateNow(PASS, undefined) exits 0 with empty stdout', () => {
|
|
const r = spawnTerminateNow([
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`c.terminateNow('PASS', undefined);`,
|
|
]);
|
|
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
assert.equal(r.stdout, '', `expected empty stdout, got: ${r.stdout}`);
|
|
assert.equal(r.stderr, '', `expected empty stderr, got: ${r.stderr}`);
|
|
});
|
|
|
|
test('#3911: terminateNow(HOOK_DENY, undefined) exits 2 with EMPTY stdout and EMPTY stderr', () => {
|
|
const r = spawnTerminateNow([
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`c.terminateNow('HOOK_DENY', undefined);`,
|
|
]);
|
|
assert.equal(r.status, 2, `stderr: ${r.stderr}`);
|
|
assert.equal(r.stdout, '', `expected empty stdout, got: ${r.stdout}`);
|
|
assert.equal(r.stderr, '', `expected empty stderr, got: ${r.stderr}`);
|
|
});
|
|
|
|
// The defect this whole file is regressing (#3911): `deny(undefined,
|
|
// 'some reason')` used to exit 2 with EMPTY stderr because the fd1 write of
|
|
// `undefined` threw (JSON.stringify(undefined) -> undefined,
|
|
// Buffer.from(undefined,'utf8') throws) and the SHARED try/catch aborted
|
|
// before the fd2 write of `stderrPayload` ever ran.
|
|
test('#3911: terminateNow(HOOK_DENY, undefined, "reason text") exits 2 with EMPTY stdout and stderr EXACTLY "reason text"', () => {
|
|
const r = spawnTerminateNow([
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`c.terminateNow('HOOK_DENY', undefined, 'reason text');`,
|
|
]);
|
|
assert.equal(r.status, 2, `stderr: ${r.stderr}`);
|
|
assert.equal(r.stdout, '', `expected empty stdout, got: ${r.stdout}`);
|
|
assert.equal(r.stderr, 'reason text', `expected exactly "reason text" on stderr, got: ${r.stderr}`);
|
|
});
|
|
|
|
// ── #3911 regression: the two stream emissions are INDEPENDENT ───────────
|
|
// These two tests are the direct regression coverage for the defect: a
|
|
// shared try/catch around both writes meant a failure serializing/writing
|
|
// fd 1 aborted before fd 2 (or vice versa) ever ran. Both must FAIL against
|
|
// the pre-fix single-try-block implementation.
|
|
test('#3911 regression: fd 1 write throws but fd 2 STILL receives its payload, exit code unchanged', () => {
|
|
const r = spawnTerminateNow([
|
|
`const fs = require('node:fs');`,
|
|
`const origWriteSync = fs.writeSync;`,
|
|
`fs.writeSync = (fd, ...rest) => {`,
|
|
` if (fd === 1) throw new Error('injected fd1 failure');`,
|
|
` return origWriteSync(fd, ...rest);`,
|
|
`};`,
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`c.terminateNow('HOOK_DENY', { reason: 'fd1-throws' });`,
|
|
]);
|
|
assert.equal(r.status, 2, `exit code must be unchanged by the fd1 failure; stderr: ${r.stderr}`);
|
|
assert.equal(r.stdout, '', 'fd1 write failed, so stdout must be empty (not a partial payload)');
|
|
assert.deepEqual(
|
|
JSON.parse(r.stderr), { reason: 'fd1-throws' },
|
|
`fd2 must still receive its payload despite the fd1 failure; got: ${r.stderr}`,
|
|
);
|
|
});
|
|
|
|
test('#3911 regression: fd 2 write throws but fd 1 STILL receives its payload, exit code unchanged', () => {
|
|
const r = spawnTerminateNow([
|
|
`const fs = require('node:fs');`,
|
|
`const origWriteSync = fs.writeSync;`,
|
|
`fs.writeSync = (fd, ...rest) => {`,
|
|
` if (fd === 2) throw new Error('injected fd2 failure');`,
|
|
` return origWriteSync(fd, ...rest);`,
|
|
`};`,
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`c.terminateNow('HOOK_DENY', { reason: 'fd2-throws' });`,
|
|
]);
|
|
assert.equal(r.status, 2, `exit code must be unchanged by the fd2 failure; stderr: ${r.stderr}`);
|
|
assert.deepEqual(
|
|
JSON.parse(r.stdout), { reason: 'fd2-throws' },
|
|
`fd1 must still receive its payload despite the fd2 failure; got: ${r.stdout}`,
|
|
);
|
|
assert.equal(r.stderr, '', 'fd2 write failed, so stderr must be empty (not a partial payload)');
|
|
});
|
|
|
|
// Cross-platform IO-failure injection: a monkeypatched THROWING fs.writeSync,
|
|
// restored implicitly by process exit — never chmod (CONTRIBUTING.md /
|
|
// CLAUDE.md: mode-bit tricks are bypassed by root/CI and leak resources).
|
|
test('a failed write still exits with the projected code (the fail-open control)', () => {
|
|
const r = spawnTerminateNow([
|
|
`const fs = require('node:fs');`,
|
|
`fs.writeSync = () => { throw new Error('injected write failure'); };`,
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`c.terminateNow('FAIL', { reason: 'x' });`,
|
|
]);
|
|
assert.equal(r.status, 1, 'the exit code must still be the projected one, not corrupted by the write failure');
|
|
assert.equal(r.stdout, '', 'nothing could be written, so stdout must be empty (not a partial payload)');
|
|
});
|
|
|
|
test('a failed write still exits 2 for a deny, including the stderr write attempt', () => {
|
|
const r = spawnTerminateNow([
|
|
`const fs = require('node:fs');`,
|
|
`fs.writeSync = () => { throw new Error('injected write failure'); };`,
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`c.terminateNow('HOOK_DENY', { reason: 'x' });`,
|
|
]);
|
|
assert.equal(r.status, 2);
|
|
assert.equal(r.stdout, '');
|
|
assert.equal(r.stderr, '');
|
|
});
|
|
|
|
test('does not return: nothing after the call executes', () => {
|
|
const r = spawnTerminateNow([
|
|
`const fs = require('node:fs');`,
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`c.terminateNow('PASS', { ok: true });`,
|
|
`fs.writeSync(1, 'SHOULD_NEVER_APPEAR');`,
|
|
]);
|
|
assert.equal(r.status, 0);
|
|
assert.equal(r.stdout, JSON.stringify({ ok: true }));
|
|
assert.ok(!r.stdout.includes('SHOULD_NEVER_APPEAR'), `code after terminateNow must never run; got: ${r.stdout}`);
|
|
});
|
|
|
|
test('a large payload (bigger than a pipe buffer) arrives whole', () => {
|
|
// 256KB comfortably exceeds every common OS pipe-buffer size (64KB on
|
|
// Linux, 16 pages, is the largest common default), so a single
|
|
// fs.writeSync call is very likely to under-write without the
|
|
// write-until-drained loop.
|
|
//
|
|
// The payload is built INSIDE the child from PROBE_BIG_PAYLOAD_SIZE
|
|
// (an env var carrying a small integer), never embedded as a literal
|
|
// in the `-e` script text handed to spawnSync. Embedding the 256KB
|
|
// string directly in that argv element (the previous form of this
|
|
// test) is what actually failed on the remote Linux matrix: Linux's
|
|
// execve(2) enforces MAX_ARG_STRLEN, a 128KiB-per-argv/envp-string cap
|
|
// (32 pages; see `man execve` NOTES), independent of any OS pipe
|
|
// buffer. A single argv element over that cap makes spawnSync fail at
|
|
// the exec() level with ENAMETOOLONG/E2BIG — no process ever starts,
|
|
// which reads back as `status: null, stdout: ''`, exactly the recorded
|
|
// failure signature. macOS enforces no such per-string cap (only a much
|
|
// larger whole-argv+env budget), which is why 25 macOS attempts at
|
|
// reproducing this via the old form never turned up the mechanism.
|
|
// Measured on this machine (see the phase report for the full probe
|
|
// transcript): an async `child_process.spawn` with a concurrently
|
|
// draining reader delivers a 256KB terminateNow payload whole, byte for
|
|
// byte, in ~30ms; `spawnSync` with the DEFAULT (non-draining-by-JS,
|
|
// internally-drained-by-libuv) pipe stdio used by this suite's `runNode`
|
|
// never hangs at any size up to 64MB either — it either succeeds
|
|
// (payload under the ~1MB default `maxBuffer`) or is classified
|
|
// `BUFFER_OVERFLOW` (over it), never a stall. terminateNow's
|
|
// write-until-drained loop itself was never the defect; the previous
|
|
// form of this test just could not reach the child at all on Linux.
|
|
const size = 256 * 1024;
|
|
const r = toLegacyResult(runNode(['-e', [
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`const n = parseInt(process.env.PROBE_BIG_PAYLOAD_SIZE, 10);`,
|
|
`const big = 'x'.repeat(n);`,
|
|
`c.terminateNow('PASS', { big });`,
|
|
].join('\n')], {
|
|
timeoutMs: PROBE_TIMEOUT_MS,
|
|
env: { ...process.env, PROBE_BIG_PAYLOAD_SIZE: String(size) },
|
|
}));
|
|
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
const parsed = JSON.parse(r.stdout);
|
|
assert.equal(parsed.big.length, size, 'the payload must arrive byte-for-byte whole, not truncated');
|
|
assert.equal(parsed.big, 'x'.repeat(size));
|
|
});
|
|
|
|
// ── terminateNow is TOTAL: no input can make it return or throw ──────────
|
|
//
|
|
// P7 (#3911) wires 19 enforcement hooks onto terminateNow, and hooks are
|
|
// exactly the callers whose OWN outer catch may fail open
|
|
// (`process.exit(0)`). If any of these malformed-call paths propagated a
|
|
// throw instead of terminating, unwinding into that catch would silently
|
|
// convert a deny into an allow — the regression this block exists to pin.
|
|
describe('terminateNow is total: no input can make it return or throw', () => {
|
|
test('an unregistered outcome name exits 70 (INTERNAL), with a stderr diagnostic naming it, and nothing after the call runs', () => {
|
|
const r = spawnTerminateNow([
|
|
`const fs = require('node:fs');`,
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`c.terminateNow('NOT_A_REGISTERED_OUTCOME', {});`,
|
|
`fs.writeSync(1, 'SHOULD_NEVER_APPEAR');`,
|
|
]);
|
|
assert.equal(r.status, 70, `expected INTERNAL (70); stderr: ${r.stderr}`);
|
|
assert.ok(
|
|
r.stderr.includes('NOT_A_REGISTERED_OUTCOME'),
|
|
`expected the diagnostic to name the offending outcome; got: ${r.stderr}`,
|
|
);
|
|
assert.ok(!r.stdout.includes('SHOULD_NEVER_APPEAR'), `code after terminateNow must never run; got: ${r.stdout}`);
|
|
});
|
|
|
|
test('an empty-string outcome exits 70', () => {
|
|
const r = spawnTerminateNow([
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`c.terminateNow('', {});`,
|
|
]);
|
|
assert.equal(r.status, 70, `expected INTERNAL (70); stderr: ${r.stderr}`);
|
|
});
|
|
|
|
test('a null outcome exits 70', () => {
|
|
const r = spawnTerminateNow([
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`c.terminateNow(null, {});`,
|
|
]);
|
|
assert.equal(r.status, 70, `expected INTERNAL (70); stderr: ${r.stderr}`);
|
|
});
|
|
|
|
test('FAIL under an invalid ambient GSD_EXIT_CONTRACT=v3 exits 70, not a Node crash and not a silent v1', () => {
|
|
const r = toLegacyResult(runNode(['-e', [
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`c.terminateNow('FAIL', {});`,
|
|
].join('\n')], { timeoutMs: PROBE_TIMEOUT_MS, env: { ...process.env, GSD_EXIT_CONTRACT: 'v3' } }));
|
|
assert.equal(r.status, 70, `expected INTERNAL (70), not a Node crash / silent v1; stderr: ${r.stderr}`);
|
|
assert.notEqual(r.status, 1, 'FAIL under a valid v1 resolution would exit 1 — 70 proves the invalid version was not silently accepted as v1');
|
|
});
|
|
|
|
// The regression that matters: a caller-side outer catch that fails open
|
|
// (process.exit(0)) must NEVER be reached. Written so it would FAIL
|
|
// against the previous implementation, where the HOOK_DENY collision
|
|
// guard's throw (and the other two throw sites) propagated out of
|
|
// terminateNow and into this exact catch, exiting 0.
|
|
test('the fail-open control: a try/catch wrapper around terminateNow that exits 0 on catch must still see exit 70, never 0', () => {
|
|
const r = spawnTerminateNow([
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`try {`,
|
|
` c.terminateNow('BOGUS', {});`,
|
|
`} catch {`,
|
|
` process.exit(0);`,
|
|
`}`,
|
|
]);
|
|
assert.equal(
|
|
r.status, 70,
|
|
`the outer catch must NEVER be reached (which would exit 0); got ${r.status}, stderr: ${r.stderr}`,
|
|
);
|
|
assert.notEqual(r.status, 0, 'a fail-open exit 0 here would mean a deny silently became an allow');
|
|
});
|
|
|
|
test('existing valid-outcome behaviour is unchanged: HOOK_DENY still exits 2, UNAVAILABLE still exits 69', () => {
|
|
const deny = spawnTerminateNow([
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`c.terminateNow('HOOK_DENY', { reason: 'still-blocked' });`,
|
|
]);
|
|
assert.equal(deny.status, 2, `stderr: ${deny.stderr}`);
|
|
|
|
const unavailable = spawnTerminateNow([
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`c.terminateNow('UNAVAILABLE', { reason: 'still-unavailable' });`,
|
|
]);
|
|
assert.equal(unavailable.status, 69, `stderr: ${unavailable.stderr}`);
|
|
});
|
|
|
|
test('a throwing fs.writeSync for a VALID outcome still exits with the projected code, not 70', () => {
|
|
const r = spawnTerminateNow([
|
|
`const fs = require('node:fs');`,
|
|
`fs.writeSync = () => { throw new Error('injected write failure'); };`,
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`c.terminateNow('FAIL', { reason: 'x' });`,
|
|
]);
|
|
assert.equal(
|
|
r.status, 1,
|
|
`a failed payload write must still exit with the PROJECTED code (1 for FAIL), not fall into the ` +
|
|
`programming-error path (70); stderr: ${r.stderr}`,
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('#3906: runMain stays drain-then-exit (the reason two terminators exist)', () => {
|
|
test('process.on(\'exit\') still fires, and a pending timer is allowed to run before exit', () => {
|
|
const r = toLegacyResult(runNode(['-e', [
|
|
`const fs = require('node:fs');`,
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`process.on('exit', () => { fs.writeSync(1, 'ON_EXIT_FIRED\\n'); });`,
|
|
`setTimeout(() => { fs.writeSync(1, 'TIMER_FIRED\\n'); }, 20);`,
|
|
`c.runMain(() => 'FAIL');`,
|
|
].join('\n')], { timeoutMs: PROBE_TIMEOUT_MS }));
|
|
|
|
assert.equal(r.status, 1, `expected exit 1 from FAIL; stderr: ${r.stderr}`);
|
|
// If runMain had hard-exited (like terminateNow), the 20ms timer below
|
|
// would never have gotten to run — proving the process instead drained
|
|
// its event loop naturally (process.exitCode, never process.exit).
|
|
assert.ok(r.stdout.includes('TIMER_FIRED'), `pending timer must run before exit; got: ${JSON.stringify(r.stdout)}`);
|
|
assert.ok(r.stdout.includes('ON_EXIT_FIRED'), `'exit' handler must fire; got: ${JSON.stringify(r.stdout)}`);
|
|
assert.ok(
|
|
r.stdout.indexOf('TIMER_FIRED') < r.stdout.indexOf('ON_EXIT_FIRED'),
|
|
`the timer must fire BEFORE the 'exit' handler; got: ${JSON.stringify(r.stdout)}`,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('#3906: cross-copy — the built copy and the scripts copy agree', () => {
|
|
test('projectOutcome agrees across both copies for every outcome/version', () => {
|
|
const built = require(BUILT_CLI_EXIT_PATH);
|
|
const scripts = require(SCRIPTS_CLI_EXIT_PATH);
|
|
for (const version of VERSIONS) {
|
|
for (const outcome of ['PASS', 'FAIL', ...REGISTERED_NAMES]) {
|
|
assert.equal(
|
|
built.projectOutcome(outcome, version), scripts.projectOutcome(outcome, version),
|
|
`built vs scripts disagree for ${outcome}/${version}`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
// #3911 (ADR-3889 Phase 7) A6: EXTENDS the two-copy parity above to the
|
|
// THIRD emitted copy (hooks/lib/cli-exit.js + hooks/lib/exit-code-registry.js)
|
|
// rather than duplicating it. Outcome names are enumerated from the hooks
|
|
// registry itself — never hardcoded — so a future registry addition is
|
|
// covered automatically and a hooks-registry omission fails loudly here
|
|
// instead of silently under-testing the hooks copy.
|
|
test('the hooks copy agrees with both existing copies for every registered outcome/version', () => {
|
|
const built = require(BUILT_CLI_EXIT_PATH);
|
|
const scripts = require(SCRIPTS_CLI_EXIT_PATH);
|
|
const hooks = require(HOOKS_CLI_EXIT_PATH);
|
|
const { EXIT_CODES: hooksExitCodes } = require(HOOKS_EXIT_CODE_REGISTRY_PATH);
|
|
const hooksNames = hooksExitCodes.map((e) => e.name);
|
|
|
|
assert.deepStrictEqual(
|
|
[...hooksNames].sort(), [...REGISTERED_NAMES].sort(),
|
|
'the hooks registry must declare the exact same outcome set as the primary registry',
|
|
);
|
|
|
|
for (const version of VERSIONS) {
|
|
for (const outcome of ['PASS', 'FAIL', ...REGISTERED_NAMES]) {
|
|
const fromBuilt = built.projectOutcome(outcome, version);
|
|
const fromScripts = scripts.projectOutcome(outcome, version);
|
|
const fromHooks = hooks.projectOutcome(outcome, version);
|
|
assert.equal(fromScripts, fromBuilt, `scripts vs built disagree for ${outcome}/${version}`);
|
|
assert.equal(fromHooks, fromBuilt, `hooks vs built disagree for ${outcome}/${version}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
// #3911 A6: the json-error-mode cell is a `globalThis`-backed shared cell
|
|
// (see the #3906 "ONE json-error-mode cell" tests above for the built/scripts
|
|
// pair) — prove the hooks copy reads and writes the SAME cell, not a third,
|
|
// independent module-level flag that would silently diverge.
|
|
test('the json-error-mode cell is genuinely shared across all three copies (built, scripts, hooks)', () => {
|
|
const built = require(BUILT_CLI_EXIT_PATH);
|
|
const scripts = require(SCRIPTS_CLI_EXIT_PATH);
|
|
const hooks = require(HOOKS_CLI_EXIT_PATH);
|
|
const saved = built.getJsonErrorMode();
|
|
try {
|
|
built.setJsonErrorMode(true);
|
|
assert.equal(scripts.getJsonErrorMode(), true, 'scripts must observe the mode set through built');
|
|
assert.equal(hooks.getJsonErrorMode(), true, 'hooks must observe the mode set through built');
|
|
|
|
hooks.setJsonErrorMode(false);
|
|
assert.equal(built.getJsonErrorMode(), false, 'built must observe the mode set through hooks');
|
|
assert.equal(scripts.getJsonErrorMode(), false, 'scripts must observe the mode set through hooks');
|
|
|
|
scripts.setJsonErrorMode(true);
|
|
assert.equal(built.getJsonErrorMode(), true, 'built must observe the mode set through scripts');
|
|
assert.equal(hooks.getJsonErrorMode(), true, 'hooks must observe the mode set through scripts');
|
|
} finally {
|
|
built.setJsonErrorMode(saved);
|
|
}
|
|
});
|
|
|
|
test('scripts/lib/cli-exit.cjs + its sibling exit-code-registry.cjs load standalone, no gsd-core sibling', (t) => {
|
|
const dir = createTempDir('gsd-3906-standalone-');
|
|
t.after(() => cleanup(dir));
|
|
const copiedExit = path.join(dir, 'cli-exit.cjs');
|
|
const copiedRegistry = path.join(dir, 'exit-code-registry.cjs');
|
|
fs.copyFileSync(SCRIPTS_CLI_EXIT_PATH, copiedExit);
|
|
fs.copyFileSync(path.resolve(__dirname, '../scripts/lib/exit-code-registry.cjs'), copiedRegistry);
|
|
|
|
// This directory contains ONLY these two files — no node_modules, no
|
|
// gsd-core sibling. A successful load here is the behavioral proof (per
|
|
// CONTRIBUTING.md's ban on source-grepping a generated .cjs for its
|
|
// require() specifiers) that this module's only dependencies are
|
|
// node: builtins plus ./exit-code-registry.cjs: anything else would
|
|
// MODULE_NOT_FOUND in this exact isolation.
|
|
const r = toLegacyResult(runNode(['-e', [
|
|
`const c = require(${JSON.stringify(copiedExit)});`,
|
|
`process.stdout.write(JSON.stringify({`,
|
|
` degradedV2: c.projectOutcome('DEGRADED', 'v2'),`,
|
|
` hasTerminateNow: typeof c.terminateNow,`,
|
|
` hasRunMain: typeof c.runMain,`,
|
|
`}));`,
|
|
].join('\n')], { cwd: dir, timeoutMs: PROBE_TIMEOUT_MS }));
|
|
|
|
assert.ok(!r.stderr.includes('MODULE_NOT_FOUND'), `unexpected require failure; stderr: ${r.stderr}`);
|
|
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
assert.deepEqual(JSON.parse(r.stdout), { degradedV2: 80, hasTerminateNow: 'function', hasRunMain: 'function' });
|
|
});
|
|
|
|
test('the contract-version cell is shared across both copies', () => {
|
|
const r = toLegacyResult(runNode(['-e', [
|
|
`const built = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`const scripts = require(${JSON.stringify(SCRIPTS_CLI_EXIT_PATH)});`,
|
|
`if (built === scripts) throw new Error('expected two distinct module instances');`,
|
|
`built.resolveContractVersion({ argv: ['node','x','--exit-contract=v2'], env: {} });`,
|
|
`process.stdout.write(JSON.stringify({`,
|
|
` built: built.getContractVersion(),`,
|
|
` scripts: scripts.getContractVersion(),`,
|
|
`}));`,
|
|
].join('\n')], { timeoutMs: PROBE_TIMEOUT_MS }));
|
|
|
|
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
assert.deepEqual(
|
|
JSON.parse(r.stdout),
|
|
{ built: 'v2', scripts: 'v2' },
|
|
'both copies must read the version resolved through only ONE of them — two independent cells would diverge here',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── #3911 (ADR-3889 Phase 7, issue #3911): the hooks copy is load-bearing ──
|
|
//
|
|
// The WHOLE POINT of hooks/lib/cli-exit.js is that a shipped enforcement hook
|
|
// can terminate through terminateNow WITHOUT depending on any build
|
|
// artifact. gsd-core/bin/lib is gitignored tsc output and is ABSENT on a raw
|
|
// plugin-marketplace or git-clone install. These tests prove that property
|
|
// hermetically: by copying ONLY hooks/lib/cli-exit.js and its sibling
|
|
// hooks/lib/exit-code-registry.js into a fresh, otherwise-empty tmpdir (no
|
|
// gsd-core sibling, no node_modules) and requiring the copy from a CHILD
|
|
// process rooted there.
|
|
describe('#3911: hooks/lib/cli-exit.js loads and terminates with no build present (A4, load-bearing)', () => {
|
|
/** Copy both hooks/lib artifacts into a fresh, otherwise-empty tmpdir. */
|
|
function makeStandaloneHooksCopy(t) {
|
|
const dir = createTempDir('gsd-3911-hooks-standalone-');
|
|
t.after(() => cleanup(dir));
|
|
const copiedExit = path.join(dir, 'cli-exit.js');
|
|
const copiedRegistry = path.join(dir, 'exit-code-registry.js');
|
|
fs.copyFileSync(HOOKS_CLI_EXIT_PATH, copiedExit);
|
|
fs.copyFileSync(HOOKS_EXIT_CODE_REGISTRY_PATH, copiedRegistry);
|
|
return { dir, copiedExit, copiedRegistry };
|
|
}
|
|
|
|
// This test must FAIL if hooks/lib/cli-exit.js ever gains a require
|
|
// reaching outside hooks/lib/ (e.g. back into gsd-core/bin/lib, or a
|
|
// node_modules package): the tmpdir contains NOTHING else, so any such
|
|
// require resolves to MODULE_NOT_FOUND and the child crashes before
|
|
// terminateNow ever runs, failing every assertion below.
|
|
test('terminateNow(PASS) exits 0 with the payload on stdout, from a build-free tmpdir', (t) => {
|
|
const { dir, copiedExit } = makeStandaloneHooksCopy(t);
|
|
const r = toLegacyResult(runNode(['-e', [
|
|
`const c = require(${JSON.stringify(copiedExit)});`,
|
|
`c.terminateNow('PASS', { ok: true, from: 'hooks-standalone' });`,
|
|
].join('\n')], { cwd: dir, timeoutMs: PROBE_TIMEOUT_MS }));
|
|
|
|
assert.ok(
|
|
!r.stderr.includes('MODULE_NOT_FOUND'),
|
|
`the hooks copy must not require anything outside hooks/lib/; got: ${r.stderr.slice(0, 400)}`,
|
|
);
|
|
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
assert.deepEqual(JSON.parse(r.stdout), { ok: true, from: 'hooks-standalone' });
|
|
});
|
|
|
|
test('terminateNow(HOOK_DENY) exits 2 with the payload on BOTH stdout and stderr, from a build-free tmpdir', (t) => {
|
|
const { dir, copiedExit } = makeStandaloneHooksCopy(t);
|
|
const r = toLegacyResult(runNode(['-e', [
|
|
`const c = require(${JSON.stringify(copiedExit)});`,
|
|
`c.terminateNow('HOOK_DENY', { reason: 'blocked-by-guard', from: 'hooks-standalone' });`,
|
|
].join('\n')], { cwd: dir, timeoutMs: PROBE_TIMEOUT_MS }));
|
|
|
|
assert.ok(
|
|
!r.stderr.includes('MODULE_NOT_FOUND'),
|
|
`the hooks copy must not require anything outside hooks/lib/; got: ${r.stderr.slice(0, 400)}`,
|
|
);
|
|
assert.equal(r.status, 2, `stderr: ${r.stderr}`);
|
|
const expected = { reason: 'blocked-by-guard', from: 'hooks-standalone' };
|
|
assert.deepEqual(JSON.parse(r.stdout), expected);
|
|
assert.deepEqual(JSON.parse(r.stderr), expected);
|
|
});
|
|
|
|
// A5: sibling resolution. hooks/lib/cli-exit.js requires its OWN sibling
|
|
// (./exit-code-registry.js), not some other copy sitting elsewhere on the
|
|
// machine — proven by DELETING the sibling from the tmpdir and observing
|
|
// the require actually fail, then restoring it and observing success again.
|
|
test('resolves its OWN sibling exit-code-registry.js, not some other copy (A5)', (t) => {
|
|
const { dir, copiedExit, copiedRegistry } = makeStandaloneHooksCopy(t);
|
|
const registryBytes = fs.readFileSync(copiedRegistry);
|
|
|
|
// Sanity: with the sibling present, the copy loads clean.
|
|
const before = toLegacyResult(runNode(['-e', [
|
|
`const c = require(${JSON.stringify(copiedExit)});`,
|
|
`process.stdout.write(JSON.stringify({ hasTerminateNow: typeof c.terminateNow }));`,
|
|
].join('\n')], { cwd: dir, timeoutMs: PROBE_TIMEOUT_MS }));
|
|
assert.equal(before.status, 0, `stderr: ${before.stderr}`);
|
|
assert.deepEqual(JSON.parse(before.stdout), { hasTerminateNow: 'function' });
|
|
|
|
// Prove the failure: delete the sibling, require must fail. Single-file
|
|
// removal of a fixture we immediately restore below (not directory
|
|
// teardown; cleanup(dir) still runs via t.after() for the whole tmpdir).
|
|
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- see comment above
|
|
fs.rmSync(copiedRegistry);
|
|
try {
|
|
const missing = toLegacyResult(runNode(['-e', [
|
|
`require(${JSON.stringify(copiedExit)});`,
|
|
].join('\n')], { cwd: dir, timeoutMs: PROBE_TIMEOUT_MS }));
|
|
assert.notEqual(missing.status, 0, 'require must fail once the sibling registry is removed');
|
|
assert.ok(
|
|
missing.stderr.includes('MODULE_NOT_FOUND') || missing.stderr.includes('Cannot find module'),
|
|
`expected a module-resolution failure naming the missing sibling; got: ${missing.stderr.slice(0, 400)}`,
|
|
);
|
|
} finally {
|
|
// Restore in a finally so a failing assertion above cannot leave the
|
|
// tmpdir fixture (owned by this test, not a committed artifact) broken
|
|
// for any later step in this same test.
|
|
fs.writeFileSync(copiedRegistry, registryBytes);
|
|
}
|
|
|
|
// Confirm restoration actually fixes it — the negative-space check is
|
|
// meaningless without this positive control.
|
|
const after = toLegacyResult(runNode(['-e', [
|
|
`const c = require(${JSON.stringify(copiedExit)});`,
|
|
`process.stdout.write(JSON.stringify({ hasTerminateNow: typeof c.terminateNow }));`,
|
|
].join('\n')], { cwd: dir, timeoutMs: PROBE_TIMEOUT_MS }));
|
|
assert.equal(after.status, 0, `stderr: ${after.stderr}`);
|
|
assert.deepEqual(JSON.parse(after.stdout), { hasTerminateNow: 'function' });
|
|
});
|
|
});
|
|
|
|
// ─── #3911 A3: the --check generator guards can actually fail ──────────────
|
|
//
|
|
// CONTEXT.md's prove-it-can-fail rule: a guard that has never been observed
|
|
// to fail is not a guard. For BOTH new committed artifacts, corrupt a
|
|
// DISPOSABLE TMPDIR COPY of the artifact (never the committed file itself —
|
|
// test files in this repo run in parallel, and a sibling test
|
|
// (tests/exit-code-registry.test.cjs) asserts byte-equality on the real
|
|
// hooks/lib/exit-code-registry.js concurrently), run the generator's --check
|
|
// redirected at that tmpdir copy via its output-path override flag(s),
|
|
// assert it fails and names the file, then re-run --check against a
|
|
// freshly-restored tmpdir copy to confirm the guard actually clears. Nothing
|
|
// under hooks/ in the repo is ever written by either test.
|
|
describe('#3911: the --check guards for the new hooks/lib artifacts can actually fail (A3)', () => {
|
|
const REPO_ROOT = path.resolve(__dirname, '..');
|
|
const GEN_HOOKS_CLI_EXIT = path.join(REPO_ROOT, 'scripts', 'gen-hooks-cli-exit.cjs');
|
|
const GEN_EXIT_CODE_REGISTRY = path.join(REPO_ROOT, 'scripts', 'gen-exit-code-registry.cjs');
|
|
const registryGenerator = require(GEN_EXIT_CODE_REGISTRY);
|
|
// gen-hooks-cli-exit.cjs --check runs a real tsc compile of the whole
|
|
// project to a throwaway outDir (see its own COMPILE_TIMEOUT_MS=60000) —
|
|
// this needs a longer bound than a plain probe.
|
|
const CHECK_TIMEOUT_MS = 90000;
|
|
|
|
test('gen-hooks-cli-exit.cjs --check fails on a corrupted TMPDIR copy of hooks/lib/cli-exit.js, names the file, and clears on restore', (t) => {
|
|
const dir = createTempDir('gsd-3911-hooks-cli-exit-check-');
|
|
t.after(() => cleanup(dir));
|
|
const copiedOut = path.join(dir, 'cli-exit.js');
|
|
const original = fs.readFileSync(HOOKS_CLI_EXIT_PATH);
|
|
fs.writeFileSync(copiedOut, original);
|
|
|
|
fs.appendFileSync(copiedOut, '\n// corrupted-by-A3-test\n');
|
|
const r = toLegacyResult(runNode(
|
|
[GEN_HOOKS_CLI_EXIT, '--check', '--out', copiedOut],
|
|
{ timeoutMs: CHECK_TIMEOUT_MS },
|
|
));
|
|
assert.notEqual(r.status, 0, `--check must fail on a corrupted artifact; stderr: ${r.stderr}`);
|
|
assert.ok(
|
|
r.stderr.includes('cli-exit.js'),
|
|
`expected the failure to name the drifted file; got: ${r.stderr.slice(0, 400)}`,
|
|
);
|
|
|
|
fs.writeFileSync(copiedOut, original);
|
|
const restored = toLegacyResult(runNode(
|
|
[GEN_HOOKS_CLI_EXIT, '--check', '--out', copiedOut],
|
|
{ timeoutMs: CHECK_TIMEOUT_MS },
|
|
));
|
|
assert.equal(restored.status, 0, `--check must pass again once the artifact is restored; stderr: ${restored.stderr}`);
|
|
|
|
// The property this whole test exists to prove: the committed file was
|
|
// never touched, at any point, by any of the above.
|
|
assert.deepEqual(fs.readFileSync(HOOKS_CLI_EXIT_PATH), original, 'committed hooks/lib/cli-exit.js must be untouched');
|
|
});
|
|
|
|
test('gen-exit-code-registry.cjs --check fails on a corrupted TMPDIR copy of hooks/lib/exit-code-registry.js, names the file, and clears on restore', (t) => {
|
|
const dir = createTempDir('gsd-3911-exit-code-registry-check-');
|
|
t.after(() => cleanup(dir));
|
|
|
|
// Copy all FIVE generated artifacts into the tmpdir so --check compares
|
|
// entirely against tmpdir copies — no write to any real committed path.
|
|
// `--declaration` stays pointed at the REAL committed declaration
|
|
// (read-only; never written) rather than a tmpdir copy: the generator's
|
|
// banner embeds `path.relative(REPO_ROOT, declarationPath)`
|
|
// (scripts/gen-exit-code-registry.cjs:324), so a tmpdir declaration path
|
|
// (outside REPO_ROOT) would itself make freshly-derived content diverge
|
|
// from the real committed artifacts' banners — a false drift unrelated
|
|
// to the corruption this test injects. The secondary/hooks/dts/sh paths
|
|
// are derived by the SAME ensureScriptsOut seam
|
|
// tests/exit-code-registry.test.cjs uses, not a second hand-rolled copy.
|
|
const copiedOut = path.join(dir, 'exit-code-registry.cjs');
|
|
const args = ensureScriptsOut(['--declaration', registryGenerator.DEFAULT_DECLARATION_PATH, '--out', copiedOut]);
|
|
const copiedScriptsOut = args[args.indexOf('--scripts-out') + 1];
|
|
const copiedHooksOut = args[args.indexOf('--hooks-out') + 1];
|
|
const copiedDtsOut = args[args.indexOf('--dts-out') + 1];
|
|
const copiedShOut = args[args.indexOf('--sh-out') + 1];
|
|
|
|
fs.copyFileSync(registryGenerator.DEFAULT_OUTPUT_PATH, copiedOut);
|
|
fs.copyFileSync(registryGenerator.DEFAULT_SCRIPTS_OUTPUT_PATH, copiedScriptsOut);
|
|
const original = fs.readFileSync(HOOKS_EXIT_CODE_REGISTRY_PATH);
|
|
fs.writeFileSync(copiedHooksOut, original);
|
|
fs.copyFileSync(registryGenerator.DEFAULT_DTS_OUTPUT_PATH, copiedDtsOut);
|
|
fs.copyFileSync(registryGenerator.DEFAULT_SH_OUTPUT_PATH, copiedShOut);
|
|
|
|
fs.appendFileSync(copiedHooksOut, '\n// corrupted-by-A3-test\n');
|
|
const r = toLegacyResult(runNode([GEN_EXIT_CODE_REGISTRY, '--check', ...args], { timeoutMs: PROBE_TIMEOUT_MS }));
|
|
assert.notEqual(r.status, 0, `--check must fail on a corrupted artifact; stderr: ${r.stderr}`);
|
|
assert.ok(
|
|
r.stderr.includes(copiedHooksOut) && r.stderr.includes('hooks'),
|
|
`expected the failure to name the drifted hooks artifact (${copiedHooksOut}); got: ${r.stderr.slice(0, 400)}`,
|
|
);
|
|
|
|
fs.writeFileSync(copiedHooksOut, original);
|
|
const restored = toLegacyResult(runNode([GEN_EXIT_CODE_REGISTRY, '--check', ...args], { timeoutMs: PROBE_TIMEOUT_MS }));
|
|
assert.equal(restored.status, 0, `--check must pass again once the artifact is restored; stderr: ${restored.stderr}`);
|
|
|
|
// The property this whole test exists to prove: the committed file was
|
|
// never touched, at any point, by any of the above.
|
|
assert.deepEqual(fs.readFileSync(HOOKS_EXIT_CODE_REGISTRY_PATH), original, 'committed hooks/lib/exit-code-registry.js must be untouched');
|
|
});
|
|
});
|
|
|
|
// ─── #3912 (ADR-3889 §4, P8): the pending-outcome cell ──────────────────────
|
|
//
|
|
// output()'s payload-carried-error detection lives in io.cts and is tested
|
|
// there; these tests exercise the cell + runMain projection mechanics that
|
|
// live in cli-exit.cts itself: precedence (test matrix row C5) and cross-copy
|
|
// parity (row C6).
|
|
|
|
describe('#3912: pending-outcome cell (runMain precedence + parity)', () => {
|
|
// E1: for every registered outcome name and version, projectOutcome is a
|
|
// non-negative integer, and is 0 ONLY for PASS, or for DEGRADED under v1.
|
|
// The existing "every projection... is a non-negative integer" test above
|
|
// does not pin the ZERO-ONLY-FOR half of this; this test is what makes an
|
|
// accidental future zero for some OTHER outcome fail loudly.
|
|
test('E1 (fast-check): 0 is produced only by PASS, or by DEGRADED under v1', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.constantFrom('PASS', 'FAIL', ...REGISTERED_NAMES),
|
|
fc.constantFrom(...VERSIONS),
|
|
(outcome, version) => {
|
|
const result = projectOutcome(outcome, version);
|
|
assert.equal(Number.isInteger(result), true);
|
|
assert.ok(result >= 0);
|
|
if (result === 0) {
|
|
const isPass = outcome === 'PASS';
|
|
const isDegradedV1 = outcome === 'DEGRADED' && version === 'v1';
|
|
assert.ok(
|
|
isPass || isDegradedV1,
|
|
`outcome=${outcome} version=${version} projected to 0 but is neither PASS nor DEGRADED/v1`,
|
|
);
|
|
} else {
|
|
assert.notEqual(outcome, 'PASS', 'PASS must always project to 0');
|
|
}
|
|
},
|
|
),
|
|
{ seed: 3912, numRuns: 300 },
|
|
);
|
|
});
|
|
|
|
// C5: an explicit main() return beats a recorded DEGRADED cell. Without
|
|
// this, a caller that both returns an explicit code AND had a stale
|
|
// DEGRADED left in the cell (e.g. from an earlier output({error}) call in
|
|
// the same process) would get the CELL's projection instead of its own
|
|
// explicit decision — exactly backwards from "the cell is a fallback for
|
|
// the absence of a decision".
|
|
test('C5: an explicit main() return wins over a pending DEGRADED cell', () => {
|
|
const script = [
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`c.setPendingOutcome('DEGRADED');`,
|
|
`c.runMain(() => 'PASS');`,
|
|
`setImmediate(() => {});`,
|
|
].join('\n');
|
|
// Under v2, DEGRADED alone would project to 80; PASS must win with 0.
|
|
const r = toLegacyResult(runNode(['-e', script], {
|
|
timeoutMs: PROBE_TIMEOUT_MS,
|
|
env: { ...process.env, GSD_EXIT_CONTRACT: 'v2' },
|
|
}));
|
|
assert.equal(r.status, 0, `explicit PASS return must win over the pending DEGRADED cell; stderr: ${r.stderr}`);
|
|
});
|
|
|
|
test('C5 (numeric arm): an explicit numeric return also wins over a pending DEGRADED cell', () => {
|
|
const script = [
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`c.setPendingOutcome('DEGRADED');`,
|
|
`c.runMain(() => 42);`,
|
|
`setImmediate(() => {});`,
|
|
].join('\n');
|
|
const r = toLegacyResult(runNode(['-e', script], {
|
|
timeoutMs: PROBE_TIMEOUT_MS,
|
|
env: { ...process.env, GSD_EXIT_CONTRACT: 'v2' },
|
|
}));
|
|
assert.equal(r.status, 42, `explicit numeric return must win over the pending DEGRADED cell; stderr: ${r.stderr}`);
|
|
});
|
|
|
|
test('a void return with NO pending outcome set leaves exit code untouched (v1 unchanged)', () => {
|
|
const script = [
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`c.runMain(() => undefined);`,
|
|
`setImmediate(() => {});`,
|
|
].join('\n');
|
|
const r = toLegacyResult(runNode(['-e', script], { timeoutMs: PROBE_TIMEOUT_MS }));
|
|
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
});
|
|
|
|
test('a void return WITH a pending DEGRADED cell projects it under v1 (0) and v2 (80)', () => {
|
|
const script = [
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`c.setPendingOutcome('DEGRADED');`,
|
|
`c.runMain(() => undefined);`,
|
|
`setImmediate(() => {});`,
|
|
].join('\n');
|
|
const v1 = toLegacyResult(runNode(['-e', script], {
|
|
timeoutMs: PROBE_TIMEOUT_MS, env: { ...process.env, GSD_EXIT_CONTRACT: 'v1' },
|
|
}));
|
|
assert.equal(v1.status, 0, `stderr: ${v1.stderr}`);
|
|
const v2 = toLegacyResult(runNode(['-e', script], {
|
|
timeoutMs: PROBE_TIMEOUT_MS, env: { ...process.env, GSD_EXIT_CONTRACT: 'v2' },
|
|
}));
|
|
assert.equal(v2.status, 80, `stderr: ${v2.stderr}`);
|
|
});
|
|
|
|
// Regression (fail-open, found live in `state validate --strict` on a
|
|
// missing STATE.md): a void-returning main() that ALREADY set
|
|
// process.exitCode to a non-zero value itself (e.g. `emit()` setting 1
|
|
// directly on the STATE.md-not-found early return) must have that value
|
|
// survive a pending DEGRADED cell — the cell must never LOWER an exit
|
|
// code main() itself already raised. Before the fix, this arm
|
|
// unconditionally overwrote process.exitCode with the cell's projection,
|
|
// clobbering an already-set 1 down to DEGRADED's v1 projection (0) — a
|
|
// real declared failure silently turned into success.
|
|
test('a void return with an ALREADY-SET non-zero exitCode beats a pending DEGRADED cell (regression)', () => {
|
|
const script = [
|
|
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`c.setPendingOutcome('DEGRADED');`,
|
|
`c.runMain(() => { process.exitCode = 1; });`,
|
|
`setImmediate(() => {});`,
|
|
].join('\n');
|
|
const v1 = toLegacyResult(runNode(['-e', script], {
|
|
timeoutMs: PROBE_TIMEOUT_MS, env: { ...process.env, GSD_EXIT_CONTRACT: 'v1' },
|
|
}));
|
|
assert.equal(v1.status, 1, `an already-set non-zero exitCode must not be clobbered down to DEGRADED's v1 projection (0); stderr: ${v1.stderr}`);
|
|
const v2 = toLegacyResult(runNode(['-e', script], {
|
|
timeoutMs: PROBE_TIMEOUT_MS, env: { ...process.env, GSD_EXIT_CONTRACT: 'v2' },
|
|
}));
|
|
assert.equal(v2.status, 1, `an already-set non-zero exitCode must not be clobbered by DEGRADED's v2 projection (80) either; stderr: ${v2.stderr}`);
|
|
});
|
|
|
|
// C6: extends the existing three-copy parity coverage (json-error-mode cell,
|
|
// tested above at "both copies of the exit module share one json-error-mode
|
|
// cell") to the pending-outcome cell, across all THREE emitted copies —
|
|
// built (gsd-core/bin/lib), scripts/lib, and hooks/lib — not just two.
|
|
test('C6: all three emitted copies of cli-exit share ONE pending-outcome cell', () => {
|
|
const r = toLegacyResult(runNode(['-e', [
|
|
`const built = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
|
`const scripts = require(${JSON.stringify(SCRIPTS_CLI_EXIT_PATH)});`,
|
|
`const hooks = require(${JSON.stringify(HOOKS_CLI_EXIT_PATH)});`,
|
|
`if (built === scripts || built === hooks || scripts === hooks) throw new Error('expected three distinct module instances');`,
|
|
`built.setPendingOutcome('DEGRADED');`,
|
|
`process.stdout.write(JSON.stringify({`,
|
|
` viaBuilt: built.getPendingOutcome(),`,
|
|
` viaScripts: scripts.getPendingOutcome(),`,
|
|
` viaHooks: hooks.getPendingOutcome(),`,
|
|
`}));`,
|
|
].join('\n')], { timeoutMs: PROBE_TIMEOUT_MS }));
|
|
assert.strictEqual(r.status, 0, `stderr: ${r.stderr}`);
|
|
assert.deepStrictEqual(
|
|
JSON.parse(r.stdout),
|
|
{ viaBuilt: 'DEGRADED', viaScripts: 'DEGRADED', viaHooks: 'DEGRADED' },
|
|
'all three copies must read one shared cell — three independent module-level flags would diverge here',
|
|
);
|
|
});
|
|
|
|
test('C6: all three copies also agree on the PROJECTED exit code via runMain', () => {
|
|
for (const version of VERSIONS) {
|
|
for (const modulePath of [BUILT_CLI_EXIT_PATH, SCRIPTS_CLI_EXIT_PATH, HOOKS_CLI_EXIT_PATH]) {
|
|
const r = toLegacyResult(runNode(['-e', [
|
|
`const c = require(${JSON.stringify(modulePath)});`,
|
|
`c.setPendingOutcome('DEGRADED');`,
|
|
`c.runMain(() => undefined);`,
|
|
`setImmediate(() => {});`,
|
|
].join('\n')], {
|
|
timeoutMs: PROBE_TIMEOUT_MS, env: { ...process.env, GSD_EXIT_CONTRACT: version },
|
|
}));
|
|
const expected = version === 'v1' ? 0 : 80;
|
|
assert.equal(
|
|
r.status, expected,
|
|
`${modulePath} under ${version} expected ${expected}, got ${r.status}; stderr: ${r.stderr}`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
});
|