Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD across contents and paths, upstream package/repo coordinates -> @golem15/msd-core and golem15com/msd-core. Deep links into upstream history, sibling upstream packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is. Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line, package/plugin identity, regenerated lockfile, install-tree fixtures, derived registries and benchmark baseline; migration checksum baseline re-locked (MSD keeps its own install state, so no install had applied the old sums); sort-order and regex-escaped expectations in tests adjusted.
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 → msd-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, '../msd-core/bin/lib/cli-exit.cjs');
|
|
const IO_PATH = path.resolve(__dirname, '../msd-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, '../msd-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 (msd-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 (msd-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: msd-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: msd-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 .msd/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 msd-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 msd-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
|
|
// msd-core sibling and no node_modules — a require of the built tree is
|
|
// MODULE_NOT_FOUND there. Deliberately NOT done by renaming the real
|
|
// msd-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('msd-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', () => {
|
|
// msd-core/bin/ensure-runtime-build.cjs keys isBuilt() on this exact filename.
|
|
assert.ok(
|
|
fs.statSync(BUILT_CLI_EXIT_PATH).isFile(),
|
|
'msd-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('MSD_EXIT_CONTRACT=v2 env -> v2', () => {
|
|
assert.equal(resolveContractVersion({ argv: ['node', 'x'], env: { MSD_EXIT_CONTRACT: 'v2' } }), 'v2');
|
|
});
|
|
|
|
test('flag v1 beats env v2', () => {
|
|
assert.equal(
|
|
resolveContractVersion({ argv: ['node', 'x', '--exit-contract=v1'], env: { MSD_EXIT_CONTRACT: 'v2' } }),
|
|
'v1',
|
|
);
|
|
});
|
|
|
|
test('flag v2 beats env v1 (both directions)', () => {
|
|
assert.equal(
|
|
resolveContractVersion({ argv: ['node', 'x', '--exit-contract=v2'], env: { MSD_EXIT_CONTRACT: 'v1' } }),
|
|
'v2',
|
|
);
|
|
});
|
|
|
|
test('an empty MSD_EXIT_CONTRACT reads as unset, not as an explicit selection', () => {
|
|
assert.equal(resolveContractVersion({ argv: ['node', 'x'], env: { MSD_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('MSD_EXIT_CONTRACT=v3 (env garbage) is rejected the same way', () => {
|
|
assert.throws(() => resolveContractVersion({ argv: ['node', 'x'], env: { MSD_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: { MSD_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 (MSD_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 MSD_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, MSD_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, MSD_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 MSD_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 MSD_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 MSD_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 msd-tools command with --exit-contract=v2 (or
|
|
// MSD_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
|
|
// `MSD_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 MSD_EXIT_CONTRACT/--exit-contract wiring (acceptance criterion)', () => {
|
|
test('terminateNow: MSD_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, MSD_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.MSD_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: MSD_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, MSD_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.MSD_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('msd-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.MSD_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('msd-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.MSD_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 MSD_EXIT_CONTRACT must NOT unseat it.
|
|
test('an explicit resolveContractVersion() call beats a later ambient MSD_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, MSD_EXIT_CONTRACT: 'v2' } }));
|
|
assert.equal(
|
|
r.status, 0,
|
|
`an explicit resolveContractVersion('v1') call must beat a later ambient MSD_EXIT_CONTRACT=v2 read; stderr: ${r.stderr}`,
|
|
);
|
|
});
|
|
|
|
test('MSD_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, MSD_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: MSD_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, MSD_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,
|
|
// msd-core/bin/lib/cli-exit.cjs). Nothing previously spliced the flag out of
|
|
// the `msd-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:
|
|
// `msd-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.
|
|
// `msd-tools state-snapshot --exit-contract=v2` (trailing) worked, because
|
|
// the flag landed after the command name and never collided with dispatch.
|
|
describe('#3912: msd-tools dispatcher splices --exit-contract=<v> regardless of argv position', () => {
|
|
const MSD_TOOLS_BIN = path.resolve(__dirname, '../msd-core/bin/msd-tools.cjs');
|
|
|
|
function run(args, options = {}) {
|
|
return toLegacyResult(runNode([MSD_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 MSD_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, MSD_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 msd-core sibling', (t) => {
|
|
const dir = createTempDir('msd-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
|
|
// msd-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. msd-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
|
|
// msd-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('msd-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 msd-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('msd-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('msd-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, MSD_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, MSD_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, MSD_EXIT_CONTRACT: 'v1' },
|
|
}));
|
|
assert.equal(v1.status, 0, `stderr: ${v1.stderr}`);
|
|
const v2 = toLegacyResult(runNode(['-e', script], {
|
|
timeoutMs: PROBE_TIMEOUT_MS, env: { ...process.env, MSD_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, MSD_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, MSD_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 (msd-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, MSD_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}`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
});
|