* feat(#3906): two terminators over one registry, with a versioned projection Adds terminateNow (write-then-terminate, for callers that cannot wait for the event loop) beside runMain (drain-then-exit), both projecting through one shared function so they cannot disagree - the parity the ADR makes mandatory. A failed write does not change the exit code: letting it propagate would fail a hook open, which is what the fail-closed branches exist to prevent. The projection is versioned. v1 reproduces today's integers, including keeping a payload-carried degraded result at exit 0 - ADR-2980 ratified that across 60 sites and declined normalizing it on measured blast radius. v2 applies the registry. --exit-contract=v2 or GSD_EXIT_CONTRACT=v2 selects it; an unrecognized version throws rather than silently defaulting. The registry is now emitted beside both copies of the exit module, so it resolves as a sibling in the built tree and in the committed scripts/ copy that must load on an unbuilt clone. * fix(#3906): actually restrict code 2 to terminateNow, and generate the registry's type The claim that terminateNow is the only place 2 can be produced was false: runMain's outcome arm applied no guard, so runMain(()=>'HOOK_DENY') set exitCode 2 through the drain path - and the parity matrix demonstrated it while calling it parity. runMain now refuses any outcome projecting to the hook-protocol code, gated on the code rather than the name so an alias cannot slip past, and the matrix asserts the restriction instead of contradicting it. The ambient type for the generated registry was hand-written with no gate against the generator's actual output - the declared-surface-diverges-from-runtime defect class this epic exists to close, reintroduced inside it. It is now a third generated artifact covered by the same --check. Also converts every test-body try/finally to t.after(). * test(#3906): derive the glossary fixture's dependencies instead of hand-listing them Adding a require to scripts/lib/cli-exit.cjs broke 31 tests in one suite that built its fixture from a hand-written dependency list, so the new sibling was absent and the copied script could not load. copyScriptWithDeps walks the require graph and exists for exactly this class - #3412 paid the same bill when one new require broke 82 tests across two suites. Migrating rather than adding another copyFileSync line keeps the class closed. The other nine suites referencing that path were triaged; none copies-and-spawns, so none needed migrating. * fix(#3906): enumerate the new shipped file, drop a vendor name from shipped data, and fix three test defects install: scripts/lib/exit-code-registry.cjs was missing from GSD_SCRIPTS_LIB_FILES, so it shipped to every install and orphaned on uninstall. The registry gave HOOK_DENY a meaning naming one harness, and that string ships into every runtime's tree - a guard correctly caught it leaking into the hermes and qwen installs. The registry is runtime-neutral infrastructure; the vendor name belongs in the ADR, not in shipped data. Two more fixture harnesses built their trees from hand-listed dependencies and broke on the new require; both migrated to the derived helper, and all 23 copy-and-spawn candidates were enumerated so the class is closed rather than patched. One generator test used a fixture code that collided with a real allocation, so the generator correctly reported a duplicate where the test expected drift. The large-payload test embedded a 256KB literal in the child's argv, exceeding Linux's 128KiB MAX_ARG_STRLEN so the child never started - it now builds the payload inside the child. * chore(#3906): backfill changeset pr number * docs(#3906): document the exit-code contract selector P2 is the first phase of this epic with a user-invocable surface, so the flag and env var owe a reference entry. Records what actually differs between v1 and v2 today (one outcome), that an unrecognized value is rejected rather than silently defaulted, and the fail-safe property that makes switching safe. --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -14,13 +14,30 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
/**
|
||||
* Process-exit primitives (ExitError, runMain) plus the json-error-mode cell.
|
||||
* Must import nothing but `node:fs` — this source is emitted to TWO locations,
|
||||
* gsd-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.
|
||||
* 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, gsd-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.cjs");
|
||||
// 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
|
||||
@@ -50,6 +67,139 @@ function setJsonErrorMode(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('gsd.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 `gsd-tools` without
|
||||
* this module touching either (P8 owns `gsd-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 `GSD_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);
|
||||
}
|
||||
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
|
||||
* `GSD_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 `GSD_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.GSD_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
|
||||
@@ -68,17 +218,48 @@ class ExitError extends Error {
|
||||
/**
|
||||
* 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). main may be sync or async:
|
||||
* number return -> process.exitCode = it
|
||||
* thrown ExitError -> process.exitCode = err.code (+ stderr err.message if hasUserMessage && code!=0)
|
||||
* 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 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).
|
||||
* 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.
|
||||
* to stderr; otherwise writes raw stack trace. exit code = 1 in either case. (unchanged)
|
||||
*/
|
||||
function runMain(main) {
|
||||
Promise.resolve()
|
||||
.then(() => main())
|
||||
.then((code) => { if (typeof code === 'number')
|
||||
process.exitCode = code; })
|
||||
.then((result) => {
|
||||
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;
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err instanceof ExitError) {
|
||||
if (err.hasUserMessage && err.code !== 0)
|
||||
@@ -102,4 +283,138 @@ function runMain(main) {
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
module.exports = { ExitError, runMain, setJsonErrorMode, getJsonErrorMode, EXIT_ENVELOPE_REASON };
|
||||
/**
|
||||
* 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,
|
||||
* fd 2 too — Kimi's native hook bus feeds stderr, not stdout, back to the
|
||||
* model on exit 2, per hooks/gsd-write-guard.js's emitBlock).
|
||||
*
|
||||
* 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) {
|
||||
// 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/gsd-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.
|
||||
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.
|
||||
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);
|
||||
}
|
||||
if (projected === HOOK_DENY_CODE) {
|
||||
let stderrOffset = 0;
|
||||
while (stderrOffset < buf.length) {
|
||||
stderrOffset += node_fs_1.default.writeSync(2, buf, stderrOffset, buf.length - stderrOffset);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Emission failed; the exit code decision still stands (see above).
|
||||
}
|
||||
// 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/GSD_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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user