// 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. gsd-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 (gsd-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, 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.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 gsd-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 * (gsd-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('gsd.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('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=` 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=` 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 * 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 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. (unchanged) */ function runMain(main) { Promise.resolve() .then(() => main()) .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) 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/gsd-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 "......"`) 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/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. // // 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/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, };