Files
msd-core/tests/io.test.cjs
Tom Boucher d24e22b156 enhance(#3912): gsd-tools declares outcomes, pinned at v1 (#3983)
* enhance(#3912): gsd-tools declares outcomes, pinned at v1

ADR-3889 §4. Phase 6 already moved error()'s terminator onto the seam, so what
remained was the declaration — and the pin that makes it invisible today.

The census corrected two documented figures before any code changed.
ERROR_REASON has exactly 25 members (the ADR and epic were right; an earlier
note of mine claiming 23 was wrong and is corrected). And output({error}) is
**64 sites across 9 files, not the 60 ADR-2980 ratified** — the module shape
holds but the total drifted +4: frontmatter 7 not 6, phase 4 not 2, roadmap 3
not 2. That matters because this phase's criterion demands the pin be asserted
over the enumerated population rather than sampled; asserting over a stale 60
would leave four sites unpinned while claiming full coverage, which is the
shape of failure this epic exists to remove.

The issue does not state the fact that shapes the design: output() never
touches the exit code. Confirmed by reading it — it writes fd 1 and returns.
So a declared outcome for those 64 sites had nowhere to be READ. The mapping
was never the work; wiring somewhere for the declaration to land was.

The seam already existed twice over. cli-exit.cts holds two globalThis-Symbol
cells, each because the module is emitted to three locations and a module-level
`let` would let instances disagree, and runMain already maps a code returned by
main(). A third cell inherits that solution. output() records DEGRADED for any
{error} payload — key-order agnostic, which is exactly why the "42 sites"
figure undercounts — and runMain projects the cell only when main() returns
nothing, so an explicit return still wins.

error() maps its reason through a table over the closed 25-member enum, leaving
all 278 call sites untouched; 226 of them pass no reason at all. The version
gate lives in error(), NOT in projectOutcome: registered names are
version-invariant there, so mapping a reason straight through would make USAGE
project to 64 under v1 and break the pin on its first line. projectOutcome is
left exactly as Phase 2 shipped it, DEGRADED's 0/80 asymmetry included.

Proven rather than asserted. v1 is byte-identical across three real CLI paths —
config-get plain, config-get --json-errors, and an output({error}) path —
matching exit code and exact bytes against the pre-change build. Under
GSD_EXIT_CONTRACT=v2 the same commands now exit 66 (CONFIG_KEY_NOT_FOUND ->
NO_INPUT) and 80 (DEGRADED), both looked up through the registry. An
anti-vacuity test pins that v1 and v2 genuinely differ for at least one reason,
because without it a mapping where everything projects to 1 under both versions
would satisfy every other assertion and the declaration would be theatre.

A1 iterates all 25 enum members and A3 asserts over the measured 64-site
population, so a 26th reason or a 65th site fails until it is given a mapping —
the drift guard this phase needs, given ADR-2980's own count had drifted +4
unnoticed.

Verification runs on the remote runner.

Refs #3912

* fix(#3912): the outcome cell must never lower an exit code

The remote run caught a fail-open that this phase introduced, in the phase
whose entire purpose is removing fail-opens.

`state validate --strict` on a missing STATE.md exited **0** where it must exit
1. Mechanism: `runMain` projected the pending outcome whenever `main()` returned
void, and under v1 DEGRADED projects to 0 — so a `process.exitCode` already set
non-zero by the command was clobbered down to success. Confirmed live against a
fixture, before and after.

This refutes a review conclusion recorded earlier in this phase, that the cell
was "fail-closed and can never mask a failure as success". It could, and did.
Recording that plainly so the assumption is not repeated: the cell's danger was
never only that it might add a failure — it was that projecting it
unconditionally overwrites whatever decision came before.

Projection is now guarded: it may set a code only when none is set, and an
already-non-zero exit code always wins. The full precedence — explicit `main()`
return, then an existing non-zero exitCode, then the declared outcome — is
written at the projection site. A regression test drives a void return with a
pre-set non-zero code and a pending DEGRADED, and fails against the pre-fix
build.

The second failure was my test encoding the wrong contract, not a code defect.
It asserted `output({found:false, error: undefined})` records DEGRADED because
the KEY is present. `JSON.stringify` drops undefined, so the payload the user
receives is `{"found":false}` — carrying no error at all, and calling that
degraded would hand back exit 80 under v2 for output that reads as clean. The
discriminator is a serializable error VALUE, not key presence. The test now
pins `{error: undefined}` as explicitly NOT degraded, and the design doc's
wording is tightened to match.

Verification runs on the remote runner.

Refs #3912

* docs(#3912): the versioned exit contract, and a flag defect the docs found

Diataxis pass for Phase 8, plus a real fix that only surfaced because writing
the how-to meant running its own examples.

The docs. ADR-2980's "Revisit if" clause asked for exactly the versioned
projection this phase provides, so it gets an amendment naming #3912 /
ADR-3889 section 4 as that boundary: v1 stays 0 byte-for-byte, v2 projects
DEGRADED to 80. The amendment also records the count drift rather than
restating a stale figure — the ADR ratified 60 output({error}) sites in 9
modules; the AST-measured population is 64 across the same 9 (frontmatter 7
not 6, phase 4 not 2, roadmap 3 not 2). The pin is asserted over the
enumerated 64. json-errors.md gains the outcome-declaration reference,
including the precedence order a review pass got wrong and the suite refuted:
an explicit main() return, then an already-set non-zero process.exitCode, then
the declared outcome. Projection may only ever set a code, never lower one.

A how-to is owed here and is written, not skipped. Under v1 nothing changes,
so the audience is an operator opting into v2 and needing to know what the
codes mean for a CI gate — a migration, which is how-to shaped. It covers
turning v2 on, the code table, why 80 is "ran and reported a condition" rather
than a crash, and how to split a gate that treats any non-zero as fatal. No
tutorial: there is no new entry point to learn, and under the default contract
a reader would be walked through observing nothing.

The defect. Running the how-to's own Step 1 example returned

    $ gsd-tools --exit-contract=v2 state validate --strict
    Error: Unknown command: --exit-contract=v2          (exit 64)

while the same flag trailing the subcommand worked and exited 80. The flag
half-worked, by argv position. resolveContractVersion scans argv
non-destructively, so the token survived into the dispatcher, which treats
argv[2] as the command name. --json-errors had already solved precisely this
at gsd-tools.cjs:4455, under a comment naming the hazard verbatim: "The argv
splice must happen here too, otherwise the dispatcher below sees
--json-errors as an unknown command." The later flag never got the same
treatment.

Fixed rather than documented around: the version is resolved first — which
memoizes the cell and makes an invalid value throw early — and then every
--exit-contract= token is spliced out of the dispatcher's argv copy.
--exit-contract is now listed in TOP_LEVEL_USAGE, where it never was. The
regression test pins leading position, trailing position, agreement between
the two, and a loud failure on v3 rather than a silent fall back to v1.

Neither review engine would have caught this: the defect is invisible in the
diff, because the diff does not touch argv handling. It surfaced only from
running the documentation's own example. Writing a how-to is an execution pass.

Verification runs on the remote runner.

Refs #3912

* fix(#3912): the flag splice has to run before the run-with-timeout return

An isolated review of the previous commit found that the fix did not deliver
what it claimed, and that two of its own tests were weak. All three findings
reproduced by execution before any change was made.

The fix was placed below a return. main() intercepts `run-with-timeout` at
gsd-tools.cjs:4436 and returns from there — above both the --json-errors block
and the --exit-contract splice added in the previous commit. So the flag still
died in leading position for that one command:

    $ gsd-tools --exit-contract=v2 run-with-timeout 5 -- node -e "..."
    Error: Unknown command: run-with-timeout        (exit 64, child never ran)

The previous commit message and the test's describe-block both claimed
position-independence unconditionally. That was an overclaim, not a gap left
open, and it is the part worth naming: the fix was verified by hand on the
commands I happened to think of, and `run-with-timeout` returns before the
code I was verifying.

Both global-flag blocks now run above the interception, with a comment naming
it so a later edit cannot slide them back down. Moving --json-errors up fixes
the identical pre-existing bug for that flag, verified failing beforehand
(exit 1, sdk_unknown_command). Fixing the sibling is deliberate: same defect,
same block, and a known-broken twin next to a fixed one is not a resting state.

Two tests were not pulling their weight. The invalid-value test was vacuous —
it passed against the pre-fix build, because `--exit-contract=v3` already
exited 1 there and already printed the resolve error lazily through
error() -> getContractVersion. Both its assertions held before the fix, so it
pinned nothing. The real discriminator is that the pre-fix build emits BOTH
"Unknown command: --exit-contract=v3" and the resolve error, while the fixed
build emits only the latter; the test now asserts that absence.

The leading-position and leading==trailing tests asserted proxies — "not 64",
"no Unknown command", "the two agree" — none of which pin a value, and all of
which would survive both positions being identically broken. With a .planning
directory and no STATE.md, state-snapshot exits exactly 80 under v2 and 0
under v1 in both positions. Those numbers are pinned now. The multi-token case
the descending splice loop exists for is covered too, and run-with-timeout has
regression tests for both flags.

The lesson is narrower than "test more". Hand-verifying the production
behavior does not verify that the test would have caught its absence. The
pre-fix binary has to be run against the test's own assertions.

Investigated and deliberately not changed: splicing before --cwd parsing
degrades one diagnostic from "Missing value for --cwd" to "Invalid --cwd:
<path>", but that is pre-existing — verified on the pre-fix build via
--json-errors, which already did it. This change joins the pattern rather than
creating it, and both forms exit 64 on malformed input either way.

Verification runs on the remote runner.

Refs #3912

* chore(#3912): backfill changeset pr numbers to 3983

* test(#3912): pin the reason-table invariant as set equality, not a count

A graph-backed review flagged the unchecked lookup in
expectedErrorCode3912. Investigated by execution: the drift guard DOES
hold — for an unmapped reason under v2 the production error() yields 1
while the table yields undefined, so the assertion fails. Not a
correctness defect, and deliberately NOT made tolerant, since a tolerant
lookup would destroy the guard.

Two real problems remained. The guard asserted the wrong invariant: it
counted the TABLE's keys at 25 rather than checking they match the
ENUM's values, so a renamed member keeps the count at 25 and slips past,
and a 26th member leaves the table at 25 and slips past too. Both were
then caught only indirectly, by an undefined mismatch producing 'must
exit undefined'. It is now a sorted set equality, so the failure names
the specific missing or extra reason.

And the comment above it described a '?? FAIL' fallback that does not
exist anywhere in the function. It now states what the code actually
does, verified by running it rather than by reading it.

Refs #3912

---------

Co-authored-by: sim <sim@local>
2026-08-28 08:09:05 -04:00

1093 lines
47 KiB
JavaScript

/**
* Tests for src/io.cts (compiled to gsd-core/bin/lib/io.cjs).
*
* Verifies behavioural contracts of the extracted CLI I/O primitives:
* - output() writes expected structure to stdout
* - error() writes expected structure to stderr and exits
* - ERROR_REASON constants have the correct wire values
* - setJsonErrorMode/getJsonErrorMode toggle behaviour
* - core.cjs re-export shims resolve to the exact same objects as io.cjs
*
* ADR-857 phase 1 / issue #859.
*/
const { test, describe, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const os = require('node:os');
const fs = require('node:fs');
const io = require('../gsd-core/bin/lib/io.cjs');
const {
ExitError, resolveContractVersion, setPendingOutcome, getPendingOutcome, runMain,
} = require('../gsd-core/bin/lib/cli-exit.cjs');
const { EXIT_CODES } = require('../gsd-core/bin/lib/exit-code-registry.cjs');
const { runNode } = require('./helpers/process-seam.cjs');
const { toLegacyResult } = require('./helpers/git-fixture.cjs');
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const fc = require('./helpers/fast-check-setup.cjs');
const ts = require('typescript');
function runScript(script) {
return toLegacyResult(runNode(['-e', script], { timeoutMs: PROBE_TIMEOUT_MS }));
}
// ─── ERROR_REASON constants ───────────────────────────────────────────────────
describe('ERROR_REASON', () => {
test('is a frozen object', () => {
assert.ok(Object.isFrozen(io.ERROR_REASON));
});
test('contains expected wire values', () => {
assert.strictEqual(io.ERROR_REASON.CONFIG_KEY_NOT_FOUND, 'config_key_not_found');
assert.strictEqual(io.ERROR_REASON.CONFIG_NO_FILE, 'config_no_file');
assert.strictEqual(io.ERROR_REASON.CONFIG_PARSE_FAILED, 'config_parse_failed');
assert.strictEqual(io.ERROR_REASON.CONFIG_INVALID_KEY, 'config_invalid_key');
assert.strictEqual(io.ERROR_REASON.SDK_FAIL_FAST, 'sdk_fail_fast');
assert.strictEqual(io.ERROR_REASON.SDK_UNKNOWN_COMMAND, 'sdk_unknown_command');
assert.strictEqual(io.ERROR_REASON.SDK_MISSING_ARG, 'sdk_missing_arg');
assert.strictEqual(io.ERROR_REASON.PHASE_NOT_FOUND, 'phase_not_found');
assert.strictEqual(io.ERROR_REASON.SUMMARY_NO_PLANNING, 'summary_no_planning');
assert.strictEqual(io.ERROR_REASON.GRAPHIFY_NO_GRAPH, 'graphify_no_graph');
assert.strictEqual(io.ERROR_REASON.GRAPHIFY_INVALID_QUERY, 'graphify_invalid_query');
assert.strictEqual(io.ERROR_REASON.HOOKS_OPT_OUT, 'hooks_opt_out');
assert.strictEqual(io.ERROR_REASON.SECURITY_SCAN_FAILED, 'security_scan_failed');
assert.strictEqual(io.ERROR_REASON.USAGE, 'usage');
assert.strictEqual(io.ERROR_REASON.UNKNOWN, 'unknown');
});
});
// ─── setJsonErrorMode / getJsonErrorMode ─────────────────────────────────────
describe('setJsonErrorMode / getJsonErrorMode', () => {
// Reset to false after each test so other tests are unaffected
afterEach(() => {
io.setJsonErrorMode(false);
});
test('defaults to false', () => {
io.setJsonErrorMode(false); // ensure clean state
assert.strictEqual(io.getJsonErrorMode(), false);
});
test('setJsonErrorMode(true) enables JSON error mode', () => {
io.setJsonErrorMode(true);
assert.strictEqual(io.getJsonErrorMode(), true);
});
test('setJsonErrorMode(false) disables JSON error mode', () => {
io.setJsonErrorMode(true);
io.setJsonErrorMode(false);
assert.strictEqual(io.getJsonErrorMode(), false);
});
test('setJsonErrorMode coerces truthy values', () => {
io.setJsonErrorMode(1);
assert.strictEqual(io.getJsonErrorMode(), true);
io.setJsonErrorMode(0);
assert.strictEqual(io.getJsonErrorMode(), false);
});
test('setJsonErrorMode coerces string truthy', () => {
io.setJsonErrorMode('yes');
assert.strictEqual(io.getJsonErrorMode(), true);
io.setJsonErrorMode('');
assert.strictEqual(io.getJsonErrorMode(), false);
});
});
// ─── output() ────────────────────────────────────────────────────────────────
// output() writes directly to fd 1 and never calls process.exit, so we can
// test it by spawning a child process and capturing its stdout.
describe('output()', () => {
const ioPath = path.resolve(__dirname, '../gsd-core/bin/lib/io.cjs');
test('emits JSON-serialised result to stdout', () => {
const script = `
const io = require(${JSON.stringify(ioPath)});
io.output({ ok: true, value: 42 }, false);
`;
const result = runScript(script);
assert.strictEqual(result.status, 0, `process exited non-zero: ${result.stderr}`);
const parsed = JSON.parse(result.stdout);
assert.deepStrictEqual(parsed, { ok: true, value: 42 });
});
test('emits raw string value when raw=true and rawValue provided', () => {
const script = `
const io = require(${JSON.stringify(ioPath)});
io.output({ ignored: true }, true, 'raw-text-output');
`;
const result = runScript(script);
assert.strictEqual(result.status, 0, `process exited non-zero: ${result.stderr}`);
assert.strictEqual(result.stdout, 'raw-text-output');
});
test('falls back to JSON when raw=true but rawValue is undefined', () => {
const script = `
const io = require(${JSON.stringify(ioPath)});
io.output({ fallback: true }, true);
`;
const result = runScript(script);
assert.strictEqual(result.status, 0, `process exited non-zero: ${result.stderr}`);
const parsed = JSON.parse(result.stdout);
assert.deepStrictEqual(parsed, { fallback: true });
});
test('emits null correctly', () => {
const script = `
const io = require(${JSON.stringify(ioPath)});
io.output(null, false);
`;
const result = runScript(script);
assert.strictEqual(result.status, 0, `process exited non-zero: ${result.stderr}`);
assert.strictEqual(result.stdout, 'null');
});
test('large payload (>50000 chars) spills to @file: tempfile', (t) => {
// Build a payload whose serialized JSON exceeds 50000 chars.
// A string of 60000 'x' chars serializes to 60002 chars ("x...x").
const largeString = 'x'.repeat(60000);
const payload = { large: largeString };
const serialized = JSON.stringify(payload, null, 2);
assert.ok(serialized.length > 50000, 'precondition: payload must exceed 50000 chars');
const tmpFilesCreated = [];
t.after(() => {
for (const p of tmpFilesCreated) {
try { fs.unlinkSync(p); } catch { /* ignore */ }
}
});
const script = `
const io = require(${JSON.stringify(ioPath)});
const largeString = 'x'.repeat(60000);
io.output({ large: largeString }, false);
`;
const result = runScript(script);
assert.strictEqual(result.status, 0, `process exited non-zero: ${result.stderr}`);
const stdout = result.stdout.trim();
assert.ok(stdout.startsWith('@file:'), `expected stdout to start with "@file:", got: ${stdout.slice(0, 80)}`);
const tmpPath = stdout.slice('@file:'.length);
tmpFilesCreated.push(tmpPath);
assert.ok(fs.existsSync(tmpPath), `expected temp file to exist at: ${tmpPath}`);
const fileContents = fs.readFileSync(tmpPath, 'utf-8');
const parsed = JSON.parse(fileContents);
assert.deepStrictEqual(parsed, payload);
fs.unlinkSync(tmpPath);
tmpFilesCreated.length = 0; // already cleaned, skip t.after
});
});
// ─── error() ─────────────────────────────────────────────────────────────────
describe('error()', () => {
const ioPath = path.resolve(__dirname, '../gsd-core/bin/lib/io.cjs');
const cliExitPath = path.resolve(__dirname, '../gsd-core/bin/lib/cli-exit.cjs');
// ADR-3889: io.error() now throws ExitError instead of calling
// process.exit() directly. A bare `node -e` script that calls io.error()
// with no termination seam would let that ExitError escape as an uncaught
// exception (a stack trace on stderr, not the single "Error: <msg>" line
// error() itself already wrote). Every harness script below wraps the
// error() call in runMain — the sanctioned entrypoint seam — so the
// process terminates exactly the way a real CLI invocation would: the one
// stderr write error() performs itself, then `process.exitCode = err.code`
// with nothing further written (ExitError from error() carries no message,
// so runMain's own "hasUserMessage" stderr write is a no-op here).
test('plain-text mode: writes "Error: <msg>" to stderr and exits 1', () => {
const script = `
const io = require(${JSON.stringify(ioPath)});
const { runMain } = require(${JSON.stringify(cliExitPath)});
io.setJsonErrorMode(false);
runMain(() => { io.error('something went wrong'); });
`;
const result = runScript(script);
assert.strictEqual(result.status, 1);
assert.ok(result.stderr.includes('Error: something went wrong'), `stderr was: ${result.stderr}`);
assert.strictEqual(result.stdout, '');
});
test('plain-text mode: default reason does not appear in stderr text', () => {
const script = `
const io = require(${JSON.stringify(ioPath)});
const { runMain } = require(${JSON.stringify(cliExitPath)});
io.setJsonErrorMode(false);
runMain(() => { io.error('no reason code expected'); });
`;
const result = runScript(script);
assert.strictEqual(result.status, 1);
// plain mode does NOT include the reason field
assert.ok(!result.stderr.includes('"reason"'), `stderr unexpectedly contained reason: ${result.stderr}`);
});
test('JSON-error mode: writes structured JSON to stderr and exits 1', () => {
const script = `
const io = require(${JSON.stringify(ioPath)});
const { runMain } = require(${JSON.stringify(cliExitPath)});
io.setJsonErrorMode(true);
runMain(() => { io.error('structured error', io.ERROR_REASON.SDK_FAIL_FAST); });
`;
const result = runScript(script);
assert.strictEqual(result.status, 1);
assert.strictEqual(result.stdout, '');
const payload = JSON.parse(result.stderr.trim());
assert.strictEqual(payload.ok, false);
assert.strictEqual(payload.reason, 'sdk_fail_fast');
assert.strictEqual(payload.message, 'structured error');
});
test('JSON-error mode: defaults reason to UNKNOWN when not supplied', () => {
const script = `
const io = require(${JSON.stringify(ioPath)});
const { runMain } = require(${JSON.stringify(cliExitPath)});
io.setJsonErrorMode(true);
runMain(() => { io.error('no reason given'); });
`;
const result = runScript(script);
assert.strictEqual(result.status, 1);
const payload = JSON.parse(result.stderr.trim());
assert.strictEqual(payload.reason, 'unknown');
assert.strictEqual(payload.message, 'no reason given');
});
test('all ERROR_REASON values round-trip through JSON-error mode', () => {
// spot-check a few variants
const cases = [
['config_key_not_found', 'CONFIG_KEY_NOT_FOUND'],
['phase_not_found', 'PHASE_NOT_FOUND'],
['usage', 'USAGE'],
];
for (const [expected, key] of cases) {
const script = `
const io = require(${JSON.stringify(ioPath)});
const { runMain } = require(${JSON.stringify(cliExitPath)});
io.setJsonErrorMode(true);
runMain(() => { io.error('test', io.ERROR_REASON.${key}); });
`;
const result = runScript(script);
assert.strictEqual(result.status, 1, `key=${key}`);
const payload = JSON.parse(result.stderr.trim());
assert.strictEqual(payload.reason, expected, `key=${key}`);
}
});
});
// ─── GSD_TEMP_DIR / reapStaleTempFiles ───────────────────────────────────────
describe('GSD_TEMP_DIR', () => {
test('resolves to <tmpdir>/gsd', () => {
assert.strictEqual(io.GSD_TEMP_DIR, path.join(os.tmpdir(), 'gsd'));
});
});
describe('reapStaleTempFiles (via io)', () => {
const TEST_PREFIX = 'gsd-io-test-';
afterEach(() => {
// clean up any test files we created
try {
const entries = fs.readdirSync(io.GSD_TEMP_DIR);
for (const e of entries) {
if (e.startsWith(TEST_PREFIX)) {
const p = path.join(io.GSD_TEMP_DIR, e);
try { fs.unlinkSync(p); } catch { /* ignore */ }
}
}
} catch { /* ignore */ }
});
test('removes stale files beyond maxAgeMs', () => {
fs.mkdirSync(io.GSD_TEMP_DIR, { recursive: true });
const stalePath = path.join(io.GSD_TEMP_DIR, TEST_PREFIX + 'stale.json');
fs.writeFileSync(stalePath, '{}');
// backdate mtime so it looks older than 1ms
const old = new Date(Date.now() - 10000);
fs.utimesSync(stalePath, old, old);
io.reapStaleTempFiles(TEST_PREFIX, { maxAgeMs: 5000 });
assert.ok(!fs.existsSync(stalePath), 'stale file should have been removed');
});
test('keeps fresh files within maxAgeMs', () => {
fs.mkdirSync(io.GSD_TEMP_DIR, { recursive: true });
const freshPath = path.join(io.GSD_TEMP_DIR, TEST_PREFIX + 'fresh.json');
fs.writeFileSync(freshPath, '{}');
// mtime is just now — well within a 1-hour window
io.reapStaleTempFiles(TEST_PREFIX, { maxAgeMs: 60 * 60 * 1000 });
assert.ok(fs.existsSync(freshPath), 'fresh file should have been kept');
});
test('does not throw when GSD_TEMP_DIR does not exist yet', () => {
// reap against a non-existent prefix — must not throw
assert.doesNotThrow(() => {
io.reapStaleTempFiles('gsd-io-nonexistent-prefix-xyz-', { maxAgeMs: 0 });
});
});
// #3314 — ADR-456 in-process reachability: t.mock.timers patches the
// process-global Date, so it controls `now` inside reapStaleTempFiles with
// no production code change needed. Both sides of the comparison (mocked
// "now" and the fs.utimesSync mtime) use second-aligned epoch values to
// avoid filesystem mtime sub-second-precision truncation on filesystems
// that round mtime to the nearest second.
describe('boundary: age exactly at maxAgeMs (condition is strictly-greater)', () => {
const MTIME_MS = 1_700_000_000_000; // second-aligned
const MAX_AGE_MS = 5000;
function plantFileAtAge(t, ageMs) {
fs.mkdirSync(io.GSD_TEMP_DIR, { recursive: true });
const p = path.join(io.GSD_TEMP_DIR, TEST_PREFIX + `boundary-${ageMs}.json`);
fs.writeFileSync(p, '{}');
fs.utimesSync(p, new Date(MTIME_MS), new Date(MTIME_MS));
t.mock.timers.enable(['Date']);
t.mock.timers.setTime(MTIME_MS + ageMs);
return p;
}
test('boundary: age exactly maxAgeMs-1 is kept', (t) => {
const p = plantFileAtAge(t, MAX_AGE_MS - 1);
io.reapStaleTempFiles(TEST_PREFIX, { maxAgeMs: MAX_AGE_MS });
assert.ok(fs.existsSync(p), 'file at maxAgeMs-1 must be kept');
});
test('boundary: age exactly maxAgeMs is kept (condition is strictly-greater)', (t) => {
const p = plantFileAtAge(t, MAX_AGE_MS);
io.reapStaleTempFiles(TEST_PREFIX, { maxAgeMs: MAX_AGE_MS });
assert.ok(fs.existsSync(p), 'file at exactly maxAgeMs must be kept — condition is strictly-greater, not >=');
});
test('boundary: age exactly maxAgeMs+1 is removed', (t) => {
const p = plantFileAtAge(t, MAX_AGE_MS + 1);
io.reapStaleTempFiles(TEST_PREFIX, { maxAgeMs: MAX_AGE_MS });
assert.ok(!fs.existsSync(p), 'file at maxAgeMs+1 must be removed');
});
});
});
// ─── bug #1008: output()/error() tolerate a full / slow non-blocking pipe ─────
//
// The pre-fix bare `fs.writeSync(fd, data)` assumed it blocks until the kernel
// accepts every byte — false when fd is a non-blocking pipe (the parallel
// node:test runner on Linux): a full pipe throws EAGAIN and a partially-drained
// pipe returns a SHORT count. These behavioral tests inject fs.writeSync via
// mock.method (the approved fault-injection seam) and assert the observable
// contract (no throw, full payload, real errors still surface). They are red
// against the pre-fix io.cjs (throw / truncate).
// Normalize either writeSync call form to the chunk it emits:
// buffer form: writeSync(fd, buffer, offset, length) ← the fixed writeAllSync loop
// string form: writeSync(fd, string) ← the pre-fix bare call
function bug1008ChunkOf(data, offset, length) {
if (Buffer.isBuffer(data)) {
const start = offset ?? 0;
const end = length === undefined ? data.length : start + length;
return data.subarray(start, end).toString('utf8');
}
return String(data);
}
function bug1008WriteError(code, errno) {
const e = new Error(`${code}: write`);
e.code = code;
e.errno = errno;
e.syscall = 'write';
return e;
}
describe('bug #1008: io.output() tolerates a full / slow non-blocking pipe', () => {
test('retries on EAGAIN and emits the full payload without throwing', (t) => {
const written = [];
let calls = 0;
t.mock.method(fs, 'writeSync', (fd, data, offset, length) => {
calls += 1;
if (calls === 1) throw bug1008WriteError('EAGAIN', -11); // pipe momentarily full
const chunk = bug1008ChunkOf(data, offset, length);
written.push(chunk);
return Buffer.byteLength(chunk, 'utf8');
});
const payload = { ok: true, n: 42 };
assert.doesNotThrow(() => io.output(payload, false));
assert.ok(calls >= 2, `expected a retry after EAGAIN, got ${calls} call(s)`);
assert.equal(written.join(''), JSON.stringify(payload, null, 2), 'full payload must reach the fd');
});
test('retries on EINTR (signal-interrupted write) too', (t) => {
const written = [];
let calls = 0;
t.mock.method(fs, 'writeSync', (fd, data, offset, length) => {
calls += 1;
if (calls === 1) throw bug1008WriteError('EINTR', -4);
const chunk = bug1008ChunkOf(data, offset, length);
written.push(chunk);
return Buffer.byteLength(chunk, 'utf8');
});
assert.doesNotThrow(() => io.output('plain', true, 'PLAIN-RAW'));
assert.equal(written.join(''), 'PLAIN-RAW');
});
test('handles short (partial) writes without truncating', (t) => {
const written = [];
const CAP = 3; // each writeSync accepts at most 3 bytes, like a draining pipe
t.mock.method(fs, 'writeSync', (fd, data, offset, length) => {
const chunk = bug1008ChunkOf(data, offset, length);
const part = chunk.slice(0, CAP);
written.push(part);
return Buffer.byteLength(part, 'utf8');
});
const payload = { message: 'a reasonably long ascii payload to force many short writes' };
io.output(payload, false);
assert.equal(written.join(''), JSON.stringify(payload, null, 2), 'no bytes may be dropped on short writes');
});
test('does NOT swallow a genuine, non-transient write error (EPIPE)', (t) => {
t.mock.method(fs, 'writeSync', () => { throw bug1008WriteError('EPIPE', -32); });
assert.throws(
() => io.output({ ok: true }, false),
(err) => err.code === 'EPIPE',
'real (non-transient) errors must still surface',
);
});
});
describe('bug #1008: io.error() tolerates a full non-blocking stderr pipe', () => {
// ADR-3889: error() throws ExitError instead of calling process.exit()
// directly, so mocking process.exit and asserting doesNotThrow no longer
// matches the contract — error() now DOES throw, on purpose, and the
// termination semantics (translating that throw into a process exit code)
// belong to runMain() at the entrypoint, not to error() itself. This test
// asserts the real contract directly: catch the ExitError and check its
// `code`.
test('retries on EAGAIN, emits the full message, and throws ExitError(1)', () => {
const written = [];
let calls = 0;
const restore = fs.writeSync;
fs.writeSync = (fd, data, offset, length) => {
calls += 1;
if (calls === 1) throw bug1008WriteError('EAGAIN', -11);
assert.equal(fd, 2, 'error() must write to stderr');
const chunk = bug1008ChunkOf(data, offset, length);
written.push(chunk);
return Buffer.byteLength(chunk, 'utf8');
};
try {
assert.throws(
() => io.error('boom', io.ERROR_REASON.UNKNOWN),
(err) => err instanceof ExitError && err.code === 1,
'error() must throw ExitError(1) after a retried write',
);
} finally {
fs.writeSync = restore;
}
assert.ok(calls >= 2, 'error() should retry after EAGAIN');
assert.equal(written.join(''), 'Error: boom\n');
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-1891-file-resolution.test.cjs — consolidation epic #1969 (B5 #1974)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-1891-file-resolution (consolidation epic #1969 B5 #1974)", () => {
// allow-test-rule: structural-implementation-guard (see #1891)
// gsd-tools.cjs @file: resolution is a low-level stdout interception that cannot be
// exercised end-to-end via runGsdTools without a real workflow that emits @file: output.
// These structural tests guard the interception wiring until a behavioral integration
// test suite for the full @file: path is added.
/**
* Regression tests for bug #1891
*
* gsd-tools.cjs must transparently resolve @file: references in stdout
* so that workflows never see the @file: prefix. This eliminates the
* bash-specific `if [[ "$INIT" == @file:* ]]` check that breaks on
* PowerShell and other non-bash shells.
*/
'use strict';
const { describe, test, before } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const GSD_TOOLS_SRC = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
describe('bug #1891: @file: resolution in gsd-tools.cjs', () => {
let src;
before(() => {
src = fs.readFileSync(GSD_TOOLS_SRC, 'utf-8');
});
test('main() intercepts stdout and resolves @file: references', () => {
// The non-pick path should have @file: resolution, just like the --pick path
assert.ok(
src.includes("captured.startsWith('@file:')") ||
src.includes('captured.startsWith(\'@file:\')'),
'main() should check for @file: prefix in captured output'
);
});
test('@file: resolution reads file content via readFileSync', () => {
// Verify the resolution reads the actual file
assert.ok(
src.includes("readFileSync(captured.slice(6)") ||
src.includes('readFileSync(captured.slice(6)'),
'@file: resolution should read file at the path after the prefix'
);
});
test('stdout interception wraps runCommand in the non-pick path', () => {
// The main function should resolve @file: output in BOTH --pick and
// non-pick paths. This can be either two inline checks or a shared helper.
const mainFunc = src.slice(src.indexOf('async function main()'));
const resolveCalls = (mainFunc.match(/resolveAtFileOutput\(/g) || []).length;
const inlineAtFileChecks = (mainFunc.match(/@file:/g) || []).length;
assert.ok(
resolveCalls >= 2 || inlineAtFileChecks >= 2,
'Both --pick and normal paths should resolve @file: references'
);
});
});
});
}
// ═══════════════════════════════════════════════════════════════════════════
// #3912 (ADR-3889 §4, epic #3889 Phase 8) — gsd-tools declares outcomes,
// pinned at v1. See .gsd/phase/enhance-3912-gsd-tools-outcomes/40-design.md
// and 50-test-matrix.md.
// ═══════════════════════════════════════════════════════════════════════════
const CLI_EXIT_PATH_3912 = path.resolve(__dirname, '../gsd-core/bin/lib/cli-exit.cjs');
const IO_PATH_3912 = path.resolve(__dirname, '../gsd-core/bin/lib/io.cjs');
const REGISTERED_NAMES_3912 = EXIT_CODES.map((e) => e.name);
const CODE_FOR_3912 = new Map(EXIT_CODES.map((e) => [e.name, e.code]));
const VERSIONS_3912 = ['v1', 'v2'];
/**
* Expected reason -> outcome mapping, mirroring src/io.cts's own
* REASON_TO_OUTCOME table. Kept as an independent, explicit table here
* (rather than importing the internal function) so the test is a real
* behavioral check against error()'s observable exit code, not a tautology
* that re-imports the thing it is meant to verify.
*/
const EXPECTED_REASON_OUTCOME_3912 = {
config_key_not_found: 'NO_INPUT',
config_no_file: 'UNAVAILABLE',
config_parse_failed: 'UNAVAILABLE',
config_invalid_key: 'USAGE',
sdk_fail_fast: 'INTERNAL',
sdk_unknown_command: 'USAGE',
sdk_missing_arg: 'USAGE',
phase_not_found: 'UNAVAILABLE',
phase_verification_incomplete: 'UNAVAILABLE',
phase_plan_coverage_incomplete: 'UNAVAILABLE',
summary_no_planning: 'NO_INPUT',
workstream_mode_none_active: 'NO_INPUT',
workstream_mode_marker_unresolved: 'UNAVAILABLE',
graphify_no_graph: 'UNAVAILABLE',
graphify_invalid_query: 'USAGE',
estimate_phases_unreadable: 'UNAVAILABLE',
hooks_opt_out: 'FAIL',
commit_docs_guard_not_a_repo: 'UNAVAILABLE',
commit_docs_guard_foreign_hook: 'UNAVAILABLE',
commit_docs_guard_hooks_path_set: 'UNAVAILABLE',
security_scan_failed: 'INTERNAL',
pick_field_absent: 'UNAVAILABLE',
pick_output_not_json: 'UNAVAILABLE',
usage: 'USAGE',
unknown: 'FAIL',
};
/** Expected exit code for `error(msg, reason)` under a given contract version. */
function expectedErrorCode3912(reasonValue, version) {
if (version === 'v1') return 1;
const outcome = EXPECTED_REASON_OUTCOME_3912[reasonValue];
if (outcome === 'FAIL') return 1;
return CODE_FOR_3912.get(outcome);
}
describe('#3912 A1/B1: error() declares from ERROR_REASON, exhaustive over the 25-member enum', () => {
afterEach(() => {
resolveContractVersion({ argv: ['node', 'x'], env: {} }); // restore v1 default
});
// A1 — the acceptance criterion: EVERY member of ERROR_REASON, iterated
// from the enum itself (not a hand-picked subset), exits 1 under v1. A
// 26th member added to the enum without a table entry still exits 1
// under v1 (v1 never consults the table at all); under v2, the table
// lookup for that member yields `undefined`, `CODE_FOR_3912.get(undefined)`
// yields `undefined`, and `err.code === expected` fails against the real
// code (1) for that reason. With the set-equality assertion below in
// place, that drift is caught first, with a message naming the specific
// missing/extra reason instead of a confusing "must exit undefined".
assert.deepEqual(
Object.keys(EXPECTED_REASON_OUTCOME_3912).sort(),
Object.values(io.ERROR_REASON).slice().sort(),
'this table must cover exactly the ERROR_REASON enum values, no more, no less',
);
for (const [key, reasonValue] of Object.entries(io.ERROR_REASON)) {
test(`v1: ERROR_REASON.${key} (${reasonValue}) exits 1`, () => {
resolveContractVersion({ argv: ['node', 'x'], env: {} }); // v1
assert.throws(
() => io.error('msg', reasonValue),
(err) => err instanceof ExitError && err.code === 1,
`ERROR_REASON.${key} must exit 1 under v1`,
);
});
test(`v2: ERROR_REASON.${key} (${reasonValue}) projects to its mapped outcome's registered code`, () => {
resolveContractVersion({ argv: ['node', 'x', '--exit-contract=v2'], env: {} });
const expected = expectedErrorCode3912(reasonValue, 'v2');
assert.throws(
() => io.error('msg', reasonValue),
(err) => err instanceof ExitError && err.code === expected,
`ERROR_REASON.${key} under v2 must exit ${expected}`,
);
});
}
// A2 — the 226-site default: no reason argument at all -> UNKNOWN -> exit 1.
test('A2: error() with no reason argument exits 1 under v1 (defaults to UNKNOWN)', () => {
resolveContractVersion({ argv: ['node', 'x'], env: {} });
assert.throws(
() => io.error('no reason given'),
(err) => err instanceof ExitError && err.code === 1,
);
});
test('A2: error() with no reason argument stays FAIL (exit 1) under v2 too — UNKNOWN is not a specific outcome', () => {
resolveContractVersion({ argv: ['node', 'x', '--exit-contract=v2'], env: {} });
assert.throws(
() => io.error('no reason given'),
(err) => err instanceof ExitError && err.code === 1,
);
});
// B2 — spot-check the specific mappings the design calls out by name.
test('B2: SDK_MISSING_ARG / SDK_UNKNOWN_COMMAND / USAGE all reach USAGE (64) under v2', () => {
resolveContractVersion({ argv: ['node', 'x', '--exit-contract=v2'], env: {} });
for (const key of ['SDK_MISSING_ARG', 'SDK_UNKNOWN_COMMAND', 'USAGE']) {
assert.throws(
() => io.error('msg', io.ERROR_REASON[key]),
(err) => err instanceof ExitError && err.code === 64,
`${key} must project to 64 under v2`,
);
}
});
// B5 — the anti-vacuity test. Without this, a mapping where every reason
// projects to 1 under both versions would satisfy every row above.
test('B5 (anti-vacuity): v1 and v2 differ for at least one reason', () => {
resolveContractVersion({ argv: ['node', 'x'], env: {} });
let v1Code;
try { io.error('msg', io.ERROR_REASON.SDK_MISSING_ARG); } catch (e) { v1Code = e.code; }
resolveContractVersion({ argv: ['node', 'x', '--exit-contract=v2'], env: {} });
let v2Code;
try { io.error('msg', io.ERROR_REASON.SDK_MISSING_ARG); } catch (e) { v2Code = e.code; }
assert.equal(v1Code, 1);
assert.equal(v2Code, 64);
assert.notEqual(v1Code, v2Code, 'v1 and v2 must differ for at least one reason, or the declaration is decorative');
});
});
describe('#3912 A3-A5: output({error}) records DEGRADED — shape-exhaustive plus a real census', () => {
// A3/A4 — shape exhaustive: both key orders, and varying the error value's
// own type/truthiness (irrelevant to detection — presence of the key is
// what counts).
// The discriminator is a SERIALIZABLE error value, not mere key presence:
// every row here embeds its payload via `JSON.stringify(payload)` to build
// the child script's literal, and `JSON.stringify` drops any key whose
// value is `undefined` — so an `{ error: undefined }` row would silently
// arrive at `output()` with NO `error` key at all, making the row pass for
// the wrong reason (or, as originally written, fail outright: see the
// dedicated `{error: undefined}` case below, which constructs the object
// as source text instead so the key survives).
const shapes = [
['error first', { error: 'boom', found: false }],
['error last (A4)', { found: false, error: 'boom' }],
['error in the middle', { a: 1, error: 'boom', b: 2 }],
['error value is falsy (0)', { found: false, error: 0 }],
['error value is null', { found: false, error: null }],
['error value is an object', { error: { code: 'X' }, found: false }],
];
for (const [label, payload] of shapes) {
test(`A3/A4 (${label}): exits 0 under v1 and is recorded as DEGRADED`, () => {
const script = `
const io = require(${JSON.stringify(IO_PATH_3912)});
const c = require(${JSON.stringify(CLI_EXIT_PATH_3912)});
io.output(${JSON.stringify(payload)}, false);
process.stdout.write('|PENDING=' + c.getPendingOutcome());
`;
const result = toLegacyResult(runNode(['-e', script], { timeoutMs: PROBE_TIMEOUT_MS }));
assert.strictEqual(result.status, 0, `stderr: ${result.stderr}`);
assert.ok(result.stdout.includes('|PENDING=DEGRADED'), `expected DEGRADED recorded; got: ${result.stdout}`);
});
}
// A3/A4 pin — `{ error: undefined }` is NOT degraded. The object literal is
// written as SOURCE TEXT here (not round-tripped through
// `JSON.stringify(payload)`), so the `error` key genuinely reaches
// `output()` with an `undefined` value. `JSON.stringify` (the serializer
// `output()` itself uses to build the payload the user actually receives)
// drops a key whose value is `undefined`, so the wire payload is
// `{"found":false}` — no error at all. Recording DEGRADED here would be a
// false verdict: exit 80 under v2 for output the user sees as clean. The
// discriminator is a SERIALIZABLE error value, not key presence.
test('A3/A4 pin: {error: undefined} is NOT recorded as DEGRADED (JSON.stringify drops it)', () => {
const script = `
const io = require(${JSON.stringify(IO_PATH_3912)});
const c = require(${JSON.stringify(CLI_EXIT_PATH_3912)});
io.output({ found: false, error: undefined }, false);
process.stdout.write('|PENDING=' + c.getPendingOutcome());
`;
const result = toLegacyResult(runNode(['-e', script], { timeoutMs: PROBE_TIMEOUT_MS }));
assert.strictEqual(result.status, 0, `stderr: ${result.stderr}`);
assert.ok(
!result.stdout.includes('|PENDING=DEGRADED'),
`{error: undefined} carries no serializable error and must not be degraded; got: ${result.stdout}`,
);
});
// A5 — negative space: no `error` key at all must NOT be recorded as degraded.
test('A5: output() with no error key exits 0 and records NOTHING', () => {
const script = `
const io = require(${JSON.stringify(IO_PATH_3912)});
const c = require(${JSON.stringify(CLI_EXIT_PATH_3912)});
io.output({ ok: true, value: 1 }, false);
process.stdout.write('|PENDING=' + c.getPendingOutcome());
`;
const result = toLegacyResult(runNode(['-e', script], { timeoutMs: PROBE_TIMEOUT_MS }));
assert.strictEqual(result.status, 0, `stderr: ${result.stderr}`);
assert.ok(!result.stdout.includes('|PENDING=DEGRADED'), `must not record DEGRADED; got: ${result.stdout}`);
});
// A3 census — AST-based, not a text grep (local/no-source-grep bans
// readFileSync().includes()/.match()/etc on source; this parses an AST
// instead and never calls a string-search method on the source text).
// Counts every call to an identifier literally named `output` (the name
// every call site in this tree destructures it to — `const { output } =
// ioMod`) whose first argument is an object literal carrying an `error`
// property. This is the SHAPE the design measured, over the real tree,
// not a hand-picked subset — and it independently reproduces the design
// doc's per-file breakdown (frontmatter 7, phase 4, roadmap 3, state 25,
// verify 8, workstream 7, commands 5, template 3, gsd2-import 2 = 64),
// which is itself the corrected count over ADR-2980's stale 60.
test('A3 census: exactly 64 output({error}) call sites exist in src/, across the 9 modules the design measured', () => {
const SRC_ROOT = path.resolve(__dirname, '../src');
function listCtsFiles(dir) {
const out = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) out.push(...listCtsFiles(full));
else if (entry.name.endsWith('.cts') && !entry.name.endsWith('.d.cts')) out.push(full);
}
return out;
}
function hasErrorProp(objLit) {
return objLit.properties.some((p) => {
if (ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p)) {
const name = p.name;
if (ts.isIdentifier(name)) return name.text === 'error';
if (ts.isStringLiteral(name)) return name.text === 'error';
}
return false;
});
}
const perFile = {};
let total = 0;
for (const file of listCtsFiles(SRC_ROOT)) {
const text = fs.readFileSync(file, 'utf8');
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
let count = 0;
(function visit(node) {
if (ts.isCallExpression(node)) {
let calleeName = null;
if (ts.isIdentifier(node.expression)) calleeName = node.expression.text;
else if (ts.isPropertyAccessExpression(node.expression) && ts.isIdentifier(node.expression.name)) {
calleeName = node.expression.name.text;
}
if (calleeName === 'output' && node.arguments.length > 0 && ts.isObjectLiteralExpression(node.arguments[0])) {
if (hasErrorProp(node.arguments[0])) count += 1;
}
}
ts.forEachChild(node, visit);
})(sf);
if (count > 0) perFile[path.basename(file)] = count;
total += count;
}
assert.deepStrictEqual(
perFile,
{
'commands.cts': 5, 'frontmatter.cts': 7, 'gsd2-import.cts': 2, 'phase.cts': 4,
'roadmap.cts': 3, 'state.cts': 25, 'template.cts': 3, 'verify.cts': 8, 'workstream.cts': 7,
},
`per-file output({error}) census drifted: ${JSON.stringify(perFile)}`,
);
assert.strictEqual(total, 64, `enumerated output({error}) population drifted from the measured 64: got ${total}`);
});
});
describe('#3912 C5/D: output({error}) DEGRADED reaches process.exitCode only through runMain', () => {
afterEach(() => {
setPendingOutcome(undefined);
resolveContractVersion({ argv: ['node', 'x'], env: {} });
});
test('a real gsd-tools-shaped main() that calls output({error}) and returns nothing exits 0 under v1, 80 under v2', () => {
const script = `
const io = require(${JSON.stringify(IO_PATH_3912)});
const c = require(${JSON.stringify(CLI_EXIT_PATH_3912)});
c.runMain(() => {
io.output({ found: false, error: 'not found' }, false);
return undefined;
});
setImmediate(() => {});
`;
const v1 = toLegacyResult(runNode(['-e', script], {
timeoutMs: PROBE_TIMEOUT_MS, env: { ...process.env, GSD_EXIT_CONTRACT: 'v1' },
}));
assert.strictEqual(v1.status, 0, `stderr: ${v1.stderr}`);
const v2 = toLegacyResult(runNode(['-e', script], {
timeoutMs: PROBE_TIMEOUT_MS, env: { ...process.env, GSD_EXIT_CONTRACT: 'v2' },
}));
assert.strictEqual(v2.status, 80, `stderr: ${v2.stderr}`);
});
test('an explicit main() return still wins over a DEGRADED output({error}) call in the same main()', () => {
const script = `
const io = require(${JSON.stringify(IO_PATH_3912)});
const c = require(${JSON.stringify(CLI_EXIT_PATH_3912)});
c.runMain(() => {
io.output({ found: false, error: 'not found' }, false);
return 0;
});
setImmediate(() => {});
`;
const r = toLegacyResult(runNode(['-e', script], {
timeoutMs: PROBE_TIMEOUT_MS, env: { ...process.env, GSD_EXIT_CONTRACT: 'v2' },
}));
assert.strictEqual(r.status, 0, `an explicit 0 return must win over the DEGRADED cell; stderr: ${r.stderr}`);
});
});
describe('review fix: pending-outcome cell lifetime (last-write-wins, cleared on consumption)', () => {
// These drive runMain/output IN-PROCESS (not via a subprocess), which is
// exactly the gap that let the leak through: every other #3912 test above
// spawns a fresh process per case, so a cell that is never cleared was
// unobservable. runMain mutates process.exitCode as a side effect, so each
// test saves/restores it to avoid corrupting the real node:test run's own
// exit code.
function waitForRunMain() {
// runMain resolves its outcome via a Promise.resolve().then().then()
// chain (microtasks); a macrotask tick guarantees both have drained.
return new Promise((resolve) => { setImmediate(resolve); });
}
afterEach(() => {
// Harmless test hygiene now that production also clears the cell on
// every runMain call and on every clean output() — this scrub is a
// belt-and-suspenders reset between test cases, not the mechanism that
// prevents the leak (that mechanism now lives in cli-exit.cts/io.cts).
setPendingOutcome(undefined);
resolveContractVersion({ argv: ['node', 'x'], env: {} });
});
test('leak regression: output({error}) then a second void-returning runMain must NOT inherit stale DEGRADED', async () => {
resolveContractVersion({ argv: ['node', 'x', '--exit-contract=v2'], env: {} });
const savedExitCode = process.exitCode;
try {
// First invocation declares DEGRADED via a payload-carried error and
// returns nothing — runMain projects it to 80 under v2.
runMain(() => {
io.output({ found: false, error: 'not found' }, false);
return undefined;
});
await waitForRunMain();
assert.strictEqual(process.exitCode, 80, 'first runMain should have projected the DEGRADED cell to 80');
// Second, unrelated invocation in the SAME process declares nothing
// and returns nothing. Before the fix, the cell was never cleared by
// runMain, so this would inherit the first call's stale DEGRADED and
// also exit 80 — the exact bug the reviewers found.
process.exitCode = undefined;
runMain(() => undefined);
await waitForRunMain();
assert.strictEqual(
process.exitCode,
undefined,
'a later void-returning runMain must not inherit a prior invocation\'s stale DEGRADED declaration',
);
} finally {
process.exitCode = savedExitCode;
}
});
test('last-write-wins: output({error}) then output({ok:true}) in the SAME invocation is not degraded', async () => {
resolveContractVersion({ argv: ['node', 'x', '--exit-contract=v2'], env: {} });
const savedExitCode = process.exitCode;
try {
runMain(() => {
io.output({ found: false, error: 'not found' }, false);
io.output({ ok: true }, false);
return undefined;
});
await waitForRunMain();
assert.strictEqual(
process.exitCode,
undefined,
'a later clean output() must undo an earlier degraded one — exit code must stay untouched, not 80',
);
} finally {
process.exitCode = savedExitCode;
}
});
test('consumption clears: after runMain consumes a pending outcome, the cell reads unset', async () => {
resolveContractVersion({ argv: ['node', 'x', '--exit-contract=v2'], env: {} });
const savedExitCode = process.exitCode;
try {
runMain(() => {
io.output({ error: 'x' }, false);
return undefined;
});
await waitForRunMain();
assert.strictEqual(process.exitCode, 80);
assert.strictEqual(getPendingOutcome(), undefined, 'the cell must be cleared once runMain has consumed it');
} finally {
process.exitCode = savedExitCode;
}
});
});
describe('#3912 A6: error() stderr bytes are unchanged by this phase', () => {
afterEach(() => {
resolveContractVersion({ argv: ['node', 'x'], env: {} });
});
test('plain mode: "Error: <msg>" bytes are identical regardless of reason or contract version', () => {
for (const version of VERSIONS_3912) {
resolveContractVersion({ argv: ['node', 'x', `--exit-contract=${version}`], env: {} });
let caught;
const chunks = [];
const origWrite = fs.writeSync;
fs.writeSync = (fd, buf, offset, length) => {
if (fd !== 2) return origWrite(fd, buf, offset, length);
const slice = Buffer.isBuffer(buf) ? buf.subarray(offset, offset + length) : Buffer.from(String(buf));
chunks.push(slice.toString('utf8'));
return slice.length;
};
try {
io.setJsonErrorMode(false);
try { io.error('boundary case', io.ERROR_REASON.SDK_MISSING_ARG); } catch (e) { caught = e; }
} finally {
fs.writeSync = origWrite;
}
assert.ok(caught instanceof ExitError);
assert.strictEqual(chunks.join(''), 'Error: boundary case\n', `version=${version}`);
}
});
test('json mode: the stderr envelope is identical regardless of contract version (only the thrown exit code differs)', () => {
for (const version of VERSIONS_3912) {
resolveContractVersion({ argv: ['node', 'x', `--exit-contract=${version}`], env: {} });
const chunks = [];
const origWrite = fs.writeSync;
fs.writeSync = (fd, buf, offset, length) => {
if (fd !== 2) return origWrite(fd, buf, offset, length);
const slice = Buffer.isBuffer(buf) ? buf.subarray(offset, offset + length) : Buffer.from(String(buf));
chunks.push(slice.toString('utf8'));
return slice.length;
};
let caught;
try {
io.setJsonErrorMode(true);
try { io.error('boundary case', io.ERROR_REASON.SDK_MISSING_ARG); } catch (e) { caught = e; }
} finally {
fs.writeSync = origWrite;
io.setJsonErrorMode(false);
}
assert.ok(caught instanceof ExitError);
assert.deepStrictEqual(
JSON.parse(chunks.join('').trim()),
{ ok: false, reason: 'sdk_missing_arg', message: 'boundary case' },
`version=${version}`,
);
}
});
});
describe('#3912 B1/B4/E1: projectOutcome-backed checks over the real registry', () => {
test('B1: every registered outcome name is reachable through error() via SOME reason, and matches the registry', () => {
// Sanity check that CODE_FOR_3912 (derived straight from the shipped
// registry) matches the pinned table cli-exit.test.cjs already asserts.
assert.strictEqual(CODE_FOR_3912.get('USAGE'), 64);
assert.strictEqual(CODE_FOR_3912.get('NO_INPUT'), 66);
assert.strictEqual(CODE_FOR_3912.get('UNAVAILABLE'), 69);
assert.strictEqual(CODE_FOR_3912.get('INTERNAL'), 70);
assert.strictEqual(CODE_FOR_3912.get('DEGRADED'), 80);
});
test('B4: any output({error}) site under v2 exits 80 (DEGRADED)', () => {
const script = `
const io = require(${JSON.stringify(IO_PATH_3912)});
const c = require(${JSON.stringify(CLI_EXIT_PATH_3912)});
c.runMain(() => { io.output({ error: 'x' }, false); return undefined; });
setImmediate(() => {});
`;
const r = toLegacyResult(runNode(['-e', script], {
timeoutMs: PROBE_TIMEOUT_MS, env: { ...process.env, GSD_EXIT_CONTRACT: 'v2' },
}));
assert.strictEqual(r.status, 80, `stderr: ${r.stderr}`);
});
// E1 lives primarily in tests/cli-exit.test.cjs (projectOutcome is defined
// there); this is the io-side control confirming REGISTERED_NAMES_3912
// used by the reason-mapping table above matches the live registry.
test('registered names used by the reason-mapping table are exactly the live registry names', () => {
for (const outcome of Object.values(EXPECTED_REASON_OUTCOME_3912)) {
if (outcome === 'FAIL') continue;
assert.ok(
REGISTERED_NAMES_3912.includes(outcome),
`mapped outcome ${outcome} must be a registered exit-code name`,
);
}
});
test('fast-check: E1 sanity — every mapped outcome/version pair used by error() yields a non-negative integer', () => {
const outcomes = [...new Set(Object.values(EXPECTED_REASON_OUTCOME_3912))];
fc.assert(
fc.property(
fc.constantFrom(...outcomes),
fc.constantFrom(...VERSIONS_3912),
(outcome, version) => {
const code = outcome === 'FAIL' ? 1 : (version === 'v1' ? 1 : CODE_FOR_3912.get(outcome));
assert.ok(Number.isInteger(code) && code >= 0);
},
),
{ seed: 39120, numRuns: 100 },
);
});
});