diff --git a/.changeset/graceful-jaguars-wave.md b/.changeset/graceful-jaguars-wave.md new file mode 100644 index 000000000..4f11d4005 --- /dev/null +++ b/.changeset/graceful-jaguars-wave.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 3924 +--- +**A second terminator for code that cannot wait for the event loop, and a versioned exit-code projection** — hooks and other write-then-exit callers can now terminate through the same registry lookup that `runMain` uses, so both agree on what every outcome means. Exit integers are versioned: today's behavior is `v1`, and `--exit-contract=v2` (or `GSD_EXIT_CONTRACT=v2`) opts into the registry's codes ahead of the next major. (#3906) diff --git a/bin/install.js b/bin/install.js index 9f73e8467..419e5a409 100755 --- a/bin/install.js +++ b/bin/install.js @@ -411,7 +411,7 @@ const GSD_CHANGESET_FILES = [ 'github-release-notes.cjs', 'lint.cjs', 'new.cjs', 'README.md', // documentation only — not user-authored ]; -const GSD_SCRIPTS_LIB_FILES = ['cli-exit.cjs', 'allowlist-ratchet.cjs', 'drift-scan.cjs', 'alias-drift-families.cjs']; +const GSD_SCRIPTS_LIB_FILES = ['cli-exit.cjs', 'allowlist-ratchet.cjs', 'drift-scan.cjs', 'alias-drift-families.cjs', 'exit-code-registry.cjs']; /** * Resolve a runtime's shared-hooks directory name from its descriptor. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index ad7441299..51c452220 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -2005,9 +2005,48 @@ Use `provider: "generic"` (or `"custom"`) for OpenRouter, LiteLLM, local gateway | `GSD_AUDIT_ARGS` | Set to `1` to include command args in audit/error events (omitted by default) | | `GSD_PROJECT` | Override project root for multi-project workspace support (v1.32) | | `GSD_SKIP_SCHEMA_CHECK` | Skip schema drift detection during execute-phase (v1.31) | +| `GSD_EXIT_CONTRACT` | Select the exit-code projection: `v1` (default) or `v2`. See [Exit-code contract](#exit-code-contract-gsd_exit_contract) below. | | `GSD_ALLOW_SYMLINKED_DEST` | Set to `1` (or `true`) to permit install/update when `CLAUDE_CONFIG_DIR` (or any artifact-kind child like `skills/`, `hooks/`) is an **intentional, user-owned symlink** pointing outside the install root. v1.7.x write-confinement (ADR-1239 Phase B) refuses such layouts by default to prevent untrusted `destSubpath` traversal. Opt in only if you manage configHome via symlinked external dirs, multi-account config layouts (`~/.claude-personal`, `~/.claude-team`), or dotfiles-managed configHome (nix-darwin, etc.). Two refusals remain load-bearing even with opt-in: path-traversal in `destSubpath` (`../../etc`-style), and a symlink whose resolved target equals the install root itself (would let the prune pass wipe it). | | `WSL_DISTRO_NAME` | Detected by installer for WSL path handling | +### Exit-code contract (`GSD_EXIT_CONTRACT`) + +Which integers GSD's commands exit with is **versioned**, so the meanings can be +sharpened without breaking callers that already depend on today's numbers. + +| Version | Behavior | +|---|---| +| `v1` | **Default.** Today's exit codes, unchanged. | +| `v2` | Codes come from the exit-code registry. | + +Select `v2` either way — the flag wins when both are given: + +```bash +GSD_EXIT_CONTRACT=v2 gsd-tools +gsd-tools --exit-contract=v2 +``` + +An unrecognized value is **rejected**, not silently treated as `v1`. That is +deliberate: a selector that quietly ignores what you asked for is the failure +mode this contract exists to remove. + +**What actually differs today.** Only one outcome: a command that ran to +completion and is reporting a condition **through its result payload** rather +than as a process failure. Under `v1` that exits `0` — a long-standing +contract across ~60 call sites, documented in +[`json-errors.md`](json-errors.md), where a caller detects the condition by +inspecting the payload rather than the exit code. Under `v2` it exits a +registered non-zero code instead. Pass or fail, and every other registered +outcome, are identical under both. + +Every registered code is non-zero, so a caller written `if ! cmd; then` behaves +the same for success under either version and trips for everything else. +Switching to `v2` can turn a false green red; it cannot turn a red green. + +`v2` is opt-in now and becomes the default at the next major version. Rationale +and the full band allocation are in +[ADR-3889](adr/3889-process-exit-contract.md). + --- ## Global Defaults diff --git a/gsd-core/bin/lib/exit-code-registry.cjs b/gsd-core/bin/lib/exit-code-registry.cjs index b0247b193..51af7339a 100644 --- a/gsd-core/bin/lib/exit-code-registry.cjs +++ b/gsd-core/bin/lib/exit-code-registry.cjs @@ -3,7 +3,10 @@ // GENERATED FILE — DO NOT EDIT BY HAND. // Source of truth: gsd-core/bin/shared/exit-codes.json. Regenerate with: // node scripts/gen-exit-code-registry.cjs --write -// Byte-compared by `npm run lint:generated-sync` (#3905, ADR-3889 Phase 1). +// This exact content is emitted to TWO locations — gsd-core/bin/lib/exit-code-registry.cjs +// and scripts/lib/exit-code-registry.cjs (the latter committed so scripts/ +// consumers work on an unbuilt clone) — both byte-compared by +// `npm run lint:generated-sync` (#3905 ADR-3889 Phase 1; #3906 Phase 2 added the second copy). // // exitCodeFor(name) / nameForExitCode(code) are pure and total over this // closed table — each throws for anything not registered here. @@ -12,7 +15,7 @@ const EXIT_CODES = Object.freeze([ Object.freeze({ code: 2, name: "HOOK_DENY", - meaning: "Claude Code hook protocol — deny the tool call", + meaning: "Hook protocol deny — the harness blocks the tool call", owner: "hook-adapter", authorizedBy: "ADR-3889", }), @@ -43,6 +46,13 @@ const EXIT_CODES = Object.freeze([ meaning: "Self-failure — crash, timeout, killed subprocess", owner: "generic", authorizedBy: "ADR-3889", + }), + Object.freeze({ + code: 80, + name: "DEGRADED", + meaning: "Ran to completion and is reporting a condition through its result payload rather than as a process failure", + owner: "gsd-tools", + authorizedBy: "ADR-3889 + ADR-2980", }) ]); diff --git a/gsd-core/bin/shared/exit-codes.json b/gsd-core/bin/shared/exit-codes.json index 279fe4dff..a8207c77d 100644 --- a/gsd-core/bin/shared/exit-codes.json +++ b/gsd-core/bin/shared/exit-codes.json @@ -1,7 +1,8 @@ [ - { "code": 2, "name": "HOOK_DENY", "meaning": "Claude Code hook protocol — deny the tool call", "owner": "hook-adapter", "authorizedBy": "ADR-3889" }, + { "code": 2, "name": "HOOK_DENY", "meaning": "Hook protocol deny — the harness blocks the tool call", "owner": "hook-adapter", "authorizedBy": "ADR-3889" }, { "code": 64, "name": "USAGE", "meaning": "Caller error — bad argv, unknown subcommand, missing argument", "owner": "generic", "authorizedBy": "ADR-3889" }, { "code": 66, "name": "NO_INPUT", "meaning": "Ran; zero units were in scope, and that emptiness is known to be genuine", "owner": "generic", "authorizedBy": "ADR-3889" }, { "code": 69, "name": "UNAVAILABLE", "meaning": "Could not run — prerequisite absent, input unreadable, scope unestablished", "owner": "generic", "authorizedBy": "ADR-3889" }, - { "code": 70, "name": "INTERNAL", "meaning": "Self-failure — crash, timeout, killed subprocess", "owner": "generic", "authorizedBy": "ADR-3889" } + { "code": 70, "name": "INTERNAL", "meaning": "Self-failure — crash, timeout, killed subprocess", "owner": "generic", "authorizedBy": "ADR-3889" }, + { "code": 80, "name": "DEGRADED", "meaning": "Ran to completion and is reporting a condition through its result payload rather than as a process failure", "owner": "gsd-tools", "authorizedBy": "ADR-3889 + ADR-2980" } ] diff --git a/scripts/gen-exit-code-registry.cjs b/scripts/gen-exit-code-registry.cjs index c3c2cdfc0..0f7782c06 100644 --- a/scripts/gen-exit-code-registry.cjs +++ b/scripts/gen-exit-code-registry.cjs @@ -1,23 +1,39 @@ #!/usr/bin/env node /** - * gen-exit-code-registry.cjs — generates gsd-core/bin/lib/exit-code-registry.cjs - * from the declaration at gsd-core/bin/shared/exit-codes.json. + * gen-exit-code-registry.cjs — generates THREE byte-identical/derived + * artifacts from the declaration at gsd-core/bin/shared/exit-codes.json: + * - gsd-core/bin/lib/exit-code-registry.cjs (tsc-adjacent build tree) + * - scripts/lib/exit-code-registry.cjs (committed, for scripts/ + * consumers that must work on an unbuilt clone — same reason + * scripts/lib/cli-exit.cjs exists alongside gsd-core/bin/lib/cli-exit.cjs; + * see scripts/gen-scripts-cli-exit.cjs). + * - src/exit-code-registry.d.cts (the ambient type declaration + * tsc uses to typecheck src/cli-exit.cts's `require('./exit-code-registry.cjs')` + * against the shape the .cjs artifacts above actually export — generated + * from the SAME ENTRY_FIELD_TYPES table serializeRegistry() uses, so the + * two can never independently drift). * * ADR-3889 ("One exit-code registry — 0 and 1 are free, everything else is - * allocated") Phase 1 (#3905): this script builds the ALLOCATOR. It owns - * validating the declaration (band rules, one-number-one-meaning, one-owner) - * and hand-serializing the generated lookup module — the same - * declaration -> generator -> `--check` gate pattern this repo already uses - * for capability-registry.cjs, the model catalog, and the ADR index. + * allocated") Phase 1 (#3905) built the single-output allocator; Phase 2 + * (#3906) added the second .cjs emission so scripts/ has its own committed + * copy instead of reaching into gitignored build output, and a follow-up + * closed the review finding that the .d.cts was hand-maintained with no gate + * by generating it here too. + * + * The two .cjs artifacts are byte-identical: serializeRegistry() only encodes + * the DECLARATION path (for the banner comment), never the output path, so + * one generated string is written to both locations unchanged. The .d.cts is + * a separate, smaller derivation (a structural type, not a per-entry table) + * but is generated and --check-gated exactly the same way. * * Nothing in this script emits a registered exit code itself; wiring - * consumers onto the registry is a later phase (#3906). + * consumers onto the registry is separate work. * * Usage: * node scripts/gen-exit-code-registry.cjs # same as --write - * node scripts/gen-exit-code-registry.cjs --write # write the artifact - * node scripts/gen-exit-code-registry.cjs --check # exit 1 if the committed artifact is stale - * node scripts/gen-exit-code-registry.cjs --declaration --out # override for tests + * node scripts/gen-exit-code-registry.cjs --write # write all three artifacts + * node scripts/gen-exit-code-registry.cjs --check # exit 1 if ANY committed artifact is stale + * node scripts/gen-exit-code-registry.cjs --declaration --out --scripts-out --dts-out # override for tests * node scripts/gen-exit-code-registry.cjs --json # emit ONE JSON report on stdout instead of human prose */ @@ -29,6 +45,24 @@ const path = require('node:path'); const REPO_ROOT = path.resolve(__dirname, '..'); const DEFAULT_DECLARATION_PATH = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared', 'exit-codes.json'); const DEFAULT_OUTPUT_PATH = path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'exit-code-registry.cjs'); +const DEFAULT_SCRIPTS_OUTPUT_PATH = path.join(REPO_ROOT, 'scripts', 'lib', 'exit-code-registry.cjs'); +const DEFAULT_DTS_OUTPUT_PATH = path.join(REPO_ROOT, 'src', 'exit-code-registry.d.cts'); + +/** + * Single source of the entry field list (name -> TS type), in emission order. + * serializeRegistry()'s per-entry object literal and serializeDts()'s + * ExitCodeEntry interface are BOTH derived from this one table, so the two + * artifacts cannot independently drift out of shape with each other — closing + * the review finding that the ambient .d.cts was a hand-maintained guess at + * what serializeRegistry() emits. + */ +const ENTRY_FIELD_TYPES = Object.freeze({ + code: 'number', + name: 'string', + meaning: 'string', + owner: 'string', + authorizedBy: 'string', +}); /** Frozen reason codes so tests assert on structure, not prose. */ const REASON = Object.freeze({ @@ -48,12 +82,14 @@ const REASON = Object.freeze({ }); const USAGE_MESSAGE = [ - 'Usage: node scripts/gen-exit-code-registry.cjs [--write|--check] [--declaration ] [--out ] [--json]', + 'Usage: node scripts/gen-exit-code-registry.cjs [--write|--check] [--declaration ] [--out ] [--scripts-out ] [--dts-out ] [--json]', ' (no flag) same as --write', - ' --write write the generated registry artifact', - ' --check exit 1 if the committed artifact is stale', + ' --write write all three generated registry artifacts', + ' --check exit 1 if ANY committed artifact is stale', ' --declaration override the declaration path (default: gsd-core/bin/shared/exit-codes.json)', - ' --out override the output artifact path (default: gsd-core/bin/lib/exit-code-registry.cjs)', + ' --out override the primary output artifact path (default: gsd-core/bin/lib/exit-code-registry.cjs)', + ' --scripts-out override the secondary output artifact path (default: scripts/lib/exit-code-registry.cjs)', + ' --dts-out override the ambient type declaration path (default: src/exit-code-registry.d.cts)', ' --json emit ONE JSON report ({ok, reason, context, detail?}) on stdout instead of human-readable prose', ].join('\n'); @@ -274,21 +310,20 @@ function serializeRegistry(entries, declarationPath) { '// GENERATED FILE — DO NOT EDIT BY HAND.', `// Source of truth: ${relDeclaration}. Regenerate with:`, '// node scripts/gen-exit-code-registry.cjs --write', - '// Byte-compared by `npm run lint:generated-sync` (#3905, ADR-3889 Phase 1).', + '// This exact content is emitted to TWO locations — gsd-core/bin/lib/exit-code-registry.cjs', + '// and scripts/lib/exit-code-registry.cjs (the latter committed so scripts/', + '// consumers work on an unbuilt clone) — both byte-compared by', + '// `npm run lint:generated-sync` (#3905 ADR-3889 Phase 1; #3906 Phase 2 added the second copy).', '//', '// exitCodeFor(name) / nameForExitCode(code) are pure and total over this', '// closed table — each throws for anything not registered here.', '', ].join('\n'); + const entryFieldNames = Object.keys(ENTRY_FIELD_TYPES); const entryLiterals = entries.map((e) => { - return ' Object.freeze({\n' - + ` code: ${JSON.stringify(e.code)},\n` - + ` name: ${JSON.stringify(e.name)},\n` - + ` meaning: ${JSON.stringify(e.meaning)},\n` - + ` owner: ${JSON.stringify(e.owner)},\n` - + ` authorizedBy: ${JSON.stringify(e.authorizedBy)},\n` - + ' })'; + const fieldLines = entryFieldNames.map((field) => ` ${field}: ${JSON.stringify(e[field])},`).join('\n'); + return ` Object.freeze({\n${fieldLines}\n })`; }).join(',\n'); const body = [ @@ -341,6 +376,65 @@ function serializeRegistry(entries, declarationPath) { return banner + '\n' + body; } +/** + * Generate the ambient type declaration for the generated .cjs registry + * artifacts. Derived from the SAME ENTRY_FIELD_TYPES table serializeRegistry() + * iterates for its per-entry object literals, and from serializeRegistry()'s + * own fixed `module.exports = { EXIT_CODES, exitCodeFor, nameForExitCode }` + * shape — so this declaration cannot drift out of step with what the sibling + * .cjs artifacts actually export without both call sites being edited + * together. Structural only (no per-entry data): the type is the same + * regardless of how many rows the declaration has. + */ +function serializeDts(declarationPath) { + const relDeclaration = path.relative(REPO_ROOT, declarationPath).split(path.sep).join('/'); + const fieldLines = Object.entries(ENTRY_FIELD_TYPES) + .map(([field, type]) => ` readonly ${field}: ${type};`) + .join('\n'); + + return [ + '// GENERATED FILE — DO NOT EDIT BY HAND.', + `// Source of truth: ${relDeclaration} + the ENTRY_FIELD_TYPES table in`, + '// scripts/gen-exit-code-registry.cjs. Regenerate with:', + '// node scripts/gen-exit-code-registry.cjs --write', + '//', + '// Ambient type declaration for exit-code-registry.cjs — a GENERATED,', + '// committed artifact with no `.cts` source of its own (it is hand-serialized', + '// from gsd-core/bin/shared/exit-codes.json by scripts/gen-exit-code-registry.cjs,', + '// ADR-3889 §2, #3905/#3906), so tsc has nothing to compile for it. This file', + "// exists purely so `src/cli-exit.cts`'s `require('./exit-code-registry.cjs')`", + '// type-checks against the SAME shape the generated artifact actually exports', + '// at runtime — mirroring the src/vendor/*.d.cts pattern already used for', + '// other verbatim/generated JS this tree resolves types for without compiling.', + '//', + '// This declaration is generated from the same ENTRY_FIELD_TYPES table', + "// serializeRegistry()'s per-entry object literals iterate, and is", + '// byte-compared by `node scripts/gen-exit-code-registry.cjs --check`', + '// (the same check that already covers the two sibling .cjs artifacts) so a', + '// shape drift here fails the build instead of surfacing at a destructuring', + '// call site.', + '', + 'export interface ExitCodeEntry {', + fieldLines, + '}', + '', + 'declare const exitCodeRegistry: {', + ' readonly EXIT_CODES: readonly ExitCodeEntry[];', + ' // Property-typed function signatures (`name: (args) => ret`), NOT method', + ' // shorthand (`name(args): ret`) — the latter is a TS "method" and trips', + ' // @typescript-eslint/unbound-method at every destructuring call site', + ' // (`const { exitCodeFor } = ...`), since a method may implicitly use', + ' // `this`. These are pure functions that never do, so they are typed as', + ' // plain function-valued properties instead.', + ' exitCodeFor: (name: string) => number;', + ' nameForExitCode: (code: number) => string;', + '};', + '', + 'export = exitCodeRegistry;', + '', + ].join('\n'); +} + /** * Load, validate, and serialize the declaration in one step. * @returns {{ok:true,content:string}|{ok:false,reason:string,message:string}} @@ -393,61 +487,106 @@ function emitOk(reason, humanMessage, json) { console.log(humanMessage); } -function doWrite(declarationPath, outPath, json) { +function doWrite(declarationPath, outPath, scriptsOutPath, dtsPath, json) { const result = buildRegistryContent(declarationPath); if (!result.ok) { emitFail(result, json); return 1; } - fs.mkdirSync(path.dirname(outPath), { recursive: true }); - fs.writeFileSync(outPath, result.content, 'utf8'); - emitOk(REASON.OK, `ok gen-exit-code-registry: wrote ${outPath}`, json); - return 0; -} - -function doCheck(declarationPath, outPath, json) { - const result = buildRegistryContent(declarationPath); - if (!result.ok) { - emitFail(result, json); - return 1; + // The two .cjs artifacts are byte-identical copies of the same generated + // content (serializeRegistry never encodes the output path), so the same + // string is written to both locations unchanged. + for (const target of [outPath, scriptsOutPath]) { + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, result.content, 'utf8'); } - - if (!fs.existsSync(outPath)) { - emitFail( - { - reason: REASON.MISSING_ARTIFACT, - message: `${outPath} does not exist. Run:\n node scripts/gen-exit-code-registry.cjs --write`, - }, - json, - ); - return 1; - } - - const committed = fs.readFileSync(outPath, 'utf8'); - if (committed !== result.content) { - emitFail( - { - reason: REASON.DRIFTED, - message: - `${outPath} (${committed.length} bytes) != freshly generated content (${result.content.length} bytes)\n\n` - + 'Regenerate with:\n node scripts/gen-exit-code-registry.cjs --write', - }, - json, - ); - return 1; - } - - emitOk(REASON.OK, `ok gen-exit-code-registry: ${outPath} matches ${declarationPath}`, json); + const dtsContent = serializeDts(declarationPath); + fs.mkdirSync(path.dirname(dtsPath), { recursive: true }); + fs.writeFileSync(dtsPath, dtsContent, 'utf8'); + emitOk( + REASON.OK, + `ok gen-exit-code-registry: wrote ${outPath}\nok gen-exit-code-registry: wrote ${scriptsOutPath}\n` + + `ok gen-exit-code-registry: wrote ${dtsPath}`, + json, + ); return 0; } /** - * @returns {{mode:'write'|'check', declarationPath:?string, outPath:?string, json:boolean}} + * Verify one committed artifact against the freshly generated content. + * @returns {{ok:true}|{ok:false,reason:string,message:string,context:object}} + */ +function checkOneArtifact(artifactLabel, artifactPath, content) { + if (!fs.existsSync(artifactPath)) { + return { + ok: false, + reason: REASON.MISSING_ARTIFACT, + message: `${artifactPath} (${artifactLabel}) does not exist. Run:\n node scripts/gen-exit-code-registry.cjs --write`, + context: { artifact: artifactLabel, path: artifactPath }, + }; + } + + const committed = fs.readFileSync(artifactPath, 'utf8'); + if (committed !== content) { + return { + ok: false, + reason: REASON.DRIFTED, + message: + `${artifactPath} (${artifactLabel}, ${committed.length} bytes) != freshly generated content (${content.length} bytes)\n\n` + + 'Regenerate with:\n node scripts/gen-exit-code-registry.cjs --write', + context: { artifact: artifactLabel, path: artifactPath }, + }; + } + + return { ok: true }; +} + +/** + * --check verifies ALL THREE committed artifacts against the same freshly + * generated content and fails naming which one drifted (or is missing) if + * any does. Checked in a fixed order (primary, secondary, dts) so a + * single-artifact failure is always reported deterministically. + */ +function doCheck(declarationPath, outPath, scriptsOutPath, dtsPath, json) { + const result = buildRegistryContent(declarationPath); + if (!result.ok) { + emitFail(result, json); + return 1; + } + const dtsContent = serializeDts(declarationPath); + + const artifacts = [ + ['primary', outPath, result.content], + ['secondary', scriptsOutPath, result.content], + ['dts', dtsPath, dtsContent], + ]; + for (const [artifactLabel, artifactPath, content] of artifacts) { + const checked = checkOneArtifact(artifactLabel, artifactPath, content); + if (!checked.ok) { + emitFail(checked, json); + return 1; + } + } + + emitOk( + REASON.OK, + `ok gen-exit-code-registry: ${outPath} matches ${declarationPath}\n` + + `ok gen-exit-code-registry: ${scriptsOutPath} matches ${declarationPath}\n` + + `ok gen-exit-code-registry: ${dtsPath} matches ${declarationPath}`, + json, + ); + return 0; +} + +/** + * @returns {{mode:'write'|'check', declarationPath:?string, outPath:?string, scriptsOutPath:?string, dtsPath:?string, json:boolean}} */ function parseArgs(argv) { let mode = null; let declarationPath = null; let outPath = null; + let scriptsOutPath = null; + let dtsPath = null; let json = false; for (let i = 0; i < argv.length; i++) { @@ -471,12 +610,24 @@ function parseArgs(argv) { outPath = value; } else if (arg.startsWith('--out=')) { outPath = arg.slice('--out='.length); + } else if (arg === '--scripts-out') { + const value = argv[++i]; + if (value === undefined) throw new Error('--scripts-out requires a value'); + scriptsOutPath = value; + } else if (arg.startsWith('--scripts-out=')) { + scriptsOutPath = arg.slice('--scripts-out='.length); + } else if (arg === '--dts-out') { + const value = argv[++i]; + if (value === undefined) throw new Error('--dts-out requires a value'); + dtsPath = value; + } else if (arg.startsWith('--dts-out=')) { + dtsPath = arg.slice('--dts-out='.length); } else { throw new Error(`unrecognized argument: ${arg}`); } } - return { mode: mode || 'write', declarationPath, outPath, json }; + return { mode: mode || 'write', declarationPath, outPath, scriptsOutPath, dtsPath, json }; } function main() { @@ -496,8 +647,12 @@ function main() { const declarationPath = args.declarationPath || DEFAULT_DECLARATION_PATH; const outPath = args.outPath || DEFAULT_OUTPUT_PATH; + const scriptsOutPath = args.scriptsOutPath || DEFAULT_SCRIPTS_OUTPUT_PATH; + const dtsPath = args.dtsPath || DEFAULT_DTS_OUTPUT_PATH; - return args.mode === 'check' ? doCheck(declarationPath, outPath, args.json) : doWrite(declarationPath, outPath, args.json); + return args.mode === 'check' + ? doCheck(declarationPath, outPath, scriptsOutPath, dtsPath, args.json) + : doWrite(declarationPath, outPath, scriptsOutPath, dtsPath, args.json); } if (require.main === module) process.exitCode = main(); @@ -507,12 +662,16 @@ module.exports = { USAGE_MESSAGE, DEFAULT_DECLARATION_PATH, DEFAULT_OUTPUT_PATH, + DEFAULT_SCRIPTS_OUTPUT_PATH, + DEFAULT_DTS_OUTPUT_PATH, + ENTRY_FIELD_TYPES, isAllocatableCode, bandFor, validateEntry, validateEntries, loadDeclaration, serializeRegistry, + serializeDts, buildRegistryContent, parseArgs, main, diff --git a/scripts/lib/cli-exit.cjs b/scripts/lib/cli-exit.cjs index 321756016..23adae181 100644 --- a/scripts/lib/cli-exit.cjs +++ b/scripts/lib/cli-exit.cjs @@ -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=` 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 @@ -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 "......"`) 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, +}; diff --git a/scripts/lib/exit-code-registry.cjs b/scripts/lib/exit-code-registry.cjs new file mode 100644 index 000000000..51af7339a --- /dev/null +++ b/scripts/lib/exit-code-registry.cjs @@ -0,0 +1,97 @@ +'use strict'; + +// GENERATED FILE — DO NOT EDIT BY HAND. +// Source of truth: gsd-core/bin/shared/exit-codes.json. Regenerate with: +// node scripts/gen-exit-code-registry.cjs --write +// This exact content is emitted to TWO locations — gsd-core/bin/lib/exit-code-registry.cjs +// and scripts/lib/exit-code-registry.cjs (the latter committed so scripts/ +// consumers work on an unbuilt clone) — both byte-compared by +// `npm run lint:generated-sync` (#3905 ADR-3889 Phase 1; #3906 Phase 2 added the second copy). +// +// exitCodeFor(name) / nameForExitCode(code) are pure and total over this +// closed table — each throws for anything not registered here. + +const EXIT_CODES = Object.freeze([ + Object.freeze({ + code: 2, + name: "HOOK_DENY", + meaning: "Hook protocol deny — the harness blocks the tool call", + owner: "hook-adapter", + authorizedBy: "ADR-3889", + }), + Object.freeze({ + code: 64, + name: "USAGE", + meaning: "Caller error — bad argv, unknown subcommand, missing argument", + owner: "generic", + authorizedBy: "ADR-3889", + }), + Object.freeze({ + code: 66, + name: "NO_INPUT", + meaning: "Ran; zero units were in scope, and that emptiness is known to be genuine", + owner: "generic", + authorizedBy: "ADR-3889", + }), + Object.freeze({ + code: 69, + name: "UNAVAILABLE", + meaning: "Could not run — prerequisite absent, input unreadable, scope unestablished", + owner: "generic", + authorizedBy: "ADR-3889", + }), + Object.freeze({ + code: 70, + name: "INTERNAL", + meaning: "Self-failure — crash, timeout, killed subprocess", + owner: "generic", + authorizedBy: "ADR-3889", + }), + Object.freeze({ + code: 80, + name: "DEGRADED", + meaning: "Ran to completion and is reporting a condition through its result payload rather than as a process failure", + owner: "gsd-tools", + authorizedBy: "ADR-3889 + ADR-2980", + }) +]); + +const NAME_TO_CODE = new Map(EXIT_CODES.map((entry) => [entry.name, entry.code])); +const CODE_TO_NAME = new Map(EXIT_CODES.map((entry) => [entry.code, entry.name])); + +/** + * Resolve the registered exit code for a symbolic name. Pure, total: throws + * for anything not an exact, registered, exact-case key — including + * non-strings, the empty string, untrimmed strings, wrong case, and + * prototype-chain names like `__proto__`/`constructor`/`toString` (a Map + * lookup never touches the prototype chain, so these are indistinguishable + * from any other unregistered name). + * + * @param {string} name + * @returns {number} + */ +function exitCodeFor(name) { + if (typeof name !== 'string' || name.length === 0) { + throw new Error(`exitCodeFor: name must be a non-empty string, received ${JSON.stringify(name)}`); + } + if (!NAME_TO_CODE.has(name)) { + throw new Error(`exitCodeFor: unregistered exit code name: ${JSON.stringify(name)}`); + } + return NAME_TO_CODE.get(name); +} + +/** + * Reverse of exitCodeFor: resolve the symbolic name for a registered exit + * code. Pure, total: throws for anything not an exact, registered code. + * + * @param {number} code + * @returns {string} + */ +function nameForExitCode(code) { + if (!CODE_TO_NAME.has(code)) { + throw new Error(`nameForExitCode: unregistered exit code: ${JSON.stringify(code)}`); + } + return CODE_TO_NAME.get(code); +} + +module.exports = { EXIT_CODES, exitCodeFor, nameForExitCode }; diff --git a/src/cli-exit.cts b/src/cli-exit.cts index 8ccf2a51f..b2b2919d4 100644 --- a/src/cli-exit.cts +++ b/src/cli-exit.cts @@ -1,11 +1,28 @@ /** - * 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. */ import fs from 'node:fs'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import 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: string): number => exitCodeRegistryModule.exitCodeFor(name); /** * The wire value `runMain` stamps into its structured envelope. Declared HERE, @@ -40,6 +57,149 @@ function getJsonErrorMode(): boolean { return (globalThis as unknown as Record)[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); + +type ContractVersion = 'v1' | 'v2'; + +/** + * 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: ContractVersion): void { + (globalThis as unknown as Record)[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(): ContractVersion { + const cached = (globalThis as unknown as Record)[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: unknown, version: unknown): number { + 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: readonly string[]): string | undefined { + 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: { argv?: readonly string[]; env?: NodeJS.ProcessEnv } = {}): ContractVersion { + 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: ContractVersion; + 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 @@ -59,16 +219,47 @@ 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: () => number | void | Promise): void { +function runMain(main: () => number | string | void | Promise): void { 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: unknown) => { if (err instanceof ExitError) { if (err.hasUserMessage && err.code !== 0) process.stderr.write(`${err.message}\n`); @@ -91,4 +282,141 @@ function runMain(main: () => number | void | Promise): void { }); } -export = { 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 "......"`) 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: string, payload: unknown): never { + // 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 += fs.writeSync(1, buf, offset, buf.length - offset); + } + if (projected === HOOK_DENY_CODE) { + let stderrOffset = 0; + while (stderrOffset < buf.length) { + stderrOffset += fs.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`; + fs.writeSync(2, message); + } catch { + // Diagnostic emission itself failed; the exit below is unconditional + // regardless. + } + process.exit(exitCodeFor('INTERNAL')); + } +} + +export = { + ExitError, + runMain, + setJsonErrorMode, + getJsonErrorMode, + EXIT_ENVELOPE_REASON, + projectOutcome, + resolveContractVersion, + getContractVersion, + terminateNow, +}; diff --git a/src/exit-code-registry.d.cts b/src/exit-code-registry.d.cts new file mode 100644 index 000000000..3591f7010 --- /dev/null +++ b/src/exit-code-registry.d.cts @@ -0,0 +1,42 @@ +// GENERATED FILE — DO NOT EDIT BY HAND. +// Source of truth: gsd-core/bin/shared/exit-codes.json + the ENTRY_FIELD_TYPES table in +// scripts/gen-exit-code-registry.cjs. Regenerate with: +// node scripts/gen-exit-code-registry.cjs --write +// +// Ambient type declaration for exit-code-registry.cjs — a GENERATED, +// committed artifact with no `.cts` source of its own (it is hand-serialized +// from gsd-core/bin/shared/exit-codes.json by scripts/gen-exit-code-registry.cjs, +// ADR-3889 §2, #3905/#3906), so tsc has nothing to compile for it. This file +// exists purely so `src/cli-exit.cts`'s `require('./exit-code-registry.cjs')` +// type-checks against the SAME shape the generated artifact actually exports +// at runtime — mirroring the src/vendor/*.d.cts pattern already used for +// other verbatim/generated JS this tree resolves types for without compiling. +// +// This declaration is generated from the same ENTRY_FIELD_TYPES table +// serializeRegistry()'s per-entry object literals iterate, and is +// byte-compared by `node scripts/gen-exit-code-registry.cjs --check` +// (the same check that already covers the two sibling .cjs artifacts) so a +// shape drift here fails the build instead of surfacing at a destructuring +// call site. + +export interface ExitCodeEntry { + readonly code: number; + readonly name: string; + readonly meaning: string; + readonly owner: string; + readonly authorizedBy: string; +} + +declare const exitCodeRegistry: { + readonly EXIT_CODES: readonly ExitCodeEntry[]; + // Property-typed function signatures (`name: (args) => ret`), NOT method + // shorthand (`name(args): ret`) — the latter is a TS "method" and trips + // @typescript-eslint/unbound-method at every destructuring call site + // (`const { exitCodeFor } = ...`), since a method may implicitly use + // `this`. These are pure functions that never do, so they are typed as + // plain function-valued properties instead. + exitCodeFor: (name: string) => number; + nameForExitCode: (code: number) => string; +}; + +export = exitCodeRegistry; diff --git a/tests/check-glossary-refs.test.cjs b/tests/check-glossary-refs.test.cjs index ebb2aae8c..fdf586419 100644 --- a/tests/check-glossary-refs.test.cjs +++ b/tests/check-glossary-refs.test.cjs @@ -16,6 +16,7 @@ const path = require('node:path'); const { spawnSync } = require('node:child_process'); const { createTempDir, cleanup } = require('./helpers.cjs'); +const { copyScriptWithDeps } = require('./helpers/copy-script-fixture.cjs'); const REPO_ROOT = path.resolve(__dirname, '..'); const SCRIPT_REL = path.join('scripts', 'check-glossary-refs.cjs'); @@ -34,7 +35,10 @@ function allRuntimesSentence(count, members) { /** * Build a throwaway repo containing exactly what the gate reads: CONTEXT.md, * bin/install.js, a real src/ file a clean fixture can legitimately reference, - * and a copy of the gate + its cli-exit dependency. A unique mkdtemp per call + * and a copy of the gate together with its transitive relative-require graph + * (walked and copied by copyScriptWithDeps, not hand-listed — see that + * helper's docstring for why: a hand-copied dependency list silently goes + * stale the moment the script gains a new require). A unique mkdtemp per call * keeps parallel tests from colliding, and the dir is removed via `t.after()` * so a failing assertion cannot leak it. */ @@ -42,15 +46,10 @@ function makeRepo(t, { contextBody, runtimes = REAL_RUNTIMES }) { const root = createTempDir('gsd-glossary-refs-'); t.after(() => cleanup(root)); - fs.mkdirSync(path.join(root, 'scripts', 'lib'), { recursive: true }); fs.mkdirSync(path.join(root, 'src'), { recursive: true }); fs.mkdirSync(path.join(root, 'bin'), { recursive: true }); - fs.copyFileSync(path.join(REPO_ROOT, SCRIPT_REL), path.join(root, SCRIPT_REL)); - fs.copyFileSync( - path.join(REPO_ROOT, 'scripts', 'lib', 'cli-exit.cjs'), - path.join(root, 'scripts', 'lib', 'cli-exit.cjs'), - ); + copyScriptWithDeps(REPO_ROOT, root, SCRIPT_REL); fs.writeFileSync(path.join(root, 'CONTEXT.md'), contextBody); fs.writeFileSync( diff --git a/tests/cli-exit.test.cjs b/tests/cli-exit.test.cjs index 330262e55..034d70af2 100644 --- a/tests/cli-exit.test.cjs +++ b/tests/cli-exit.test.cjs @@ -1,21 +1,29 @@ 'use strict'; -const { describe, test } = require('node:test'); +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 } = require('../scripts/lib/cli-exit.cjs'); +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'); // Paths to the compiled product seam (src/cli-exit.cts → gsd-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, '../gsd-core/bin/lib/cli-exit.cjs'); const IO_PATH = path.resolve(__dirname, '../gsd-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, '../gsd-core/bin/lib/exit-code-registry.cjs'); + +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() { @@ -64,53 +72,41 @@ describe('ExitError', () => { }); describe('runMain', () => { - test('main returns a number sets process.exitCode', async () => { + test('main returns a number sets process.exitCode', async (t) => { const saved = process.exitCode; - try { - runMain(() => 42); - await settle(); - assert.equal(process.exitCode, 42); - } finally { - process.exitCode = saved || 0; - } + t.after(() => { process.exitCode = saved || 0; }); + runMain(() => 42); + await settle(); + assert.equal(process.exitCode, 42); }); - test('main returns undefined leaves process.exitCode unchanged', async () => { + test('main returns undefined leaves process.exitCode unchanged', async (t) => { const saved = process.exitCode; // Set a known value before calling process.exitCode = 0; - try { - runMain(() => undefined); - await settle(); - assert.equal(process.exitCode, 0); - } finally { - process.exitCode = saved || 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 () => { + test('main throws ExitError sets process.exitCode to err.code', async (t) => { const saved = process.exitCode; - try { - runMain(() => { throw new ExitError(2); }); - await settle(); - assert.equal(process.exitCode, 2); - } finally { - process.exitCode = saved || 0; - } + 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 () => { + test('main rejects async ExitError(0) sets process.exitCode to 0', async (t) => { const saved = process.exitCode; - try { - runMain(async () => { throw new ExitError(0); }); - await settle(); - assert.equal(process.exitCode, 0); - } finally { - process.exitCode = saved !== undefined ? saved : 0; - } + 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 () => { + 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); @@ -118,19 +114,18 @@ describe('runMain', () => { stderrChunks.push(typeof chunk === 'string' ? chunk : chunk.toString()); return origWrite(chunk, ...args); }; - try { - 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}`); - } finally { + 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 () => { + 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); @@ -138,19 +133,18 @@ describe('runMain', () => { stderrChunks.push(typeof chunk === 'string' ? chunk : chunk.toString()); return origWrite(chunk, ...args); }; - try { - 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}`); - } finally { + 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 () => { + 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); @@ -158,17 +152,64 @@ describe('runMain', () => { stderrChunks.push(typeof chunk === 'string' ? chunk : chunk.toString()); return origWrite(chunk, ...args); }; - try { - 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}`); - } finally { + 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); + }); }); }); @@ -606,10 +647,20 @@ describe('regressions', () => { // 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('gsd-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)});`, @@ -635,3 +686,731 @@ describe('regressions', () => { }); }); }); + +// ─── #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('GSD_EXIT_CONTRACT=v2 env -> v2', () => { + assert.equal(resolveContractVersion({ argv: ['node', 'x'], env: { GSD_EXIT_CONTRACT: 'v2' } }), 'v2'); + }); + + test('flag v1 beats env v2', () => { + assert.equal( + resolveContractVersion({ argv: ['node', 'x', '--exit-contract=v1'], env: { GSD_EXIT_CONTRACT: 'v2' } }), + 'v1', + ); + }); + + test('flag v2 beats env v1 (both directions)', () => { + assert.equal( + resolveContractVersion({ argv: ['node', 'x', '--exit-contract=v2'], env: { GSD_EXIT_CONTRACT: 'v1' } }), + 'v2', + ); + }); + + test('an empty GSD_EXIT_CONTRACT reads as unset, not as an explicit selection', () => { + assert.equal(resolveContractVersion({ argv: ['node', 'x'], env: { GSD_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=' ? '' : bad} is REJECTED, not silently defaulted`, () => { + assert.throws(() => resolveContractVersion({ argv: ['node', 'x', flagArg], env: {} })); + }); + } + + test('GSD_EXIT_CONTRACT=v3 (env garbage) is rejected the same way', () => { + assert.throws(() => resolveContractVersion({ argv: ['node', 'x'], env: { GSD_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: { GSD_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 (GSD_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 GSD_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, GSD_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, GSD_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 GSD_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 GSD_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 GSD_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 gsd-tools command with --exit-contract=v2 (or +// GSD_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 +// `GSD_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 GSD_EXIT_CONTRACT/--exit-contract wiring (acceptance criterion)', () => { + test('terminateNow: GSD_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, GSD_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.GSD_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: GSD_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, GSD_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.GSD_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 "