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.
561 lines
31 KiB
JavaScript
561 lines
31 KiB
JavaScript
// GENERATED FILE — DO NOT EDIT BY HAND.
|
|
// Source of truth: src/cli-exit.cts. Regenerate with:
|
|
// node scripts/gen-hooks-cli-exit.cjs --write
|
|
// Byte-compared by `npm run lint:generated-sync` (#3911, ADR-3889 Phase 7).
|
|
//
|
|
// Why this copy exists: hooks/ runs straight from a raw, unbuilt clone — a
|
|
// shipped hook must be able to `require('./lib/cli-exit.js')` relative to
|
|
// its own __dirname and terminate through `terminateNow` without depending
|
|
// on any build artifact. msd-core/bin/lib/cli-exit.cjs is gitignored tsc
|
|
// output and doubles as the build sentinel, so it cannot be required from
|
|
// here. `.js`, not `.cjs`, to match the hooks/lib/*.js convention. Hence one
|
|
// source, three emitted locations (msd-core/bin/lib, scripts/lib, hooks/lib).
|
|
|
|
"use strict";
|
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
};
|
|
/**
|
|
* Process-exit primitives (ExitError, runMain, terminateNow) plus the
|
|
* json-error-mode and contract-version cells.
|
|
*
|
|
* Must import nothing but `node:fs` and `./exit-code-registry.cjs` — this
|
|
* source is emitted to TWO locations, msd-core/bin/lib/cli-exit.cjs (tsc
|
|
* build output) and scripts/lib/cli-exit.cjs (a generated, committed
|
|
* artifact regenerated by scripts/gen-scripts-cli-exit.cjs), and the latter
|
|
* must load on an unbuilt clone before anything under ./lib exists. The
|
|
* registry require is safe here for the same reason: scripts/gen-exit-code-
|
|
* registry.cjs (ADR-3889 Phase 1/2, #3905/#3906) dual-emits its OWN sibling
|
|
* artifact, exit-code-registry.cjs, into both of these exact locations, so
|
|
* a relative `./exit-code-registry.cjs` resolves next to whichever copy of
|
|
* this module loaded it.
|
|
*/
|
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const exitCodeRegistryModule = require("./exit-code-registry.js");
|
|
// Called only as exitCodeRegistryModule.exitCodeFor(...), never destructured:
|
|
// @typescript-eslint/unbound-method flags a bare function-typed property
|
|
// pulled off an object at the point of destructuring, since a detached
|
|
// reference COULD be called with the wrong `this` — keeping the member
|
|
// access qualified sidesteps that regardless of whether the callee ever
|
|
// actually touches `this` (it does not; exitCodeFor is pure).
|
|
const exitCodeFor = (name) => exitCodeRegistryModule.exitCodeFor(name);
|
|
/**
|
|
* The wire value `runMain` stamps into its structured envelope. Declared HERE,
|
|
* not in io.cts, because this module must not import anything (see the module
|
|
* header): io.cts builds ERROR_REASON.SDK_FAIL_FAST from this constant, so the
|
|
* two surfaces share ONE definition rather than two literals kept in step by a
|
|
* parity test.
|
|
*/
|
|
const EXIT_ENVELOPE_REASON = 'sdk_fail_fast';
|
|
/**
|
|
* Process-level flag: when true, error paths emit structured JSON to stderr
|
|
* instead of plain text. Set by msd-tools.cjs when the CLI is invoked with
|
|
* `--json-errors`; re-exported by io.cts, which is where most callers reach it.
|
|
*
|
|
* Held in a Symbol-keyed cell on globalThis rather than in module scope, and
|
|
* that is load-bearing: this module is emitted to TWO locations
|
|
* (msd-core/bin/lib/cli-exit.cjs and the generated scripts/lib/cli-exit.cjs),
|
|
* so a process that loads both would get two independent module instances. A
|
|
* module-level `let` would give them two independent flags — one copy could
|
|
* think json mode is on while the other thought it was off, which is exactly
|
|
* the divergence class ADR-3889 exists to remove. One cell, keyed by a
|
|
* registry Symbol, makes that unrepresentable.
|
|
*/
|
|
const JSON_ERROR_MODE_KEY = Symbol.for('msd.exit.jsonErrorMode');
|
|
function setJsonErrorMode(v) {
|
|
globalThis[JSON_ERROR_MODE_KEY] = !!v;
|
|
}
|
|
function getJsonErrorMode() {
|
|
return globalThis[JSON_ERROR_MODE_KEY] === true;
|
|
}
|
|
/** The single registered name code 2 may ever be produced for (ADR-3889 §1). */
|
|
const HOOK_DENY_NAME = 'HOOK_DENY';
|
|
const HOOK_DENY_CODE = exitCodeFor(HOOK_DENY_NAME);
|
|
/**
|
|
* Currently-resolved exit-contract version (ADR-3889 §4). Held in a
|
|
* Symbol-keyed globalThis cell rather than a module-level `let`, for the
|
|
* exact reason JSON_ERROR_MODE_KEY is (see its comment above): this module
|
|
* is emitted to two locations and thus loaded as two independent module
|
|
* instances in any process that requires both, so a module-level variable
|
|
* would let those two instances disagree about which contract is active.
|
|
* `resolveContractVersion` is the only writer; `terminateNow`/`runMain`
|
|
* read it internally when projecting a declared outcome.
|
|
*/
|
|
const CONTRACT_VERSION_KEY = Symbol.for('msd.exit.contractVersion');
|
|
function setContractVersion(v) {
|
|
globalThis[CONTRACT_VERSION_KEY] = v;
|
|
}
|
|
/**
|
|
* Resolve the active exit-contract version, wiring the ambient process to the
|
|
* two terminators (ADR-3889 §4/§3). Mirrors how JSON_ERROR_MODE_KEY already
|
|
* works: a process-global cell means no entrypoint needs per-call wiring, so
|
|
* a `scripts/` tool or a hook gets the same behaviour as `msd-tools` without
|
|
* this module touching either (P8 owns `msd-tools`; P7 owns hooks).
|
|
*
|
|
* Precedence: if the cell already holds an explicit version, that wins —
|
|
* this is what lets `setContractVersion` override the ambient process (a
|
|
* later `MSD_EXIT_CONTRACT=v2` in the same process must NOT unseat an
|
|
* explicit `setContractVersion('v1')` call). Otherwise resolve from argv/env
|
|
* via `resolveContractVersion`, which itself persists the result into the
|
|
* cell — so this is a one-time resolution per process; every later read is
|
|
* just the cached cell value. An invalid ambient value (e.g. `v3`) is NOT
|
|
* softened to a silent v1 here: `resolveContractVersion` throws, and that
|
|
* throw propagates — swallowing it would reintroduce the "nothing fails with
|
|
* success" defect ADR-3889 exists to close, on the very selector meant to
|
|
* demonstrate the fix. Absent both flag and env, resolution still yields
|
|
* 'v1' (the documented default) and that too gets memoized.
|
|
*/
|
|
function getContractVersion() {
|
|
const cached = globalThis[CONTRACT_VERSION_KEY];
|
|
if (cached === 'v1' || cached === 'v2')
|
|
return cached;
|
|
return resolveContractVersion({ argv: process.argv, env: process.env });
|
|
}
|
|
/**
|
|
* Project a declared outcome onto an integer exit code for a given contract
|
|
* version. Pure and total over its own input space: throws for anything not
|
|
* an exact-case registered name (mirrors exitCodeFor's contract) or an
|
|
* unrecognized version — it never returns undefined/NaN.
|
|
*
|
|
* PASS/FAIL and every registered name project IDENTICALLY under v1 and v2
|
|
* (registered names are version-invariant) — the sole exception is DEGRADED:
|
|
*
|
|
* v1: DEGRADED -> 0. Deliberate, NOT a bug: ADR-2980 ratified 60
|
|
* `output({error})` call sites that already exit 0 on a payload-carried
|
|
* error, and ADR-2980's own "Revisit if" clause is what ADR-3889 §4
|
|
* answers — normalizing this to a non-zero code was explicitly
|
|
* DECLINED there on measured blast radius. A future reader must not
|
|
* "fix" this to look more consistent with v2; the inconsistency IS the
|
|
* compatibility boundary.
|
|
* v2: DEGRADED -> exitCodeFor('DEGRADED') (80). Looked up through the
|
|
* registry, never hardcoded, so a re-allocation of DEGRADED's code
|
|
* cannot silently desync this projection from the shipped table.
|
|
*/
|
|
function projectOutcome(outcome, version) {
|
|
if (typeof outcome !== 'string' || outcome.length === 0) {
|
|
throw new Error(`projectOutcome: outcome must be a non-empty string, received ${JSON.stringify(outcome)}`);
|
|
}
|
|
if (version !== 'v1' && version !== 'v2') {
|
|
throw new Error(`projectOutcome: version must be 'v1' or 'v2', received ${JSON.stringify(version)}`);
|
|
}
|
|
if (outcome === 'PASS')
|
|
return 0;
|
|
if (outcome === 'FAIL')
|
|
return 1;
|
|
if (outcome === 'DEGRADED')
|
|
return version === 'v1' ? 0 : exitCodeFor('DEGRADED');
|
|
// Any other registered name: version-invariant, resolved through the
|
|
// registry (throws for anything unregistered/empty/non-string/wrong-case —
|
|
// exitCodeFor's own contract, which this function inherits verbatim).
|
|
return exitCodeFor(outcome);
|
|
}
|
|
/**
|
|
* Pending declared outcome (ADR-3889 §4, #3912): the outcome `output()`
|
|
* records when it detects a payload-carried error (`{ error }`, any key
|
|
* order) on a call that otherwise just returns — there is no thrown
|
|
* ExitError and no explicit `main()` return for `runMain` to project, so
|
|
* without this cell the declaration has nowhere to land. `runMain` reads it
|
|
* ONLY when `main()` itself returns no explicit code (void/undefined); an
|
|
* explicit number/string return always wins over whatever this cell holds.
|
|
*
|
|
* Held in a Symbol-keyed globalThis cell for the exact reason
|
|
* JSON_ERROR_MODE_KEY / CONTRACT_VERSION_KEY are (see their comments above):
|
|
* this module is emitted to three locations and thus loaded as independent
|
|
* module instances in any process that requires more than one, so a
|
|
* module-level variable would let those instances disagree about whether a
|
|
* degraded result was ever declared.
|
|
*
|
|
* LIFETIME (#3912 review fix): LAST DECLARATION WINS, CLEARED ON CONSUMPTION.
|
|
* This cell is NOT "was DEGRADED ever declared this process" — it is "is a
|
|
* degraded outcome pending RIGHT NOW". `output()` sets it to 'DEGRADED' on a
|
|
* payload-carried error and CLEARS it (`undefined`) on a clean payload, so a
|
|
* later clean `output()` call undoes an earlier degraded one in the same
|
|
* invocation. `runMain` clears it immediately after consuming it (in a
|
|
* `finally`, on both the pending-cell branch and the case where nothing was
|
|
* pending), so a second `runMain` in the same process starts clean. Without
|
|
* both halves the cell is monotonic for the life of the process: any later
|
|
* `main()` returning void would inherit a stale DEGRADED from an unrelated,
|
|
* earlier call — this is the leak #3912 review found and fixed.
|
|
*/
|
|
const PENDING_OUTCOME_KEY = Symbol.for('msd.exit.pendingOutcome');
|
|
function setPendingOutcome(v) {
|
|
globalThis[PENDING_OUTCOME_KEY] = v;
|
|
}
|
|
function getPendingOutcome() {
|
|
return globalThis[PENDING_OUTCOME_KEY];
|
|
}
|
|
const EXIT_CONTRACT_FLAG_PREFIX = '--exit-contract=';
|
|
/** Scan argv for the FIRST `--exit-contract=<value>` token; undefined if absent. */
|
|
function findExitContractFlag(argv) {
|
|
for (const arg of argv) {
|
|
if (typeof arg === 'string' && arg.startsWith(EXIT_CONTRACT_FLAG_PREFIX)) {
|
|
return arg.slice(EXIT_CONTRACT_FLAG_PREFIX.length);
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
/**
|
|
* Resolve which exit-contract version is active from argv/env, per ADR-3889
|
|
* §4, and persist it to the shared contract-version cell so a later
|
|
* `terminateNow`/`runMain` call (through EITHER module copy) projects
|
|
* against it without re-parsing argv/env itself.
|
|
*
|
|
* Precedence: an explicit `--exit-contract=<v>` flag BEATS
|
|
* `MSD_EXIT_CONTRACT`, in both directions (flag=v1 + env=v2 -> v1; flag=v2 +
|
|
* env=v1 -> v2). Neither present -> 'v1' (the documented default). An empty
|
|
* env var reads as UNSET, not as an explicit empty selection — a shell that
|
|
* exports `MSD_EXIT_CONTRACT=` with nothing after the `=` must not silently
|
|
* select a version.
|
|
*
|
|
* Casing is decided, not accidental: only the exact lowercase tokens `v1`/
|
|
* `v2` are accepted (matching every example in ADR-3889 and this module's own
|
|
* usage docs, both of which write `v2` never `V2`). Anything else recognized
|
|
* as PRESENT but not a valid version — `v3`, `garbage`, or an explicitly
|
|
* empty flag value (`--exit-contract=`) — THROWS rather than silently
|
|
* defaulting to v1. A selector for a contract whose whole thesis is "nothing
|
|
* fails with success" must not itself fail open.
|
|
*/
|
|
function resolveContractVersion(opts = {}) {
|
|
const argv = opts.argv ?? process.argv;
|
|
const env = opts.env ?? process.env;
|
|
const flagValue = findExitContractFlag(argv);
|
|
const rawEnvValue = env.MSD_EXIT_CONTRACT;
|
|
const envValue = rawEnvValue === undefined || rawEnvValue === '' ? undefined : rawEnvValue;
|
|
const selected = flagValue !== undefined ? flagValue : envValue;
|
|
let resolved;
|
|
if (selected === undefined) {
|
|
resolved = 'v1';
|
|
}
|
|
else if (selected === 'v1' || selected === 'v2') {
|
|
resolved = selected;
|
|
}
|
|
else {
|
|
throw new Error(`resolveContractVersion: unrecognized exit-contract version ${JSON.stringify(selected)} `
|
|
+ `(expected 'v1' or 'v2')`);
|
|
}
|
|
setContractVersion(resolved);
|
|
return resolved;
|
|
}
|
|
/**
|
|
* Error carrying a process exit code. CLI logic throws this instead of calling
|
|
* process.exit() (banned by n/no-process-exit); runMain() translates it into
|
|
* process.exitCode at the entrypoint.
|
|
*/
|
|
class ExitError extends Error {
|
|
code;
|
|
hasUserMessage;
|
|
constructor(code = 1, message) {
|
|
super(message === undefined ? `process exit ${code}` : message);
|
|
this.name = 'ExitError';
|
|
this.code = code;
|
|
this.hasUserMessage = message !== undefined;
|
|
}
|
|
}
|
|
/**
|
|
* Run a CLI main and translate its outcome into process.exitCode (never
|
|
* process.exit, so n/no-process-exit stays satisfied; output flushes and
|
|
* process.on('exit') cleanup still fires — this is precisely why runMain and
|
|
* terminateNow are two different functions: drain-then-exit vs write-then-
|
|
* terminate). main may be sync or async. Every arm below except the new
|
|
* string one and the void/pending-cell one is UNCHANGED from before
|
|
* ADR-3889 Phase 2:
|
|
* number return -> process.exitCode = it (unchanged)
|
|
* string return -> NEW: process.exitCode = projectOutcome(result, getContractVersion()),
|
|
* UNLESS that projection is the HOOK_DENY exit code (see
|
|
* the refusal below — 2 may only be produced by terminateNow).
|
|
* void/undefined return -> NEW (#3912, ADR-3889 §4): an explicit return
|
|
* already handled above always wins, so this arm only
|
|
* runs when main() declared no outcome of its own. If
|
|
* the pending-outcome cell holds a value (currently only
|
|
* ever 'DEGRADED', set by io.cts's output() on a
|
|
* payload-carried error), project THAT through the
|
|
* current contract version — BUT ONLY when
|
|
* process.exitCode is not already a non-zero value.
|
|
* FULL PRECEDENCE ORDER for the code a void-returning
|
|
* main() ends up with:
|
|
* 1. An explicit number/string return from main()
|
|
* (handled in the arms above) — always wins.
|
|
* 2. A non-zero process.exitCode already set by main()
|
|
* itself before it returned (e.g. `state validate
|
|
* --strict`'s `emit()` setting 1 directly) — wins
|
|
* over the pending cell.
|
|
* 3. The pending-outcome cell's projection — used only
|
|
* when process.exitCode is still unset/0.
|
|
* 4. Otherwise process.exitCode stays 0 (default).
|
|
* This is a regression fix: unconditionally projecting
|
|
* the pending cell here used to CLOBBER an
|
|
* already-non-zero process.exitCode down to DEGRADED's
|
|
* v1 projection (0) — turning a real declared failure
|
|
* (e.g. `state validate --strict` against a missing
|
|
* STATE.md, which sets process.exitCode = 1 directly)
|
|
* into a false success. `runMain` must never LOWER an
|
|
* exit code that main() itself already raised. DEGRADED
|
|
* still projects to 0 under v1 when nothing else set a
|
|
* code — the same value this arm produced before this
|
|
* phase by doing nothing — so v1 behavior is
|
|
* byte-identical for every caller that never sets its
|
|
* own exit code.
|
|
* thrown ExitError -> process.exitCode = err.code (+ stderr err.message if hasUserMessage && code!=0) (unchanged)
|
|
* other throw -> when json-error mode is active, emits structured { ok:false, reason, message }
|
|
* to stderr; otherwise writes raw stack trace. exit code = 1 in either case. (unchanged)
|
|
*/
|
|
function runMain(main) {
|
|
Promise.resolve()
|
|
.then(() => main())
|
|
.then((result) => {
|
|
// Cleared on EVERY branch below, not only the pending-cell-consuming
|
|
// void arm: an explicit number/string return means main() declared
|
|
// its own outcome and the cell (if anything set it earlier in this
|
|
// same invocation) is now stale — leaving it set would leak into the
|
|
// NEXT runMain call in this process, reintroducing the #3912 leak one
|
|
// level up. "Cleared on consumption" therefore means "consumption of
|
|
// this runMain call", not just "consumption of the pending value".
|
|
try {
|
|
if (typeof result === 'number') {
|
|
process.exitCode = result;
|
|
return;
|
|
}
|
|
if (typeof result === 'string') {
|
|
const projected = projectOutcome(result, getContractVersion());
|
|
// ADR-3889 §3: exit code 2 (the hook-protocol deny) may
|
|
// ONLY be produced by terminateNow, never by runMain. runMain is
|
|
// drain-then-exit; a deny drained this way can be truncated on
|
|
// Windows, which is exactly why terminateNow (write-then-terminate)
|
|
// exists. Gated on the PROJECTED code, not on the literal string
|
|
// `'HOOK_DENY'`, so a future registry rename that still resolves to
|
|
// this code cannot slip past the guard.
|
|
if (projected === HOOK_DENY_CODE) {
|
|
process.stderr.write(`runMain: refusing to exit with code ${HOOK_DENY_CODE} — outcome ${JSON.stringify(result)} `
|
|
+ `projects to the ${HOOK_DENY_NAME} exit code, which is reserved to terminateNow. `
|
|
+ `A hook-protocol deny must be delivered write-then-terminate via terminateNow(${JSON.stringify(result)}, payload), `
|
|
+ 'never drain-then-exit via runMain — a drained deny can be truncated on Windows. '
|
|
+ 'This is a caller bug: runMain must not be given a main() that returns HOOK_DENY.\n');
|
|
process.exitCode = exitCodeFor('INTERNAL');
|
|
return;
|
|
}
|
|
process.exitCode = projected;
|
|
return;
|
|
}
|
|
// result is undefined (void return): main declared no outcome itself.
|
|
// Fall back to the pending-outcome cell, if anything set it — but
|
|
// NEVER lower an exit code main() already raised on its own (see the
|
|
// precedence order in this function's doc comment above). Without
|
|
// this guard, a void-returning main() that set process.exitCode = 1
|
|
// directly (e.g. `state validate --strict` on a missing STATE.md)
|
|
// would have that 1 clobbered down to DEGRADED's v1 projection (0)
|
|
// by a payload-carried error the SAME call also recorded via
|
|
// io.cts's output() — a real failure silently reported as success.
|
|
const pending = getPendingOutcome();
|
|
if (typeof pending === 'string' && pending.length > 0 && !process.exitCode) {
|
|
process.exitCode = projectOutcome(pending, getContractVersion());
|
|
}
|
|
}
|
|
finally {
|
|
// Cell is consumed exactly once per runMain call regardless of which
|
|
// branch above ran (see the cell's own doc comment — "last
|
|
// declaration wins, cleared on consumption") — so a later `runMain`
|
|
// in the same process never inherits this one's declaration.
|
|
setPendingOutcome(undefined);
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
if (err instanceof ExitError) {
|
|
if (err.hasUserMessage && err.code !== 0)
|
|
process.stderr.write(`${err.message}\n`);
|
|
process.exitCode = err.code;
|
|
return;
|
|
}
|
|
if (getJsonErrorMode()) {
|
|
const e = err;
|
|
const payload = JSON.stringify({
|
|
ok: false,
|
|
reason: EXIT_ENVELOPE_REASON,
|
|
message: (e && e.message) ? e.message : String(err),
|
|
}) + '\n';
|
|
node_fs_1.default.writeSync(2, payload);
|
|
}
|
|
else {
|
|
const e = err;
|
|
process.stderr.write(`${e && e.stack ? e.stack : String(err)}\n`);
|
|
}
|
|
process.exitCode = 1;
|
|
});
|
|
}
|
|
/**
|
|
* Write `payload` fully to fd 1 (and, for a deny, fd 2 too) and terminate the
|
|
* process IMMEDIATELY with `outcome` projected through the current contract
|
|
* version. This is write-then-terminate, the other half of ADR-3889 §3's
|
|
* "two terminators over one registry": hooks fire from contexts (e.g. a
|
|
* `setTimeout` stdin-timeout guard) where `process.exitCode = N; return;`
|
|
* terminates nothing, so they need an immediate, synchronous exit — the
|
|
* exact gap `eslint.config.mjs:563-582` documents for `hooks/**`.
|
|
*
|
|
* This is THE ONLY sanctioned `process.exit` call site in the repo, and the
|
|
* only place exit code 2 can be produced: 2 is reserved to the hook-adapter
|
|
* protocol (ADR-3889 §1), and the registry's own one-owner rule already
|
|
* guarantees no other registered name resolves to it — the check below is a
|
|
* defense-in-depth assertion of that invariant, not the sole thing enforcing
|
|
* it.
|
|
*
|
|
* @param outcome - declared outcome name, projected via projectOutcome.
|
|
* @param payload - JSON-serializable value written to fd 1 (and, on a deny
|
|
* for which no `stderrPayload` is given, fd 2 too — this is the
|
|
* backward-compatible default every existing caller relies on).
|
|
* @param stderrPayload - optional, deny-only. When omitted (the default),
|
|
* fd 2 gets the SAME serialized `payload` fd 1 got — unchanged behavior.
|
|
* When provided, fd 2 gets THIS instead: a string is written raw
|
|
* (verbatim, not JSON-stringified), anything else is JSON-stringified
|
|
* like `payload`. This exists because `hooks/msd-write-guard.js`'s
|
|
* emitBlock does NOT write the same bytes to both streams today — it
|
|
* writes the full JSON `output` to stdout but only the plain-text
|
|
* `output.reason` STRING to stderr, because Kimi's native hook bus reads
|
|
* stderr verbatim back to the model on exit 2. Migrating that call site
|
|
* onto terminateNow requires a way to say "fd 2 gets this different,
|
|
* plain-text value" — `stderrPayload` is that seam. Ignored entirely for
|
|
* a non-deny outcome: stderr is a deny-only channel.
|
|
*
|
|
* PAYLOAD-SIZE CONSTRAINT FOR CALLERS (measured for #3906, relevant to P7/
|
|
* #3911 wiring 19 enforcement hooks onto this function): the write-until-
|
|
* drained loop above delivers a payload whole regardless of size — verified
|
|
* up to 1MB (Node's own `spawnSync` default `maxBuffer`) with no truncation
|
|
* and no stall, both with a concurrently-draining async reader (~30ms for a
|
|
* 256KB payload) and with the default (internally-drained) pipe stdio a
|
|
* spawnSync-based test harness gets for free. Node's `spawnSync` does NOT
|
|
* suffer the classic "child blocks writing past the pipe buffer because
|
|
* nothing on the parent side is reading yet" deadlock some other languages'
|
|
* synchronous-subprocess primitives have; it drains stdout/stderr
|
|
* concurrently at the libuv layer while the child runs. The constraint that
|
|
* DOES bite on Linux is unrelated to pipe buffering: `execve(2)` enforces
|
|
* `MAX_ARG_STRLEN` (128KiB per single argv/envp string; see `man execve`
|
|
* NOTES) — so a CALLER that embeds a large literal payload directly into a
|
|
* spawned command line (e.g. `node -e "...<huge string>..."`) can fail to
|
|
* even start the child on Linux (macOS has no equivalent per-string cap),
|
|
* with no relation to this function's own behavior. See
|
|
* tests/cli-exit.test.cjs's "a large payload (bigger than a pipe buffer)
|
|
* arrives whole" test, which hit exactly this constructing its own fixture
|
|
* before being rewritten to build the payload inside the child instead.
|
|
*/
|
|
function terminateNow(outcome, payload, stderrPayload) {
|
|
// terminateNow is total by construction: its callers are enforcement hooks
|
|
// (P7/#3911, 19 of them) whose OWN outer catch may fail open (some end in
|
|
// `process.exit(0)`). If resolving the contract version, projecting the
|
|
// outcome, or the HOOK_DENY-collision guard below threw and that throw
|
|
// propagated out of this function, it would unwind straight into that
|
|
// caller's catch — turning a deny into a silent allow, exactly the defect
|
|
// ADR-3889 exists to close. So every one of those steps is wrapped here:
|
|
// on ANY failure this still terminates, deterministically, with INTERNAL
|
|
// (never by returning or re-throwing) — a malformed call is a programming
|
|
// error to be diagnosed on stderr, not a reason to hand control back.
|
|
let versionForDiagnostics = '(unresolved)';
|
|
try {
|
|
const version = getContractVersion();
|
|
versionForDiagnostics = version;
|
|
const projected = projectOutcome(outcome, version);
|
|
if (projected === HOOK_DENY_CODE && outcome !== HOOK_DENY_NAME) {
|
|
throw new Error(`terminateNow: exit code ${HOOK_DENY_CODE} is reserved to the ${HOOK_DENY_NAME} outcome; `
|
|
+ `got outcome ${JSON.stringify(outcome)}`);
|
|
}
|
|
// m2 (round 5, hooks/msd-write-guard.js:159-175): emission must itself be
|
|
// exception-safe. A failed write (EPIPE, a full pipe buffer, a throwing
|
|
// fs.writeSync in a test) must NOT change the exit code — if it propagated
|
|
// out of this function, a caller whose payload could not be delivered
|
|
// would fall into ITS OWN outer catch and fail OPEN, which is the exact
|
|
// outcome the fail-closed branches this function serves exist to prevent.
|
|
// The decision to terminate with `projected` stands regardless of whether
|
|
// the payload could be delivered.
|
|
//
|
|
// The two streams are emitted in TWO SEPARATE try/catch blocks, not one
|
|
// shared block (the pre-#3911 defect): fd 1 and fd 2 (deny-only) are
|
|
// independent channels with independent failure modes, and a shared try
|
|
// meant a serialization failure on fd 1 (e.g. `payload` throwing on
|
|
// JSON.stringify) aborted the block before fd 2 ever ran — silently
|
|
// dropping a deny's reason. `deny(undefined, 'some reason')` used to exit
|
|
// 2 with EMPTY stderr because of exactly this. Each block independently
|
|
// treats an `undefined` value for ITS OWN stream as "nothing to write"
|
|
// and skips the write cleanly, rather than serializing `undefined` (which
|
|
// is not valid JSON text) and throwing into the catch.
|
|
try {
|
|
// fs.writeSync, never process.stdout.write: pipe writes via
|
|
// process.stdout/stderr are async on Windows, and process.exit() below
|
|
// does not wait for them to flush — a truncated payload is a silent
|
|
// half-emission. Looped over a Buffer (not a bare string call) so a
|
|
// payload larger than the destination pipe's buffer — where a single
|
|
// write() syscall can legitimately return fewer bytes written than
|
|
// requested — still arrives whole rather than truncated.
|
|
if (payload !== undefined) {
|
|
const buf = Buffer.from(JSON.stringify(payload), 'utf8');
|
|
let offset = 0;
|
|
while (offset < buf.length) {
|
|
offset += node_fs_1.default.writeSync(1, buf, offset, buf.length - offset);
|
|
}
|
|
}
|
|
}
|
|
catch {
|
|
// fd 1 emission failed; the exit code decision still stands (see
|
|
// above), and fd 2 below is unaffected — it has its own try/catch.
|
|
}
|
|
if (projected === HOOK_DENY_CODE) {
|
|
try {
|
|
// Backward-compatible default: when no `stderrPayload` is given, fd 2
|
|
// gets the SAME value fd 1 got (still subject to fd 2's own
|
|
// undefined-skips-the-write and string-vs-JSON rules below).
|
|
const resolvedStderr = stderrPayload === undefined ? payload : stderrPayload;
|
|
if (resolvedStderr !== undefined) {
|
|
const stderrBuf = Buffer.from(typeof resolvedStderr === 'string' ? resolvedStderr : JSON.stringify(resolvedStderr), 'utf8');
|
|
let stderrOffset = 0;
|
|
while (stderrOffset < stderrBuf.length) {
|
|
stderrOffset += node_fs_1.default.writeSync(2, stderrBuf, stderrOffset, stderrBuf.length - stderrOffset);
|
|
}
|
|
}
|
|
}
|
|
catch {
|
|
// fd 2 emission failed; independent of fd 1 above, and the exit code
|
|
// decision still stands regardless.
|
|
}
|
|
}
|
|
// n/no-process-exit is not registered for src/**/*.cts (see the ADR-3889
|
|
// reference note in the module header) and both compiled .cjs copies of
|
|
// this module are lint-ignored build/generated artifacts, so no disable
|
|
// directive is needed here for the one sanctioned process.exit call site.
|
|
process.exit(projected);
|
|
}
|
|
catch (err) {
|
|
// Anything above threw: an unrecognized --exit-contract/MSD_EXIT_CONTRACT
|
|
// value, a non-string/empty/unregistered `outcome`, or the HOOK_DENY
|
|
// collision guard. Diagnose on stderr — swallowing this silently would
|
|
// make a typo'd outcome name or a bad contract-version env var
|
|
// undebuggable — then terminate unconditionally. The diagnostic write
|
|
// itself gets its own swallow-on-failure guard, because even a failed
|
|
// diagnostic must not stop the exit below from happening.
|
|
try {
|
|
const detail = err instanceof Error ? err.message : String(err);
|
|
const message = `terminateNow: programming error — outcome=${JSON.stringify(outcome)} `
|
|
+ `version=${JSON.stringify(versionForDiagnostics)}: ${detail}\n`
|
|
+ `This is a caller bug (unrecognized outcome/exit-contract, or the HOOK_DENY collision `
|
|
+ `guard), not a declared outcome. Terminating with INTERNAL rather than propagating: an `
|
|
+ `enforcement-hook caller's own outer catch may fail open (process.exit(0)), and unwinding `
|
|
+ `into it here would silently convert a deny into an allow.\n`;
|
|
node_fs_1.default.writeSync(2, message);
|
|
}
|
|
catch {
|
|
// Diagnostic emission itself failed; the exit below is unconditional
|
|
// regardless.
|
|
}
|
|
process.exit(exitCodeFor('INTERNAL'));
|
|
}
|
|
}
|
|
module.exports = {
|
|
ExitError,
|
|
runMain,
|
|
setJsonErrorMode,
|
|
getJsonErrorMode,
|
|
EXIT_ENVELOPE_REASON,
|
|
projectOutcome,
|
|
resolveContractVersion,
|
|
getContractVersion,
|
|
terminateNow,
|
|
setPendingOutcome,
|
|
getPendingOutcome,
|
|
};
|