* feat(#3906): two terminators over one registry, with a versioned projection Adds terminateNow (write-then-terminate, for callers that cannot wait for the event loop) beside runMain (drain-then-exit), both projecting through one shared function so they cannot disagree - the parity the ADR makes mandatory. A failed write does not change the exit code: letting it propagate would fail a hook open, which is what the fail-closed branches exist to prevent. The projection is versioned. v1 reproduces today's integers, including keeping a payload-carried degraded result at exit 0 - ADR-2980 ratified that across 60 sites and declined normalizing it on measured blast radius. v2 applies the registry. --exit-contract=v2 or GSD_EXIT_CONTRACT=v2 selects it; an unrecognized version throws rather than silently defaulting. The registry is now emitted beside both copies of the exit module, so it resolves as a sibling in the built tree and in the committed scripts/ copy that must load on an unbuilt clone. * fix(#3906): actually restrict code 2 to terminateNow, and generate the registry's type The claim that terminateNow is the only place 2 can be produced was false: runMain's outcome arm applied no guard, so runMain(()=>'HOOK_DENY') set exitCode 2 through the drain path - and the parity matrix demonstrated it while calling it parity. runMain now refuses any outcome projecting to the hook-protocol code, gated on the code rather than the name so an alias cannot slip past, and the matrix asserts the restriction instead of contradicting it. The ambient type for the generated registry was hand-written with no gate against the generator's actual output - the declared-surface-diverges-from-runtime defect class this epic exists to close, reintroduced inside it. It is now a third generated artifact covered by the same --check. Also converts every test-body try/finally to t.after(). * test(#3906): derive the glossary fixture's dependencies instead of hand-listing them Adding a require to scripts/lib/cli-exit.cjs broke 31 tests in one suite that built its fixture from a hand-written dependency list, so the new sibling was absent and the copied script could not load. copyScriptWithDeps walks the require graph and exists for exactly this class - #3412 paid the same bill when one new require broke 82 tests across two suites. Migrating rather than adding another copyFileSync line keeps the class closed. The other nine suites referencing that path were triaged; none copies-and-spawns, so none needed migrating. * fix(#3906): enumerate the new shipped file, drop a vendor name from shipped data, and fix three test defects install: scripts/lib/exit-code-registry.cjs was missing from GSD_SCRIPTS_LIB_FILES, so it shipped to every install and orphaned on uninstall. The registry gave HOOK_DENY a meaning naming one harness, and that string ships into every runtime's tree - a guard correctly caught it leaking into the hermes and qwen installs. The registry is runtime-neutral infrastructure; the vendor name belongs in the ADR, not in shipped data. Two more fixture harnesses built their trees from hand-listed dependencies and broke on the new require; both migrated to the derived helper, and all 23 copy-and-spawn candidates were enumerated so the class is closed rather than patched. One generator test used a fixture code that collided with a real allocation, so the generator correctly reported a duplicate where the test expected drift. The large-payload test embedded a 256KB literal in the child's argv, exceeding Linux's 128KiB MAX_ARG_STRLEN so the child never started - it now builds the payload inside the child. * chore(#3906): backfill changeset pr number * docs(#3906): document the exit-code contract selector P2 is the first phase of this epic with a user-invocable surface, so the flag and env var owe a reference entry. Records what actually differs between v1 and v2 today (one outcome), that an unrecognized value is rejected rather than silently defaulted, and the fail-safe property that makes switching safe. --------- Co-authored-by: sim <sim@local>
This commit is contained in:
5
.changeset/graceful-jaguars-wave.md
Normal file
5
.changeset/graceful-jaguars-wave.md
Normal file
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
@@ -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 <command>
|
||||
gsd-tools <command> --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
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
]);
|
||||
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
|
||||
@@ -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 <path> --out <path> # 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 <path> --out <path> --scripts-out <path> --dts-out <path> # 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 <path>] [--out <path>] [--json]',
|
||||
'Usage: node scripts/gen-exit-code-registry.cjs [--write|--check] [--declaration <path>] [--out <path>] [--scripts-out <path>] [--dts-out <path>] [--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,
|
||||
|
||||
@@ -14,13 +14,30 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
/**
|
||||
* Process-exit primitives (ExitError, runMain) plus the json-error-mode cell.
|
||||
* Must import nothing but `node:fs` — this source is emitted to TWO locations,
|
||||
* gsd-core/bin/lib/cli-exit.cjs (tsc build output) and scripts/lib/cli-exit.cjs
|
||||
* (a generated, committed artifact regenerated by scripts/gen-scripts-cli-exit.cjs),
|
||||
* and the latter must load on an unbuilt clone before anything under ./lib exists.
|
||||
* Process-exit primitives (ExitError, runMain, terminateNow) plus the
|
||||
* json-error-mode and contract-version cells.
|
||||
*
|
||||
* Must import nothing but `node:fs` and `./exit-code-registry.cjs` — this
|
||||
* source is emitted to TWO locations, gsd-core/bin/lib/cli-exit.cjs (tsc
|
||||
* build output) and scripts/lib/cli-exit.cjs (a generated, committed
|
||||
* artifact regenerated by scripts/gen-scripts-cli-exit.cjs), and the latter
|
||||
* must load on an unbuilt clone before anything under ./lib exists. The
|
||||
* registry require is safe here for the same reason: scripts/gen-exit-code-
|
||||
* registry.cjs (ADR-3889 Phase 1/2, #3905/#3906) dual-emits its OWN sibling
|
||||
* artifact, exit-code-registry.cjs, into both of these exact locations, so
|
||||
* a relative `./exit-code-registry.cjs` resolves next to whichever copy of
|
||||
* this module loaded it.
|
||||
*/
|
||||
const node_fs_1 = __importDefault(require("node:fs"));
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const exitCodeRegistryModule = require("./exit-code-registry.cjs");
|
||||
// Called only as exitCodeRegistryModule.exitCodeFor(...), never destructured:
|
||||
// @typescript-eslint/unbound-method flags a bare function-typed property
|
||||
// pulled off an object at the point of destructuring, since a detached
|
||||
// reference COULD be called with the wrong `this` — keeping the member
|
||||
// access qualified sidesteps that regardless of whether the callee ever
|
||||
// actually touches `this` (it does not; exitCodeFor is pure).
|
||||
const exitCodeFor = (name) => exitCodeRegistryModule.exitCodeFor(name);
|
||||
/**
|
||||
* The wire value `runMain` stamps into its structured envelope. Declared HERE,
|
||||
* not in io.cts, because this module must not import anything (see the module
|
||||
@@ -50,6 +67,139 @@ function setJsonErrorMode(v) {
|
||||
function getJsonErrorMode() {
|
||||
return globalThis[JSON_ERROR_MODE_KEY] === true;
|
||||
}
|
||||
/** The single registered name code 2 may ever be produced for (ADR-3889 §1). */
|
||||
const HOOK_DENY_NAME = 'HOOK_DENY';
|
||||
const HOOK_DENY_CODE = exitCodeFor(HOOK_DENY_NAME);
|
||||
/**
|
||||
* Currently-resolved exit-contract version (ADR-3889 §4). Held in a
|
||||
* Symbol-keyed globalThis cell rather than a module-level `let`, for the
|
||||
* exact reason JSON_ERROR_MODE_KEY is (see its comment above): this module
|
||||
* is emitted to two locations and thus loaded as two independent module
|
||||
* instances in any process that requires both, so a module-level variable
|
||||
* would let those two instances disagree about which contract is active.
|
||||
* `resolveContractVersion` is the only writer; `terminateNow`/`runMain`
|
||||
* read it internally when projecting a declared outcome.
|
||||
*/
|
||||
const CONTRACT_VERSION_KEY = Symbol.for('gsd.exit.contractVersion');
|
||||
function setContractVersion(v) {
|
||||
globalThis[CONTRACT_VERSION_KEY] = v;
|
||||
}
|
||||
/**
|
||||
* Resolve the active exit-contract version, wiring the ambient process to the
|
||||
* two terminators (ADR-3889 §4/§3). Mirrors how JSON_ERROR_MODE_KEY already
|
||||
* works: a process-global cell means no entrypoint needs per-call wiring, so
|
||||
* a `scripts/` tool or a hook gets the same behaviour as `gsd-tools` without
|
||||
* this module touching either (P8 owns `gsd-tools`; P7 owns hooks).
|
||||
*
|
||||
* Precedence: if the cell already holds an explicit version, that wins —
|
||||
* this is what lets `setContractVersion` override the ambient process (a
|
||||
* later `GSD_EXIT_CONTRACT=v2` in the same process must NOT unseat an
|
||||
* explicit `setContractVersion('v1')` call). Otherwise resolve from argv/env
|
||||
* via `resolveContractVersion`, which itself persists the result into the
|
||||
* cell — so this is a one-time resolution per process; every later read is
|
||||
* just the cached cell value. An invalid ambient value (e.g. `v3`) is NOT
|
||||
* softened to a silent v1 here: `resolveContractVersion` throws, and that
|
||||
* throw propagates — swallowing it would reintroduce the "nothing fails with
|
||||
* success" defect ADR-3889 exists to close, on the very selector meant to
|
||||
* demonstrate the fix. Absent both flag and env, resolution still yields
|
||||
* 'v1' (the documented default) and that too gets memoized.
|
||||
*/
|
||||
function getContractVersion() {
|
||||
const cached = globalThis[CONTRACT_VERSION_KEY];
|
||||
if (cached === 'v1' || cached === 'v2')
|
||||
return cached;
|
||||
return resolveContractVersion({ argv: process.argv, env: process.env });
|
||||
}
|
||||
/**
|
||||
* Project a declared outcome onto an integer exit code for a given contract
|
||||
* version. Pure and total over its own input space: throws for anything not
|
||||
* an exact-case registered name (mirrors exitCodeFor's contract) or an
|
||||
* unrecognized version — it never returns undefined/NaN.
|
||||
*
|
||||
* PASS/FAIL and every registered name project IDENTICALLY under v1 and v2
|
||||
* (registered names are version-invariant) — the sole exception is DEGRADED:
|
||||
*
|
||||
* v1: DEGRADED -> 0. Deliberate, NOT a bug: ADR-2980 ratified 60
|
||||
* `output({error})` call sites that already exit 0 on a payload-carried
|
||||
* error, and ADR-2980's own "Revisit if" clause is what ADR-3889 §4
|
||||
* answers — normalizing this to a non-zero code was explicitly
|
||||
* DECLINED there on measured blast radius. A future reader must not
|
||||
* "fix" this to look more consistent with v2; the inconsistency IS the
|
||||
* compatibility boundary.
|
||||
* v2: DEGRADED -> exitCodeFor('DEGRADED') (80). Looked up through the
|
||||
* registry, never hardcoded, so a re-allocation of DEGRADED's code
|
||||
* cannot silently desync this projection from the shipped table.
|
||||
*/
|
||||
function projectOutcome(outcome, version) {
|
||||
if (typeof outcome !== 'string' || outcome.length === 0) {
|
||||
throw new Error(`projectOutcome: outcome must be a non-empty string, received ${JSON.stringify(outcome)}`);
|
||||
}
|
||||
if (version !== 'v1' && version !== 'v2') {
|
||||
throw new Error(`projectOutcome: version must be 'v1' or 'v2', received ${JSON.stringify(version)}`);
|
||||
}
|
||||
if (outcome === 'PASS')
|
||||
return 0;
|
||||
if (outcome === 'FAIL')
|
||||
return 1;
|
||||
if (outcome === 'DEGRADED')
|
||||
return version === 'v1' ? 0 : exitCodeFor('DEGRADED');
|
||||
// Any other registered name: version-invariant, resolved through the
|
||||
// registry (throws for anything unregistered/empty/non-string/wrong-case —
|
||||
// exitCodeFor's own contract, which this function inherits verbatim).
|
||||
return exitCodeFor(outcome);
|
||||
}
|
||||
const EXIT_CONTRACT_FLAG_PREFIX = '--exit-contract=';
|
||||
/** Scan argv for the FIRST `--exit-contract=<value>` token; undefined if absent. */
|
||||
function findExitContractFlag(argv) {
|
||||
for (const arg of argv) {
|
||||
if (typeof arg === 'string' && arg.startsWith(EXIT_CONTRACT_FLAG_PREFIX)) {
|
||||
return arg.slice(EXIT_CONTRACT_FLAG_PREFIX.length);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
/**
|
||||
* Resolve which exit-contract version is active from argv/env, per ADR-3889
|
||||
* §4, and persist it to the shared contract-version cell so a later
|
||||
* `terminateNow`/`runMain` call (through EITHER module copy) projects
|
||||
* against it without re-parsing argv/env itself.
|
||||
*
|
||||
* Precedence: an explicit `--exit-contract=<v>` flag BEATS
|
||||
* `GSD_EXIT_CONTRACT`, in both directions (flag=v1 + env=v2 -> v1; flag=v2 +
|
||||
* env=v1 -> v2). Neither present -> 'v1' (the documented default). An empty
|
||||
* env var reads as UNSET, not as an explicit empty selection — a shell that
|
||||
* exports `GSD_EXIT_CONTRACT=` with nothing after the `=` must not silently
|
||||
* select a version.
|
||||
*
|
||||
* Casing is decided, not accidental: only the exact lowercase tokens `v1`/
|
||||
* `v2` are accepted (matching every example in ADR-3889 and this module's own
|
||||
* usage docs, both of which write `v2` never `V2`). Anything else recognized
|
||||
* as PRESENT but not a valid version — `v3`, `garbage`, or an explicitly
|
||||
* empty flag value (`--exit-contract=`) — THROWS rather than silently
|
||||
* defaulting to v1. A selector for a contract whose whole thesis is "nothing
|
||||
* fails with success" must not itself fail open.
|
||||
*/
|
||||
function resolveContractVersion(opts = {}) {
|
||||
const argv = opts.argv ?? process.argv;
|
||||
const env = opts.env ?? process.env;
|
||||
const flagValue = findExitContractFlag(argv);
|
||||
const rawEnvValue = env.GSD_EXIT_CONTRACT;
|
||||
const envValue = rawEnvValue === undefined || rawEnvValue === '' ? undefined : rawEnvValue;
|
||||
const selected = flagValue !== undefined ? flagValue : envValue;
|
||||
let resolved;
|
||||
if (selected === undefined) {
|
||||
resolved = 'v1';
|
||||
}
|
||||
else if (selected === 'v1' || selected === 'v2') {
|
||||
resolved = selected;
|
||||
}
|
||||
else {
|
||||
throw new Error(`resolveContractVersion: unrecognized exit-contract version ${JSON.stringify(selected)} `
|
||||
+ `(expected 'v1' or 'v2')`);
|
||||
}
|
||||
setContractVersion(resolved);
|
||||
return resolved;
|
||||
}
|
||||
/**
|
||||
* Error carrying a process exit code. CLI logic throws this instead of calling
|
||||
* process.exit() (banned by n/no-process-exit); runMain() translates it into
|
||||
@@ -68,17 +218,48 @@ class ExitError extends Error {
|
||||
/**
|
||||
* Run a CLI main and translate its outcome into process.exitCode (never
|
||||
* process.exit, so n/no-process-exit stays satisfied; output flushes and
|
||||
* process.on('exit') cleanup still fires). main may be sync or async:
|
||||
* number return -> process.exitCode = it
|
||||
* thrown ExitError -> process.exitCode = err.code (+ stderr err.message if hasUserMessage && code!=0)
|
||||
* process.on('exit') cleanup still fires — this is precisely why runMain and
|
||||
* terminateNow are two different functions: drain-then-exit vs write-then-
|
||||
* terminate). main may be sync or async. Every arm below except the new
|
||||
* string one is UNCHANGED from before ADR-3889 Phase 2:
|
||||
* number return -> process.exitCode = it (unchanged)
|
||||
* string return -> NEW: process.exitCode = projectOutcome(result, getContractVersion()),
|
||||
* UNLESS that projection is the HOOK_DENY exit code (see
|
||||
* the refusal below — 2 may only be produced by terminateNow).
|
||||
* thrown ExitError -> process.exitCode = err.code (+ stderr err.message if hasUserMessage && code!=0) (unchanged)
|
||||
* other throw -> when json-error mode is active, emits structured { ok:false, reason, message }
|
||||
* to stderr; otherwise writes raw stack trace. exit code = 1 in either case.
|
||||
* to stderr; otherwise writes raw stack trace. exit code = 1 in either case. (unchanged)
|
||||
*/
|
||||
function runMain(main) {
|
||||
Promise.resolve()
|
||||
.then(() => main())
|
||||
.then((code) => { if (typeof code === 'number')
|
||||
process.exitCode = code; })
|
||||
.then((result) => {
|
||||
if (typeof result === 'number') {
|
||||
process.exitCode = result;
|
||||
return;
|
||||
}
|
||||
if (typeof result === 'string') {
|
||||
const projected = projectOutcome(result, getContractVersion());
|
||||
// ADR-3889 §3: exit code 2 (the hook-protocol deny) may
|
||||
// ONLY be produced by terminateNow, never by runMain. runMain is
|
||||
// drain-then-exit; a deny drained this way can be truncated on
|
||||
// Windows, which is exactly why terminateNow (write-then-terminate)
|
||||
// exists. Gated on the PROJECTED code, not on the literal string
|
||||
// `'HOOK_DENY'`, so a future registry rename that still resolves to
|
||||
// this code cannot slip past the guard.
|
||||
if (projected === HOOK_DENY_CODE) {
|
||||
process.stderr.write(`runMain: refusing to exit with code ${HOOK_DENY_CODE} — outcome ${JSON.stringify(result)} `
|
||||
+ `projects to the ${HOOK_DENY_NAME} exit code, which is reserved to terminateNow. `
|
||||
+ `A hook-protocol deny must be delivered write-then-terminate via terminateNow(${JSON.stringify(result)}, payload), `
|
||||
+ 'never drain-then-exit via runMain — a drained deny can be truncated on Windows. '
|
||||
+ 'This is a caller bug: runMain must not be given a main() that returns HOOK_DENY.\n');
|
||||
process.exitCode = exitCodeFor('INTERNAL');
|
||||
return;
|
||||
}
|
||||
process.exitCode = projected;
|
||||
return;
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err instanceof ExitError) {
|
||||
if (err.hasUserMessage && err.code !== 0)
|
||||
@@ -102,4 +283,138 @@ function runMain(main) {
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
module.exports = { ExitError, runMain, setJsonErrorMode, getJsonErrorMode, EXIT_ENVELOPE_REASON };
|
||||
/**
|
||||
* Write `payload` fully to fd 1 (and, for a deny, fd 2 too) and terminate the
|
||||
* process IMMEDIATELY with `outcome` projected through the current contract
|
||||
* version. This is write-then-terminate, the other half of ADR-3889 §3's
|
||||
* "two terminators over one registry": hooks fire from contexts (e.g. a
|
||||
* `setTimeout` stdin-timeout guard) where `process.exitCode = N; return;`
|
||||
* terminates nothing, so they need an immediate, synchronous exit — the
|
||||
* exact gap `eslint.config.mjs:563-582` documents for `hooks/**`.
|
||||
*
|
||||
* This is THE ONLY sanctioned `process.exit` call site in the repo, and the
|
||||
* only place exit code 2 can be produced: 2 is reserved to the hook-adapter
|
||||
* protocol (ADR-3889 §1), and the registry's own one-owner rule already
|
||||
* guarantees no other registered name resolves to it — the check below is a
|
||||
* defense-in-depth assertion of that invariant, not the sole thing enforcing
|
||||
* it.
|
||||
*
|
||||
* @param outcome - declared outcome name, projected via projectOutcome.
|
||||
* @param payload - JSON-serializable value written to fd 1 (and, on a deny,
|
||||
* fd 2 too — Kimi's native hook bus feeds stderr, not stdout, back to the
|
||||
* model on exit 2, per hooks/gsd-write-guard.js's emitBlock).
|
||||
*
|
||||
* PAYLOAD-SIZE CONSTRAINT FOR CALLERS (measured for #3906, relevant to P7/
|
||||
* #3911 wiring 19 enforcement hooks onto this function): the write-until-
|
||||
* drained loop above delivers a payload whole regardless of size — verified
|
||||
* up to 1MB (Node's own `spawnSync` default `maxBuffer`) with no truncation
|
||||
* and no stall, both with a concurrently-draining async reader (~30ms for a
|
||||
* 256KB payload) and with the default (internally-drained) pipe stdio a
|
||||
* spawnSync-based test harness gets for free. Node's `spawnSync` does NOT
|
||||
* suffer the classic "child blocks writing past the pipe buffer because
|
||||
* nothing on the parent side is reading yet" deadlock some other languages'
|
||||
* synchronous-subprocess primitives have; it drains stdout/stderr
|
||||
* concurrently at the libuv layer while the child runs. The constraint that
|
||||
* DOES bite on Linux is unrelated to pipe buffering: `execve(2)` enforces
|
||||
* `MAX_ARG_STRLEN` (128KiB per single argv/envp string; see `man execve`
|
||||
* NOTES) — so a CALLER that embeds a large literal payload directly into a
|
||||
* spawned command line (e.g. `node -e "...<huge string>..."`) can fail to
|
||||
* even start the child on Linux (macOS has no equivalent per-string cap),
|
||||
* with no relation to this function's own behavior. See
|
||||
* tests/cli-exit.test.cjs's "a large payload (bigger than a pipe buffer)
|
||||
* arrives whole" test, which hit exactly this constructing its own fixture
|
||||
* before being rewritten to build the payload inside the child instead.
|
||||
*/
|
||||
function terminateNow(outcome, payload) {
|
||||
// terminateNow is total by construction: its callers are enforcement hooks
|
||||
// (P7/#3911, 19 of them) whose OWN outer catch may fail open (some end in
|
||||
// `process.exit(0)`). If resolving the contract version, projecting the
|
||||
// outcome, or the HOOK_DENY-collision guard below threw and that throw
|
||||
// propagated out of this function, it would unwind straight into that
|
||||
// caller's catch — turning a deny into a silent allow, exactly the defect
|
||||
// ADR-3889 exists to close. So every one of those steps is wrapped here:
|
||||
// on ANY failure this still terminates, deterministically, with INTERNAL
|
||||
// (never by returning or re-throwing) — a malformed call is a programming
|
||||
// error to be diagnosed on stderr, not a reason to hand control back.
|
||||
let versionForDiagnostics = '(unresolved)';
|
||||
try {
|
||||
const version = getContractVersion();
|
||||
versionForDiagnostics = version;
|
||||
const projected = projectOutcome(outcome, version);
|
||||
if (projected === HOOK_DENY_CODE && outcome !== HOOK_DENY_NAME) {
|
||||
throw new Error(`terminateNow: exit code ${HOOK_DENY_CODE} is reserved to the ${HOOK_DENY_NAME} outcome; `
|
||||
+ `got outcome ${JSON.stringify(outcome)}`);
|
||||
}
|
||||
// m2 (round 5, hooks/gsd-write-guard.js:159-175): emission must itself be
|
||||
// exception-safe. A failed write (EPIPE, a full pipe buffer, a throwing
|
||||
// fs.writeSync in a test) must NOT change the exit code — if it propagated
|
||||
// out of this function, a caller whose payload could not be delivered
|
||||
// would fall into ITS OWN outer catch and fail OPEN, which is the exact
|
||||
// outcome the fail-closed branches this function serves exist to prevent.
|
||||
// The decision to terminate with `projected` stands regardless of whether
|
||||
// the payload could be delivered.
|
||||
try {
|
||||
// fs.writeSync, never process.stdout.write: pipe writes via
|
||||
// process.stdout/stderr are async on Windows, and process.exit() below
|
||||
// does not wait for them to flush — a truncated payload is a silent
|
||||
// half-emission. Looped over a Buffer (not a bare string call) so a
|
||||
// payload larger than the destination pipe's buffer — where a single
|
||||
// write() syscall can legitimately return fewer bytes written than
|
||||
// requested — still arrives whole rather than truncated.
|
||||
const buf = Buffer.from(JSON.stringify(payload), 'utf8');
|
||||
let offset = 0;
|
||||
while (offset < buf.length) {
|
||||
offset += node_fs_1.default.writeSync(1, buf, offset, buf.length - offset);
|
||||
}
|
||||
if (projected === HOOK_DENY_CODE) {
|
||||
let stderrOffset = 0;
|
||||
while (stderrOffset < buf.length) {
|
||||
stderrOffset += node_fs_1.default.writeSync(2, buf, stderrOffset, buf.length - stderrOffset);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Emission failed; the exit code decision still stands (see above).
|
||||
}
|
||||
// n/no-process-exit is not registered for src/**/*.cts (see the ADR-3889
|
||||
// reference note in the module header) and both compiled .cjs copies of
|
||||
// this module are lint-ignored build/generated artifacts, so no disable
|
||||
// directive is needed here for the one sanctioned process.exit call site.
|
||||
process.exit(projected);
|
||||
}
|
||||
catch (err) {
|
||||
// Anything above threw: an unrecognized --exit-contract/GSD_EXIT_CONTRACT
|
||||
// value, a non-string/empty/unregistered `outcome`, or the HOOK_DENY
|
||||
// collision guard. Diagnose on stderr — swallowing this silently would
|
||||
// make a typo'd outcome name or a bad contract-version env var
|
||||
// undebuggable — then terminate unconditionally. The diagnostic write
|
||||
// itself gets its own swallow-on-failure guard, because even a failed
|
||||
// diagnostic must not stop the exit below from happening.
|
||||
try {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
const message = `terminateNow: programming error — outcome=${JSON.stringify(outcome)} `
|
||||
+ `version=${JSON.stringify(versionForDiagnostics)}: ${detail}\n`
|
||||
+ `This is a caller bug (unrecognized outcome/exit-contract, or the HOOK_DENY collision `
|
||||
+ `guard), not a declared outcome. Terminating with INTERNAL rather than propagating: an `
|
||||
+ `enforcement-hook caller's own outer catch may fail open (process.exit(0)), and unwinding `
|
||||
+ `into it here would silently convert a deny into an allow.\n`;
|
||||
node_fs_1.default.writeSync(2, message);
|
||||
}
|
||||
catch {
|
||||
// Diagnostic emission itself failed; the exit below is unconditional
|
||||
// regardless.
|
||||
}
|
||||
process.exit(exitCodeFor('INTERNAL'));
|
||||
}
|
||||
}
|
||||
module.exports = {
|
||||
ExitError,
|
||||
runMain,
|
||||
setJsonErrorMode,
|
||||
getJsonErrorMode,
|
||||
EXIT_ENVELOPE_REASON,
|
||||
projectOutcome,
|
||||
resolveContractVersion,
|
||||
getContractVersion,
|
||||
terminateNow,
|
||||
};
|
||||
|
||||
97
scripts/lib/exit-code-registry.cjs
Normal file
97
scripts/lib/exit-code-registry.cjs
Normal file
@@ -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 };
|
||||
352
src/cli-exit.cts
352
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<symbol, boolean>)[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<symbol, ContractVersion>)[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<symbol, ContractVersion | undefined>)[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=<value>` 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=<v>` flag BEATS
|
||||
* `GSD_EXIT_CONTRACT`, in both directions (flag=v1 + env=v2 -> v1; flag=v2 +
|
||||
* env=v1 -> v2). Neither present -> 'v1' (the documented default). An empty
|
||||
* env var reads as UNSET, not as an explicit empty selection — a shell that
|
||||
* exports `GSD_EXIT_CONTRACT=` with nothing after the `=` must not silently
|
||||
* select a version.
|
||||
*
|
||||
* Casing is decided, not accidental: only the exact lowercase tokens `v1`/
|
||||
* `v2` are accepted (matching every example in ADR-3889 and this module's own
|
||||
* usage docs, both of which write `v2` never `V2`). Anything else recognized
|
||||
* as PRESENT but not a valid version — `v3`, `garbage`, or an explicitly
|
||||
* empty flag value (`--exit-contract=`) — THROWS rather than silently
|
||||
* defaulting to v1. A selector for a contract whose whole thesis is "nothing
|
||||
* fails with success" must not itself fail open.
|
||||
*/
|
||||
function resolveContractVersion(opts: { 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<number | void>): void {
|
||||
function runMain(main: () => number | string | void | Promise<number | string | void>): 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<number | void>): 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 "...<huge string>..."`) can fail to
|
||||
* even start the child on Linux (macOS has no equivalent per-string cap),
|
||||
* with no relation to this function's own behavior. See
|
||||
* tests/cli-exit.test.cjs's "a large payload (bigger than a pipe buffer)
|
||||
* arrives whole" test, which hit exactly this constructing its own fixture
|
||||
* before being rewritten to build the payload inside the child instead.
|
||||
*/
|
||||
function terminateNow(outcome: 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,
|
||||
};
|
||||
|
||||
42
src/exit-code-registry.d.cts
Normal file
42
src/exit-code-registry.d.cts
Normal file
@@ -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;
|
||||
@@ -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(
|
||||
|
||||
@@ -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=' ? '<empty>' : 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 "<script>" --exit-contract=v2` makes NODE itself consume the
|
||||
// trailing flag (Node's own CLI parser reports an unknown-option usage
|
||||
// error, exit 9) rather than leaving it in the CHILD's process.argv. A
|
||||
// real CLI entrypoint is invoked as `node <file> --exit-contract=v2`, so
|
||||
// the flag path is proven with a real temp script file, not `-e`.
|
||||
test('flag path: node <file> --exit-contract=v2 -> terminateNow DEGRADED exits 80', (t) => {
|
||||
const dir = createTempDir('gsd-3906-flag-');
|
||||
t.after(() => cleanup(dir));
|
||||
const scriptPath = path.join(dir, 'terminate-degraded.cjs');
|
||||
fs.writeFileSync(scriptPath, [
|
||||
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
||||
`c.terminateNow('DEGRADED', {});`,
|
||||
'',
|
||||
].join('\n'));
|
||||
const env = { ...process.env };
|
||||
delete env.GSD_EXIT_CONTRACT;
|
||||
const r = toLegacyResult(runNode([scriptPath, '--exit-contract=v2'], { timeoutMs: PROBE_TIMEOUT_MS, env }));
|
||||
assert.equal(r.status, 80, `stderr: ${r.stderr}`);
|
||||
});
|
||||
|
||||
test('flag path: node <file> with no flag -> terminateNow DEGRADED exits 0', (t) => {
|
||||
const dir = createTempDir('gsd-3906-flag-');
|
||||
t.after(() => cleanup(dir));
|
||||
const scriptPath = path.join(dir, 'terminate-degraded.cjs');
|
||||
fs.writeFileSync(scriptPath, [
|
||||
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
||||
`c.terminateNow('DEGRADED', {});`,
|
||||
'',
|
||||
].join('\n'));
|
||||
const env = { ...process.env };
|
||||
delete env.GSD_EXIT_CONTRACT;
|
||||
const r = toLegacyResult(runNode([scriptPath], { timeoutMs: PROBE_TIMEOUT_MS, env }));
|
||||
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
||||
});
|
||||
|
||||
// setContractVersion itself is internal (not exported); resolveContractVersion
|
||||
// is the module's own sanctioned way to write an explicit version into the
|
||||
// shared cell, and it is what this precedence rests on: getContractVersion's
|
||||
// lazy ambient-resolution path must be skipped entirely once the cell is
|
||||
// already populated, so a later read of GSD_EXIT_CONTRACT must NOT unseat it.
|
||||
test('an explicit resolveContractVersion() call beats a later ambient GSD_EXIT_CONTRACT read (precedence)', () => {
|
||||
const r = toLegacyResult(runNode(['-e', [
|
||||
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
||||
`c.resolveContractVersion({ argv: ['node','x','--exit-contract=v1'], env: {} });`,
|
||||
`c.terminateNow('DEGRADED', {});`,
|
||||
].join('\n')], { timeoutMs: PROBE_TIMEOUT_MS, env: { ...process.env, GSD_EXIT_CONTRACT: 'v2' } }));
|
||||
assert.equal(
|
||||
r.status, 0,
|
||||
`an explicit resolveContractVersion('v1') call must beat a later ambient GSD_EXIT_CONTRACT=v2 read; stderr: ${r.stderr}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('GSD_EXIT_CONTRACT=v3 (invalid) still THROWS on the lazy ambient path, never silently v1 (runMain: generic-throw arm, exit 1)', () => {
|
||||
const r = toLegacyResult(runNode(['-e', [
|
||||
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
||||
`c.runMain(() => 'DEGRADED');`,
|
||||
`setImmediate(() => {});`,
|
||||
].join('\n')], { timeoutMs: PROBE_TIMEOUT_MS, env: { ...process.env, GSD_EXIT_CONTRACT: 'v3' } }));
|
||||
assert.equal(r.status, 1, `expected the generic-throw exit code 1; stderr: ${r.stderr}`);
|
||||
assert.ok(
|
||||
/unrecognized exit-contract version/.test(r.stderr),
|
||||
`expected the resolveContractVersion rejection message on stderr; got: ${r.stderr.slice(0, 300)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// #3906 follow-up (P7/#3911 hardening): terminateNow is TOTAL — unlike
|
||||
// runMain above, an invalid ambient contract version must NOT propagate a
|
||||
// throw at all (an enforcement-hook caller's own outer catch could turn
|
||||
// that into a fail-open exit 0). It must terminate deterministically with
|
||||
// INTERNAL (70) and diagnose on stderr instead.
|
||||
test('terminateNow: GSD_EXIT_CONTRACT=v3 (invalid) exits 70 (INTERNAL), not silently v1 and not a propagated throw', () => {
|
||||
const r = toLegacyResult(runNode(['-e', [
|
||||
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
||||
`c.terminateNow('DEGRADED', {});`,
|
||||
].join('\n')], { timeoutMs: PROBE_TIMEOUT_MS, env: { ...process.env, GSD_EXIT_CONTRACT: 'v3' } }));
|
||||
assert.equal(r.status, 70, `expected INTERNAL (70); stderr: ${r.stderr}`);
|
||||
assert.ok(
|
||||
/unrecognized exit-contract version/.test(r.stderr),
|
||||
`expected the resolveContractVersion rejection message on stderr; got: ${r.stderr.slice(0, 300)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#3906: terminateNow', () => {
|
||||
function spawnTerminateNow(lines) {
|
||||
return toLegacyResult(runNode(['-e', lines.join('\n')], { timeoutMs: PROBE_TIMEOUT_MS }));
|
||||
}
|
||||
|
||||
test('HOOK_DENY exits 2 with the payload written to stdout', () => {
|
||||
const r = spawnTerminateNow([
|
||||
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
||||
`c.terminateNow('HOOK_DENY', { reason: 'blocked-by-guard', detail: 'x' });`,
|
||||
]);
|
||||
assert.equal(r.status, 2);
|
||||
assert.deepEqual(JSON.parse(r.stdout), { reason: 'blocked-by-guard', detail: 'x' });
|
||||
});
|
||||
|
||||
test('HOOK_DENY writes the SAME payload to both stdout and stderr (Kimi reads exit-2 output from stderr)', () => {
|
||||
const r = spawnTerminateNow([
|
||||
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
||||
`c.terminateNow('HOOK_DENY', { reason: 'blocked' });`,
|
||||
]);
|
||||
assert.equal(r.status, 2);
|
||||
assert.deepEqual(JSON.parse(r.stdout), { reason: 'blocked' });
|
||||
assert.deepEqual(JSON.parse(r.stderr), { reason: 'blocked' });
|
||||
});
|
||||
|
||||
test('a non-deny outcome writes stdout only, not stderr', () => {
|
||||
const r = spawnTerminateNow([
|
||||
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
||||
`c.terminateNow('FAIL', { reason: 'not-a-deny' });`,
|
||||
]);
|
||||
assert.equal(r.status, 1);
|
||||
assert.deepEqual(JSON.parse(r.stdout), { reason: 'not-a-deny' });
|
||||
assert.equal(r.stderr, '');
|
||||
});
|
||||
|
||||
// Cross-platform IO-failure injection: a monkeypatched THROWING fs.writeSync,
|
||||
// restored implicitly by process exit — never chmod (CONTRIBUTING.md /
|
||||
// CLAUDE.md: mode-bit tricks are bypassed by root/CI and leak resources).
|
||||
test('a failed write still exits with the projected code (the fail-open control)', () => {
|
||||
const r = spawnTerminateNow([
|
||||
`const fs = require('node:fs');`,
|
||||
`fs.writeSync = () => { throw new Error('injected write failure'); };`,
|
||||
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
||||
`c.terminateNow('FAIL', { reason: 'x' });`,
|
||||
]);
|
||||
assert.equal(r.status, 1, 'the exit code must still be the projected one, not corrupted by the write failure');
|
||||
assert.equal(r.stdout, '', 'nothing could be written, so stdout must be empty (not a partial payload)');
|
||||
});
|
||||
|
||||
test('a failed write still exits 2 for a deny, including the stderr write attempt', () => {
|
||||
const r = spawnTerminateNow([
|
||||
`const fs = require('node:fs');`,
|
||||
`fs.writeSync = () => { throw new Error('injected write failure'); };`,
|
||||
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
||||
`c.terminateNow('HOOK_DENY', { reason: 'x' });`,
|
||||
]);
|
||||
assert.equal(r.status, 2);
|
||||
assert.equal(r.stdout, '');
|
||||
assert.equal(r.stderr, '');
|
||||
});
|
||||
|
||||
test('does not return: nothing after the call executes', () => {
|
||||
const r = spawnTerminateNow([
|
||||
`const fs = require('node:fs');`,
|
||||
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
||||
`c.terminateNow('PASS', { ok: true });`,
|
||||
`fs.writeSync(1, 'SHOULD_NEVER_APPEAR');`,
|
||||
]);
|
||||
assert.equal(r.status, 0);
|
||||
assert.equal(r.stdout, JSON.stringify({ ok: true }));
|
||||
assert.ok(!r.stdout.includes('SHOULD_NEVER_APPEAR'), `code after terminateNow must never run; got: ${r.stdout}`);
|
||||
});
|
||||
|
||||
test('a large payload (bigger than a pipe buffer) arrives whole', () => {
|
||||
// 256KB comfortably exceeds every common OS pipe-buffer size (64KB on
|
||||
// Linux, 16 pages, is the largest common default), so a single
|
||||
// fs.writeSync call is very likely to under-write without the
|
||||
// write-until-drained loop.
|
||||
//
|
||||
// The payload is built INSIDE the child from PROBE_BIG_PAYLOAD_SIZE
|
||||
// (an env var carrying a small integer), never embedded as a literal
|
||||
// in the `-e` script text handed to spawnSync. Embedding the 256KB
|
||||
// string directly in that argv element (the previous form of this
|
||||
// test) is what actually failed on the remote Linux matrix: Linux's
|
||||
// execve(2) enforces MAX_ARG_STRLEN, a 128KiB-per-argv/envp-string cap
|
||||
// (32 pages; see `man execve` NOTES), independent of any OS pipe
|
||||
// buffer. A single argv element over that cap makes spawnSync fail at
|
||||
// the exec() level with ENAMETOOLONG/E2BIG — no process ever starts,
|
||||
// which reads back as `status: null, stdout: ''`, exactly the recorded
|
||||
// failure signature. macOS enforces no such per-string cap (only a much
|
||||
// larger whole-argv+env budget), which is why 25 macOS attempts at
|
||||
// reproducing this via the old form never turned up the mechanism.
|
||||
// Measured on this machine (see the phase report for the full probe
|
||||
// transcript): an async `child_process.spawn` with a concurrently
|
||||
// draining reader delivers a 256KB terminateNow payload whole, byte for
|
||||
// byte, in ~30ms; `spawnSync` with the DEFAULT (non-draining-by-JS,
|
||||
// internally-drained-by-libuv) pipe stdio used by this suite's `runNode`
|
||||
// never hangs at any size up to 64MB either — it either succeeds
|
||||
// (payload under the ~1MB default `maxBuffer`) or is classified
|
||||
// `BUFFER_OVERFLOW` (over it), never a stall. terminateNow's
|
||||
// write-until-drained loop itself was never the defect; the previous
|
||||
// form of this test just could not reach the child at all on Linux.
|
||||
const size = 256 * 1024;
|
||||
const r = toLegacyResult(runNode(['-e', [
|
||||
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
||||
`const n = parseInt(process.env.PROBE_BIG_PAYLOAD_SIZE, 10);`,
|
||||
`const big = 'x'.repeat(n);`,
|
||||
`c.terminateNow('PASS', { big });`,
|
||||
].join('\n')], {
|
||||
timeoutMs: PROBE_TIMEOUT_MS,
|
||||
env: { ...process.env, PROBE_BIG_PAYLOAD_SIZE: String(size) },
|
||||
}));
|
||||
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
assert.equal(parsed.big.length, size, 'the payload must arrive byte-for-byte whole, not truncated');
|
||||
assert.equal(parsed.big, 'x'.repeat(size));
|
||||
});
|
||||
|
||||
// ── terminateNow is TOTAL: no input can make it return or throw ──────────
|
||||
//
|
||||
// P7 (#3911) wires 19 enforcement hooks onto terminateNow, and hooks are
|
||||
// exactly the callers whose OWN outer catch may fail open
|
||||
// (`process.exit(0)`). If any of these malformed-call paths propagated a
|
||||
// throw instead of terminating, unwinding into that catch would silently
|
||||
// convert a deny into an allow — the regression this block exists to pin.
|
||||
describe('terminateNow is total: no input can make it return or throw', () => {
|
||||
test('an unregistered outcome name exits 70 (INTERNAL), with a stderr diagnostic naming it, and nothing after the call runs', () => {
|
||||
const r = spawnTerminateNow([
|
||||
`const fs = require('node:fs');`,
|
||||
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
||||
`c.terminateNow('NOT_A_REGISTERED_OUTCOME', {});`,
|
||||
`fs.writeSync(1, 'SHOULD_NEVER_APPEAR');`,
|
||||
]);
|
||||
assert.equal(r.status, 70, `expected INTERNAL (70); stderr: ${r.stderr}`);
|
||||
assert.ok(
|
||||
r.stderr.includes('NOT_A_REGISTERED_OUTCOME'),
|
||||
`expected the diagnostic to name the offending outcome; got: ${r.stderr}`,
|
||||
);
|
||||
assert.ok(!r.stdout.includes('SHOULD_NEVER_APPEAR'), `code after terminateNow must never run; got: ${r.stdout}`);
|
||||
});
|
||||
|
||||
test('an empty-string outcome exits 70', () => {
|
||||
const r = spawnTerminateNow([
|
||||
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
||||
`c.terminateNow('', {});`,
|
||||
]);
|
||||
assert.equal(r.status, 70, `expected INTERNAL (70); stderr: ${r.stderr}`);
|
||||
});
|
||||
|
||||
test('a null outcome exits 70', () => {
|
||||
const r = spawnTerminateNow([
|
||||
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
||||
`c.terminateNow(null, {});`,
|
||||
]);
|
||||
assert.equal(r.status, 70, `expected INTERNAL (70); stderr: ${r.stderr}`);
|
||||
});
|
||||
|
||||
test('FAIL under an invalid ambient GSD_EXIT_CONTRACT=v3 exits 70, not a Node crash and not a silent v1', () => {
|
||||
const r = toLegacyResult(runNode(['-e', [
|
||||
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
||||
`c.terminateNow('FAIL', {});`,
|
||||
].join('\n')], { timeoutMs: PROBE_TIMEOUT_MS, env: { ...process.env, GSD_EXIT_CONTRACT: 'v3' } }));
|
||||
assert.equal(r.status, 70, `expected INTERNAL (70), not a Node crash / silent v1; stderr: ${r.stderr}`);
|
||||
assert.notEqual(r.status, 1, 'FAIL under a valid v1 resolution would exit 1 — 70 proves the invalid version was not silently accepted as v1');
|
||||
});
|
||||
|
||||
// The regression that matters: a caller-side outer catch that fails open
|
||||
// (process.exit(0)) must NEVER be reached. Written so it would FAIL
|
||||
// against the previous implementation, where the HOOK_DENY collision
|
||||
// guard's throw (and the other two throw sites) propagated out of
|
||||
// terminateNow and into this exact catch, exiting 0.
|
||||
test('the fail-open control: a try/catch wrapper around terminateNow that exits 0 on catch must still see exit 70, never 0', () => {
|
||||
const r = spawnTerminateNow([
|
||||
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
||||
`try {`,
|
||||
` c.terminateNow('BOGUS', {});`,
|
||||
`} catch {`,
|
||||
` process.exit(0);`,
|
||||
`}`,
|
||||
]);
|
||||
assert.equal(
|
||||
r.status, 70,
|
||||
`the outer catch must NEVER be reached (which would exit 0); got ${r.status}, stderr: ${r.stderr}`,
|
||||
);
|
||||
assert.notEqual(r.status, 0, 'a fail-open exit 0 here would mean a deny silently became an allow');
|
||||
});
|
||||
|
||||
test('existing valid-outcome behaviour is unchanged: HOOK_DENY still exits 2, UNAVAILABLE still exits 69', () => {
|
||||
const deny = spawnTerminateNow([
|
||||
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
||||
`c.terminateNow('HOOK_DENY', { reason: 'still-blocked' });`,
|
||||
]);
|
||||
assert.equal(deny.status, 2, `stderr: ${deny.stderr}`);
|
||||
|
||||
const unavailable = spawnTerminateNow([
|
||||
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
||||
`c.terminateNow('UNAVAILABLE', { reason: 'still-unavailable' });`,
|
||||
]);
|
||||
assert.equal(unavailable.status, 69, `stderr: ${unavailable.stderr}`);
|
||||
});
|
||||
|
||||
test('a throwing fs.writeSync for a VALID outcome still exits with the projected code, not 70', () => {
|
||||
const r = spawnTerminateNow([
|
||||
`const fs = require('node:fs');`,
|
||||
`fs.writeSync = () => { throw new Error('injected write failure'); };`,
|
||||
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
||||
`c.terminateNow('FAIL', { reason: 'x' });`,
|
||||
]);
|
||||
assert.equal(
|
||||
r.status, 1,
|
||||
`a failed payload write must still exit with the PROJECTED code (1 for FAIL), not fall into the ` +
|
||||
`programming-error path (70); stderr: ${r.stderr}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#3906: runMain stays drain-then-exit (the reason two terminators exist)', () => {
|
||||
test('process.on(\'exit\') still fires, and a pending timer is allowed to run before exit', () => {
|
||||
const r = toLegacyResult(runNode(['-e', [
|
||||
`const fs = require('node:fs');`,
|
||||
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
||||
`process.on('exit', () => { fs.writeSync(1, 'ON_EXIT_FIRED\\n'); });`,
|
||||
`setTimeout(() => { fs.writeSync(1, 'TIMER_FIRED\\n'); }, 20);`,
|
||||
`c.runMain(() => 'FAIL');`,
|
||||
].join('\n')], { timeoutMs: PROBE_TIMEOUT_MS }));
|
||||
|
||||
assert.equal(r.status, 1, `expected exit 1 from FAIL; stderr: ${r.stderr}`);
|
||||
// If runMain had hard-exited (like terminateNow), the 20ms timer below
|
||||
// would never have gotten to run — proving the process instead drained
|
||||
// its event loop naturally (process.exitCode, never process.exit).
|
||||
assert.ok(r.stdout.includes('TIMER_FIRED'), `pending timer must run before exit; got: ${JSON.stringify(r.stdout)}`);
|
||||
assert.ok(r.stdout.includes('ON_EXIT_FIRED'), `'exit' handler must fire; got: ${JSON.stringify(r.stdout)}`);
|
||||
assert.ok(
|
||||
r.stdout.indexOf('TIMER_FIRED') < r.stdout.indexOf('ON_EXIT_FIRED'),
|
||||
`the timer must fire BEFORE the 'exit' handler; got: ${JSON.stringify(r.stdout)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#3906: cross-copy — the built copy and the scripts copy agree', () => {
|
||||
test('projectOutcome agrees across both copies for every outcome/version', () => {
|
||||
const built = require(BUILT_CLI_EXIT_PATH);
|
||||
const scripts = require(SCRIPTS_CLI_EXIT_PATH);
|
||||
for (const version of VERSIONS) {
|
||||
for (const outcome of ['PASS', 'FAIL', ...REGISTERED_NAMES]) {
|
||||
assert.equal(
|
||||
built.projectOutcome(outcome, version), scripts.projectOutcome(outcome, version),
|
||||
`built vs scripts disagree for ${outcome}/${version}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('scripts/lib/cli-exit.cjs + its sibling exit-code-registry.cjs load standalone, no gsd-core sibling', (t) => {
|
||||
const dir = createTempDir('gsd-3906-standalone-');
|
||||
t.after(() => cleanup(dir));
|
||||
const copiedExit = path.join(dir, 'cli-exit.cjs');
|
||||
const copiedRegistry = path.join(dir, 'exit-code-registry.cjs');
|
||||
fs.copyFileSync(SCRIPTS_CLI_EXIT_PATH, copiedExit);
|
||||
fs.copyFileSync(path.resolve(__dirname, '../scripts/lib/exit-code-registry.cjs'), copiedRegistry);
|
||||
|
||||
// This directory contains ONLY these two files — no node_modules, no
|
||||
// gsd-core sibling. A successful load here is the behavioral proof (per
|
||||
// CONTRIBUTING.md's ban on source-grepping a generated .cjs for its
|
||||
// require() specifiers) that this module's only dependencies are
|
||||
// node: builtins plus ./exit-code-registry.cjs: anything else would
|
||||
// MODULE_NOT_FOUND in this exact isolation.
|
||||
const r = toLegacyResult(runNode(['-e', [
|
||||
`const c = require(${JSON.stringify(copiedExit)});`,
|
||||
`process.stdout.write(JSON.stringify({`,
|
||||
` degradedV2: c.projectOutcome('DEGRADED', 'v2'),`,
|
||||
` hasTerminateNow: typeof c.terminateNow,`,
|
||||
` hasRunMain: typeof c.runMain,`,
|
||||
`}));`,
|
||||
].join('\n')], { cwd: dir, timeoutMs: PROBE_TIMEOUT_MS }));
|
||||
|
||||
assert.ok(!r.stderr.includes('MODULE_NOT_FOUND'), `unexpected require failure; stderr: ${r.stderr}`);
|
||||
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
||||
assert.deepEqual(JSON.parse(r.stdout), { degradedV2: 80, hasTerminateNow: 'function', hasRunMain: 'function' });
|
||||
});
|
||||
|
||||
test('the contract-version cell is shared across both copies', () => {
|
||||
const r = toLegacyResult(runNode(['-e', [
|
||||
`const built = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
||||
`const scripts = require(${JSON.stringify(SCRIPTS_CLI_EXIT_PATH)});`,
|
||||
`if (built === scripts) throw new Error('expected two distinct module instances');`,
|
||||
`built.resolveContractVersion({ argv: ['node','x','--exit-contract=v2'], env: {} });`,
|
||||
`process.stdout.write(JSON.stringify({`,
|
||||
` built: built.getContractVersion(),`,
|
||||
` scripts: scripts.getContractVersion(),`,
|
||||
`}));`,
|
||||
].join('\n')], { timeoutMs: PROBE_TIMEOUT_MS }));
|
||||
|
||||
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
||||
assert.deepEqual(
|
||||
JSON.parse(r.stdout),
|
||||
{ built: 'v2', scripts: 'v2' },
|
||||
'both copies must read the version resolved through only ONE of them — two independent cells would diverge here',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,9 @@ const { flatten, findDefaultValueChanges, evaluateDefaultFlipDoc, MANIFEST_PATH
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
const { runNode } = require('./helpers/process-seam.cjs');
|
||||
const { gitOrThrow } = require('./helpers/git-fixture.cjs');
|
||||
const { copyScriptWithDeps } = require('./helpers/copy-script-fixture.cjs');
|
||||
|
||||
const LINT_SCRIPT_REL = path.join('scripts', 'lint-default-flip-documentation.cjs');
|
||||
|
||||
describe('default-flip-documentation lint: flatten (pure)', () => {
|
||||
test('flattens a nested object into dot-path leaves', () => {
|
||||
@@ -121,13 +124,7 @@ describe('default-flip-documentation lint: main() end-to-end wiring', () => {
|
||||
git(tmpDir, 'add', '-A');
|
||||
git(tmpDir, 'commit', '-q', '-m', 'pr');
|
||||
|
||||
const scriptsDir = path.join(tmpDir, 'scripts');
|
||||
const libDir = path.join(scriptsDir, 'lib');
|
||||
fs.mkdirSync(libDir, { recursive: true });
|
||||
const scriptCopy = path.join(scriptsDir, 'lint-default-flip-documentation.cjs');
|
||||
fs.copyFileSync(LINT_SCRIPT, scriptCopy);
|
||||
fs.copyFileSync(path.join(ROOT, 'scripts', 'lib', 'cli-exit.cjs'), path.join(libDir, 'cli-exit.cjs'));
|
||||
return scriptCopy;
|
||||
return copyScriptWithDeps(ROOT, tmpDir, LINT_SCRIPT_REL);
|
||||
}
|
||||
|
||||
function runWithPrBody(tmpDir, scriptCopy, prBody) {
|
||||
|
||||
@@ -52,8 +52,32 @@ function makeEntry(overrides) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* #3906 (ADR-3889 Phase 2): the generator now emits THREE artifacts — a
|
||||
* primary (gsd-core/bin/lib), a secondary (scripts/lib), and the ambient
|
||||
* `.d.cts` type declaration (src/exit-code-registry.d.cts). Every existing
|
||||
* call site below only overrides the PRIMARY path via `--out`; without
|
||||
* matching `--scripts-out`/`--dts-out` overrides, a `--write` here would
|
||||
* clobber the real committed `scripts/lib/exit-code-registry.cjs` and
|
||||
* `src/exit-code-registry.d.cts` — dangerous since test files in this repo
|
||||
* run in parallel. Rather than touch every call site, this single seam
|
||||
* derives co-located, per-call-unique secondary/dts paths from whatever
|
||||
* `--out` value the test already supplies, whenever the caller has not
|
||||
* already supplied its own `--scripts-out`/`--dts-out`. Calls with no
|
||||
* explicit `--out` (the "real committed set" checks) are left untouched.
|
||||
*/
|
||||
function ensureScriptsOut(args) {
|
||||
const outIdx = args.indexOf('--out');
|
||||
if (outIdx === -1) return args;
|
||||
const outValue = args[outIdx + 1];
|
||||
const extra = [];
|
||||
if (!args.includes('--scripts-out')) extra.push('--scripts-out', `${outValue}.secondary.cjs`);
|
||||
if (!args.includes('--dts-out')) extra.push('--dts-out', `${outValue}.d.cts`);
|
||||
return extra.length === 0 ? args : [...args, ...extra];
|
||||
}
|
||||
|
||||
function runGen(args, opts = {}) {
|
||||
return runNode([GEN_SCRIPT, ...args], { timeoutMs: PROBE_TIMEOUT_MS, ...opts });
|
||||
return runNode([GEN_SCRIPT, ...ensureScriptsOut(args)], { timeoutMs: PROBE_TIMEOUT_MS, ...opts });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -366,7 +390,7 @@ describe('gen-exit-code-registry: CLI', () => {
|
||||
return p;
|
||||
}
|
||||
|
||||
test('--check is in sync against the real committed pair', () => {
|
||||
test('--check is in sync against the real committed set (both .cjs artifacts and the .d.cts)', () => {
|
||||
const result = runGen(['--check']);
|
||||
assert.equal(result.exitCode, 0, result.stderr);
|
||||
});
|
||||
@@ -403,12 +427,60 @@ describe('gen-exit-code-registry: CLI', () => {
|
||||
assert.equal(report.reason, generator.REASON.DRIFTED);
|
||||
});
|
||||
|
||||
// Review finding (#3906 follow-up): the ambient .d.cts was hand-maintained
|
||||
// with no gate verifying it against serializeRegistry()'s actual shape.
|
||||
// These pin the SAME write/check/drift contract already proven for the two
|
||||
// .cjs artifacts above, but for the .d.cts specifically.
|
||||
test('--write emits a matching .d.cts artifact', () => {
|
||||
const decl = validDeclarationPath(tmpDir, 'dts-decl.json');
|
||||
const out = path.join(tmpDir, 'dts-out.cjs');
|
||||
const dtsOut = path.join(tmpDir, 'dts-out.d.cts');
|
||||
const write = runGen(['--write', '--declaration', decl, '--out', out, '--dts-out', dtsOut]);
|
||||
assert.equal(write.exitCode, 0, write.stderr);
|
||||
assert.ok(fs.existsSync(dtsOut), 'expected the .d.cts artifact to be written');
|
||||
const dtsContent = fs.readFileSync(dtsOut, 'utf8');
|
||||
assert.ok(dtsContent.includes('export interface ExitCodeEntry'));
|
||||
assert.ok(dtsContent.includes('export = exitCodeRegistry;'));
|
||||
|
||||
const check = runGen(['--check', '--declaration', decl, '--out', out, '--dts-out', dtsOut]);
|
||||
assert.equal(check.exitCode, 0, check.stderr);
|
||||
});
|
||||
|
||||
test('--check on a hand-edited .d.cts artifact -> DRIFTED', () => {
|
||||
const decl = validDeclarationPath(tmpDir, 'dts-drift-decl.json');
|
||||
const out = path.join(tmpDir, 'dts-drift-out.cjs');
|
||||
const dtsOut = path.join(tmpDir, 'dts-drift-out.d.cts');
|
||||
assert.equal(runGen(['--write', '--declaration', decl, '--out', out, '--dts-out', dtsOut]).exitCode, 0);
|
||||
fs.appendFileSync(dtsOut, '\n// hand-edited, drifts from generated content\n');
|
||||
const { result, report } = runGenJson(['--check', '--declaration', decl, '--out', out, '--dts-out', dtsOut]);
|
||||
assert.equal(result.exitCode, 1);
|
||||
assert.equal(report.ok, false);
|
||||
assert.equal(report.reason, generator.REASON.DRIFTED);
|
||||
assert.equal(report.context.artifact, 'dts');
|
||||
});
|
||||
|
||||
test('--check with the .d.cts artifact absent -> MISSING_ARTIFACT', () => {
|
||||
const decl = validDeclarationPath(tmpDir, 'dts-missing-decl.json');
|
||||
const out = path.join(tmpDir, 'dts-missing-out.cjs');
|
||||
const dtsOut = path.join(tmpDir, 'dts-missing-out.d.cts');
|
||||
const { result, report } = runGenJson(['--check', '--declaration', decl, '--out', out, '--dts-out', dtsOut]);
|
||||
assert.equal(result.exitCode, 1);
|
||||
assert.equal(report.ok, false);
|
||||
assert.equal(report.reason, generator.REASON.MISSING_ARTIFACT);
|
||||
assert.equal(fs.existsSync(dtsOut), false);
|
||||
});
|
||||
|
||||
test('--check with a stale artifact (declaration changed after write) -> DRIFTED', () => {
|
||||
const decl = validDeclarationPath(tmpDir, 'd-decl.json');
|
||||
const out = path.join(tmpDir, 'd-out.cjs');
|
||||
assert.equal(runGen(['--write', '--declaration', decl, '--out', out]).exitCode, 0);
|
||||
const entries = JSON.parse(fs.readFileSync(decl, 'utf8'));
|
||||
entries.push({ code: 80, name: 'DOMAIN_X', meaning: 'm', owner: 'domain-x', authorizedBy: 'ADR-3889' });
|
||||
// 81, not 80: the real declaration already allocates 80 to DEGRADED, and
|
||||
// this fixture copies the REAL declaration (validDeclarationPath) — an
|
||||
// appended entry must pick a code neither of the two committed entries
|
||||
// already own, or the generator correctly reports fail_duplicate_code
|
||||
// instead of the DRIFTED this test means to exercise.
|
||||
entries.push({ code: 81, name: 'DOMAIN_X', meaning: 'm', owner: 'domain-x', authorizedBy: 'ADR-3889' });
|
||||
fs.writeFileSync(decl, JSON.stringify(entries, null, 2), 'utf8');
|
||||
const { result, report } = runGenJson(['--check', '--declaration', decl, '--out', out]);
|
||||
assert.equal(result.exitCode, 1);
|
||||
|
||||
1
tests/fixtures/install-tree/antigravity.json
vendored
1
tests/fixtures/install-tree/antigravity.json
vendored
@@ -427,6 +427,7 @@
|
||||
"scripts/lib/allowlist-ratchet.cjs",
|
||||
"scripts/lib/cli-exit.cjs",
|
||||
"scripts/lib/drift-scan.cjs",
|
||||
"scripts/lib/exit-code-registry.cjs",
|
||||
"skills/gsd-add-tests/SKILL.md",
|
||||
"skills/gsd-ai-integration-phase/SKILL.md",
|
||||
"skills/gsd-audit-fix/SKILL.md",
|
||||
|
||||
1
tests/fixtures/install-tree/augment.json
vendored
1
tests/fixtures/install-tree/augment.json
vendored
@@ -497,6 +497,7 @@
|
||||
"scripts/lib/allowlist-ratchet.cjs",
|
||||
"scripts/lib/cli-exit.cjs",
|
||||
"scripts/lib/drift-scan.cjs",
|
||||
"scripts/lib/exit-code-registry.cjs",
|
||||
"skills/gsd-ns-context/SKILL.md",
|
||||
"skills/gsd-ns-context/skills/docs-update/SKILL.md",
|
||||
"skills/gsd-ns-context/skills/extract-learnings/SKILL.md",
|
||||
|
||||
@@ -496,5 +496,6 @@
|
||||
"scripts/lib/alias-drift-families.cjs",
|
||||
"scripts/lib/allowlist-ratchet.cjs",
|
||||
"scripts/lib/cli-exit.cjs",
|
||||
"scripts/lib/drift-scan.cjs"
|
||||
"scripts/lib/drift-scan.cjs",
|
||||
"scripts/lib/exit-code-registry.cjs"
|
||||
]
|
||||
|
||||
1
tests/fixtures/install-tree/claude.json
vendored
1
tests/fixtures/install-tree/claude.json
vendored
@@ -426,6 +426,7 @@
|
||||
"scripts/lib/allowlist-ratchet.cjs",
|
||||
"scripts/lib/cli-exit.cjs",
|
||||
"scripts/lib/drift-scan.cjs",
|
||||
"scripts/lib/exit-code-registry.cjs",
|
||||
"skills/gsd-add-tests/SKILL.md",
|
||||
"skills/gsd-ai-integration-phase/SKILL.md",
|
||||
"skills/gsd-audit-fix/SKILL.md",
|
||||
|
||||
1
tests/fixtures/install-tree/cline.json
vendored
1
tests/fixtures/install-tree/cline.json
vendored
@@ -393,6 +393,7 @@
|
||||
"scripts/lib/allowlist-ratchet.cjs",
|
||||
"scripts/lib/cli-exit.cjs",
|
||||
"scripts/lib/drift-scan.cjs",
|
||||
"scripts/lib/exit-code-registry.cjs",
|
||||
"skills/gsd-ns-context/SKILL.md",
|
||||
"skills/gsd-ns-context/skills/docs-update/SKILL.md",
|
||||
"skills/gsd-ns-context/skills/extract-learnings/SKILL.md",
|
||||
|
||||
1
tests/fixtures/install-tree/codebuddy.json
vendored
1
tests/fixtures/install-tree/codebuddy.json
vendored
@@ -497,6 +497,7 @@
|
||||
"scripts/lib/allowlist-ratchet.cjs",
|
||||
"scripts/lib/cli-exit.cjs",
|
||||
"scripts/lib/drift-scan.cjs",
|
||||
"scripts/lib/exit-code-registry.cjs",
|
||||
"skills/gsd-add-tests/SKILL.md",
|
||||
"skills/gsd-ai-integration-phase/SKILL.md",
|
||||
"skills/gsd-audit-fix/SKILL.md",
|
||||
|
||||
3
tests/fixtures/install-tree/codex.json
vendored
3
tests/fixtures/install-tree/codex.json
vendored
@@ -431,5 +431,6 @@
|
||||
"scripts/lib/alias-drift-families.cjs",
|
||||
"scripts/lib/allowlist-ratchet.cjs",
|
||||
"scripts/lib/cli-exit.cjs",
|
||||
"scripts/lib/drift-scan.cjs"
|
||||
"scripts/lib/drift-scan.cjs",
|
||||
"scripts/lib/exit-code-registry.cjs"
|
||||
]
|
||||
|
||||
1
tests/fixtures/install-tree/copilot.json
vendored
1
tests/fixtures/install-tree/copilot.json
vendored
@@ -393,6 +393,7 @@
|
||||
"scripts/lib/allowlist-ratchet.cjs",
|
||||
"scripts/lib/cli-exit.cjs",
|
||||
"scripts/lib/drift-scan.cjs",
|
||||
"scripts/lib/exit-code-registry.cjs",
|
||||
"skills/gsd-add-tests/SKILL.md",
|
||||
"skills/gsd-ai-integration-phase/SKILL.md",
|
||||
"skills/gsd-audit-fix/SKILL.md",
|
||||
|
||||
1
tests/fixtures/install-tree/cursor.json
vendored
1
tests/fixtures/install-tree/cursor.json
vendored
@@ -401,6 +401,7 @@
|
||||
"scripts/lib/allowlist-ratchet.cjs",
|
||||
"scripts/lib/cli-exit.cjs",
|
||||
"scripts/lib/drift-scan.cjs",
|
||||
"scripts/lib/exit-code-registry.cjs",
|
||||
"skills/gsd-add-tests/SKILL.md",
|
||||
"skills/gsd-ai-integration-phase/SKILL.md",
|
||||
"skills/gsd-audit-fix/SKILL.md",
|
||||
|
||||
1
tests/fixtures/install-tree/hermes.json
vendored
1
tests/fixtures/install-tree/hermes.json
vendored
@@ -426,6 +426,7 @@
|
||||
"scripts/lib/allowlist-ratchet.cjs",
|
||||
"scripts/lib/cli-exit.cjs",
|
||||
"scripts/lib/drift-scan.cjs",
|
||||
"scripts/lib/exit-code-registry.cjs",
|
||||
"skills/gsd/DESCRIPTION.md",
|
||||
"skills/gsd/gsd-ns-context/SKILL.md",
|
||||
"skills/gsd/gsd-ns-context/skills/docs-update/SKILL.md",
|
||||
|
||||
1
tests/fixtures/install-tree/kilo.json
vendored
1
tests/fixtures/install-tree/kilo.json
vendored
@@ -500,6 +500,7 @@
|
||||
"scripts/lib/allowlist-ratchet.cjs",
|
||||
"scripts/lib/cli-exit.cjs",
|
||||
"scripts/lib/drift-scan.cjs",
|
||||
"scripts/lib/exit-code-registry.cjs",
|
||||
"skills/gsd-add-tests/SKILL.md",
|
||||
"skills/gsd-ai-integration-phase/SKILL.md",
|
||||
"skills/gsd-audit-fix/SKILL.md",
|
||||
|
||||
1
tests/fixtures/install-tree/kimi-code.json
vendored
1
tests/fixtures/install-tree/kimi-code.json
vendored
@@ -427,6 +427,7 @@
|
||||
"scripts/lib/allowlist-ratchet.cjs",
|
||||
"scripts/lib/cli-exit.cjs",
|
||||
"scripts/lib/drift-scan.cjs",
|
||||
"scripts/lib/exit-code-registry.cjs",
|
||||
"skills/gsd-add-tests/SKILL.md",
|
||||
"skills/gsd-ai-integration-phase/SKILL.md",
|
||||
"skills/gsd-audit-fix/SKILL.md",
|
||||
|
||||
1
tests/fixtures/install-tree/kimi.json
vendored
1
tests/fixtures/install-tree/kimi.json
vendored
@@ -428,6 +428,7 @@
|
||||
"scripts/lib/allowlist-ratchet.cjs",
|
||||
"scripts/lib/cli-exit.cjs",
|
||||
"scripts/lib/drift-scan.cjs",
|
||||
"scripts/lib/exit-code-registry.cjs",
|
||||
"skills/gsd-add-tests/SKILL.md",
|
||||
"skills/gsd-ai-integration-phase/SKILL.md",
|
||||
"skills/gsd-audit-fix/SKILL.md",
|
||||
|
||||
1
tests/fixtures/install-tree/opencode.json
vendored
1
tests/fixtures/install-tree/opencode.json
vendored
@@ -500,6 +500,7 @@
|
||||
"scripts/lib/allowlist-ratchet.cjs",
|
||||
"scripts/lib/cli-exit.cjs",
|
||||
"scripts/lib/drift-scan.cjs",
|
||||
"scripts/lib/exit-code-registry.cjs",
|
||||
"skills/gsd-add-tests/SKILL.md",
|
||||
"skills/gsd-ai-integration-phase/SKILL.md",
|
||||
"skills/gsd-audit-fix/SKILL.md",
|
||||
|
||||
3
tests/fixtures/install-tree/pi.json
vendored
3
tests/fixtures/install-tree/pi.json
vendored
@@ -392,5 +392,6 @@
|
||||
"scripts/lib/alias-drift-families.cjs",
|
||||
"scripts/lib/allowlist-ratchet.cjs",
|
||||
"scripts/lib/cli-exit.cjs",
|
||||
"scripts/lib/drift-scan.cjs"
|
||||
"scripts/lib/drift-scan.cjs",
|
||||
"scripts/lib/exit-code-registry.cjs"
|
||||
]
|
||||
|
||||
1
tests/fixtures/install-tree/qwen.json
vendored
1
tests/fixtures/install-tree/qwen.json
vendored
@@ -426,6 +426,7 @@
|
||||
"scripts/lib/allowlist-ratchet.cjs",
|
||||
"scripts/lib/cli-exit.cjs",
|
||||
"scripts/lib/drift-scan.cjs",
|
||||
"scripts/lib/exit-code-registry.cjs",
|
||||
"skills/gsd-ns-context/SKILL.md",
|
||||
"skills/gsd-ns-context/skills/docs-update/SKILL.md",
|
||||
"skills/gsd-ns-context/skills/extract-learnings/SKILL.md",
|
||||
|
||||
1
tests/fixtures/install-tree/trae.json
vendored
1
tests/fixtures/install-tree/trae.json
vendored
@@ -391,6 +391,7 @@
|
||||
"scripts/lib/allowlist-ratchet.cjs",
|
||||
"scripts/lib/cli-exit.cjs",
|
||||
"scripts/lib/drift-scan.cjs",
|
||||
"scripts/lib/exit-code-registry.cjs",
|
||||
"skills/gsd-ns-context/SKILL.md",
|
||||
"skills/gsd-ns-context/skills/docs-update/SKILL.md",
|
||||
"skills/gsd-ns-context/skills/extract-learnings/SKILL.md",
|
||||
|
||||
3
tests/fixtures/install-tree/windsurf.json
vendored
3
tests/fixtures/install-tree/windsurf.json
vendored
@@ -393,5 +393,6 @@
|
||||
"scripts/lib/alias-drift-families.cjs",
|
||||
"scripts/lib/allowlist-ratchet.cjs",
|
||||
"scripts/lib/cli-exit.cjs",
|
||||
"scripts/lib/drift-scan.cjs"
|
||||
"scripts/lib/drift-scan.cjs",
|
||||
"scripts/lib/exit-code-registry.cjs"
|
||||
]
|
||||
|
||||
1
tests/fixtures/install-tree/zcode.json
vendored
1
tests/fixtures/install-tree/zcode.json
vendored
@@ -462,6 +462,7 @@
|
||||
"scripts/lib/allowlist-ratchet.cjs",
|
||||
"scripts/lib/cli-exit.cjs",
|
||||
"scripts/lib/drift-scan.cjs",
|
||||
"scripts/lib/exit-code-registry.cjs",
|
||||
"skills/gsd-ns-context/SKILL.md",
|
||||
"skills/gsd-ns-context/skills/docs-update/SKILL.md",
|
||||
"skills/gsd-ns-context/skills/extract-learnings/SKILL.md",
|
||||
|
||||
@@ -23,6 +23,9 @@ const LINT_SCRIPT = path.join(ROOT, 'scripts', 'lint-frontmatter-scalar-broad-gr
|
||||
const { findBroadGrepsInBlock, extractBashBlocks, scan } = require(LINT_SCRIPT);
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
const { runNode } = require('./helpers/process-seam.cjs');
|
||||
const { copyScriptWithDeps } = require('./helpers/copy-script-fixture.cjs');
|
||||
|
||||
const LINT_SCRIPT_REL = path.join('scripts', 'lint-frontmatter-scalar-broad-grep.cjs');
|
||||
|
||||
describe('frontmatter-scalar-broad-grep lint: findBroadGrepsInBlock (pure)', () => {
|
||||
test('the real #586/#651 defect shape IS flagged: whole-file grep, no scope, no -m1, piped to cut|tr', () => {
|
||||
@@ -142,12 +145,7 @@ describe('frontmatter-scalar-broad-grep lint: main() end-to-end wiring', () => {
|
||||
'```',
|
||||
].join('\n'),
|
||||
);
|
||||
const scriptCopyDir = path.join(tmpDir, 'scripts');
|
||||
fs.mkdirSync(scriptCopyDir, { recursive: true });
|
||||
const scriptCopy = path.join(scriptCopyDir, 'lint-frontmatter-scalar-broad-grep.cjs');
|
||||
fs.copyFileSync(LINT_SCRIPT, scriptCopy);
|
||||
fs.mkdirSync(path.join(scriptCopyDir, 'lib'), { recursive: true });
|
||||
fs.copyFileSync(path.join(ROOT, 'scripts', 'lib', 'cli-exit.cjs'), path.join(scriptCopyDir, 'lib', 'cli-exit.cjs'));
|
||||
const scriptCopy = copyScriptWithDeps(ROOT, tmpDir, LINT_SCRIPT_REL);
|
||||
|
||||
const result = runNode([scriptCopy]);
|
||||
assert.equal(result.exitCode, 1, `expected exit 1, got ${result.exitCode}`);
|
||||
@@ -168,12 +166,7 @@ describe('frontmatter-scalar-broad-grep lint: main() end-to-end wiring', () => {
|
||||
'```',
|
||||
].join('\n'),
|
||||
);
|
||||
const scriptCopyDir = path.join(tmpDir, 'scripts');
|
||||
fs.mkdirSync(scriptCopyDir, { recursive: true });
|
||||
const scriptCopy = path.join(scriptCopyDir, 'lint-frontmatter-scalar-broad-grep.cjs');
|
||||
fs.copyFileSync(LINT_SCRIPT, scriptCopy);
|
||||
fs.mkdirSync(path.join(scriptCopyDir, 'lib'), { recursive: true });
|
||||
fs.copyFileSync(path.join(ROOT, 'scripts', 'lib', 'cli-exit.cjs'), path.join(scriptCopyDir, 'lib', 'cli-exit.cjs'));
|
||||
const scriptCopy = copyScriptWithDeps(ROOT, tmpDir, LINT_SCRIPT_REL);
|
||||
|
||||
const result = runNode([scriptCopy]);
|
||||
assert.equal(result.exitCode, 0, `expected exit 0, got ${result.exitCode}: ${result.stderr}`);
|
||||
|
||||
Reference in New Issue
Block a user