Files
msd-core/src/prohibition-enforcement.cts
Tom Boucher 5e2055ab90 fix(#3660): reap a bounded check's orphaned worker after its own timeout kill (#4615)
* test(#3660): prove a bounded node-test check orphans its worker on timeout

Regression test only, no fix yet: `execFileSync`'s timeout kills the direct
`node --test` runner but never the per-file worker it forks by default since
Node 22 (`--test-isolation=process`). The worker is reparented to PID 1 and
can busy-loop forever while the bounded-check verdict still reports a clean
fail-closed timeout.

Adds three tests driven through the real, uninjected defaultRunCheck path:
a hanging subject's worker must not survive the call, a control proving the
liveness probe can actually distinguish alive-vs-dead, and a non-hanging
failure proving the reap-gating logic added by the next commit doesn't
change the ordinary-failure return shape.

Expected RED on this commit (src/prohibition-enforcement.cts is unchanged).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#3660): reap a bounded check's descendant worker after its own timeout kill

execFileSync's timeout only signals the direct child (the node --test runner);
since Node 22, node --test forks a per-file WORKER by default
(--test-isolation=process), so a hung subject's worker survives the bound,
gets reparented to PID 1, and busy-loops forever while the verdict still
reports a clean fail-closed timeout.

Adds execFileSyncReaping (wraps execFileSync, detached:true on POSIX) and
reapDescendants(pid): POSIX process.kill(-pid, 'SIGKILL') against the
process group, Windows an absolute-path taskkill /PID <pid> /T /F (never a
bare PATH-resolved name -- PR #3681 review minor-9). The reap fires ONLY
when this call's own timeout killed the child (the thrown error carries a
signal) -- an ordinary non-zero-exit failure has signal:null and is left
alone, which is the fix for PR #3681's Blocker-3 (that attempt reaped on
every throw, risking a PGID-reuse collateral kill on a ordinary red run).

All four execFileSync(process.execPath, ...) call sites now route through
execFileSyncReaping: runNodeTestWithSubject, defaultRunCheck's node-test and
lint-rule arms, defaultProveFailFirst's lint-rule arm (its node-test arm
reuses runNodeTestWithSubject).

RED proven on 0bd741fbc2b2ad0792fdf2361de14b68b2e3aea3 (test-only commit,
gsd-test outcome:failed, exactly the new orphan-detection test failing).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#3660): address code-review nits on the reap doc comments

- Clarify execFileSyncReaping's gate covers a maxBuffer-triggered kill too,
  not just a timeout -- both set .signal, both are "this call's own bound".
- Note reapDescendants' POSIX catch swallows any errno, not only ESRCH.

No behavior change (tsc --noEmit clean, no-op for the compiler).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* debug(#3660): fix hardcoded Windows path + add temp diagnostics for CI reap failure

Real defect #1 (fixed for good): taskkillPath() had a 'C:\Windows' literal
fallback, tripping tests/hardcoded-paths.test.cjs's repo-wide scanner. Now
returns null when neither SystemRoot nor windir is set, and the caller skips
the Windows reap rather than guessing a path.

Real defect #2 (under investigation): the prior GREEN gsd-test run showed the
#3660 orphan-detection test STILL failing on linux-node24 even with the fix
applied -- the worker survived. Isolated diagnostic scripts against the exact
same execFileSync({detached:true})+process.kill(-pid) mechanism, including
one using a REAL node --test worker, both confirm the mechanism works
correctly on macOS (group-kill reaches the worker). This commit adds
TEMPORARY stderr instrumentation (GSD-DEBUG-3660 tags) around the reap
attempt to get direct evidence from the actual Linux CI environment before
guessing further. Will be removed once the root cause is confirmed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#3660): gate the reap on err.code === 'ETIMEDOUT', not err.signal

Root cause of the prior GREEN run's failure, confirmed with real evidence
from linux-node24 CI: execFileSync's thrown error on a genuine timeout-kill
does NOT reliably set `.signal` -- on that environment it came back
`signal: null, code: 'ETIMEDOUT', status: 7`, so the reap gate never fired.
A separate macOS/Node run of the identical scenario showed `signal: 'SIGTERM'`
for the same case -- neither field alone is safe across platforms/versions,
but `code === 'ETIMEDOUT'` was present and correct in both. Verified via
temporary stderr instrumentation on a real gsd-test run (now removed) before
landing this, rather than guessing from the macOS-only result.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* debug(#3660): round-2 instrumentation -- ETIMEDOUT gate fix alone didn't work

The err.code === 'ETIMEDOUT' gate fix (previous commit) did not resolve the
failure -- same test still red on real Linux CI. Adding probes around the
actual process.kill(-pid, 'SIGKILL') call itself to see whether it throws,
and whether the group is observably alive/dead before and after, since the
gate may now be firing correctly but the kill may not be reaching the
worker's process group on this environment. Temporary, will be removed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#3660): the fix was already correct -- the TEST's liveness probe was not

Root cause of the two prior red rounds, confirmed via process-group probes on
real Linux CI: process.kill(-pid, 'SIGKILL') succeeds (no throw) every time
the ETIMEDOUT gate fires -- the worker genuinely IS killed. But
process.kill(pid, 0) cannot tell a truly-running process from an
already-killed ZOMBIE stuck unreaped: this bench's container has no init
process collecting arbitrary orphans, so a killed worker (reparented to PID 1
on death) sits as a zombie forever, still answering kill(pid,0) with "exists"
even though it is fully dead and burning zero CPU -- which is the actual harm
#3660 is about.

Test now reads /proc/<pid>/stat's process-state field on Linux and treats 'Z'
(zombie) as dead, falling back to the plain kill(pid,0) probe elsewhere (no
/proc on macOS/Windows).

Also strips the round-2 GSD-DEBUG-3660b instrumentation now that its evidence
has been used and the real root cause is fixed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#3660): merge duplicate doc comment, fix stale err.signal reference

Leftover artifacts from the multi-round debugging: taskkillPath had two
stacked doc comments (an edit only replaced the function body, not the
original comment above it); a test comment still said "err.signal" after
the gate was changed to err.code === 'ETIMEDOUT'. Comment-only, no behavior
change (tsc --noEmit no-op).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#3660): close a vacuous-test gap and harden isAlive's error handling

Code review finding (major): none of the three #3660 regression tests ever
asserted isAlive(pid) === true for a genuinely running process -- the real
code path is fully synchronous, so there's no natural window to observe
"alive" before "dead" inside those tests. A probe that always returned false
would have passed all three vacuously. Added a standalone test proving
isAlive(process.pid) reports true, using this test's own unambiguously-alive
process, running before the three existing tests.

Also hardened isAlive's /proc read-failure handling (minor finding): only
ENOENT (process genuinely gone) now means "dead"; any other read error
(EACCES, EIO, ...) reports "alive" (inconclusive) rather than risking a
false "dead" that would silently mask a real regression.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#3660): add changeset fragment

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#3660): backfill changeset PR number

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#3660): bound the taskkill spawnSync with a timeout

CI caught it: local/require-subprocess-timeout (DEFECT.UNBOUNDED-SUBPROCESS)
flagged the new spawnSync(taskkill, ...) call in reapDescendants' Windows
branch for having no timeout. 5s bound -- a local OS command, not a network
call; reapDescendants already treats any failure (including a hypothetical
hang) identically via its existing try/catch, so the bound costs nothing and
just prevents a stuck taskkill from blocking the caller forever.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 17:55:05 -04:00

875 lines
48 KiB
TypeScript

/**
* prohibition-enforcement — the deterministic PRODUCER for test-tier prohibition verification
* (#1259, ADR-550 Decision 5d "heavy half"; the D1 seam — a NEW deterministic gsd-tools
* sub-command, NOT free-form workflow prose).
*
* Today `dispositionForProhibition()` (src/probe-core.cts) already carries the POLICY seam: with
* non-empty `enforcementEvidence` AND `tier === 'test'` it returns `{ status: 'green' }` (the
* branch at probe-core 420-427); with empty evidence it fails closed to flagged-unverified. But
* NOTHING in the live pipeline ever produced `enforcementEvidence`, so the green branch was
* unreachable. This module is the missing producer: it LOCATES the wired mechanical check from a
* check descriptor, RUNS it and requires a genuine NON-VACUOUS pass, builds a typed
* `enforcementEvidence` array on PASS, and emits the `dispositionForProhibition` verdict as JSON.
* The green/fail-closed policy itself is untouched (no src/probe-core.cts edit).
*
* Accepts BOTH wired-check kinds (ADR-550 D2): a `node --test` negative test OR a lint/AST rule
* (e.g. the in-tree `local/no-source-grep` rule — the D4 dogfood anchor, run via the project flat
* config so the plugin loads). A missing, non-attested, or genuinely-non-passing check hard-gates
* (flagged, non-green) in BOTH interactive and autonomous modes (ADR-550 D4 / D3) — never a silent
* green.
*
* FAIL-FIRST IS MACHINE-PROVEN (#1279): the producer no longer greens on caller attestation. The
* green verdict requires the injectable prover (`proveFailFirst`, default `defaultProveFailFirst`) to
* INDEPENDENTLY run the check against a known violation fixture and observe it go red, AND the runner
* to observe a real non-vacuous pass: `passed = proof.provenFailFirst === true && run.passed === true`.
* Caller attestation (`CheckDescriptor.failFirst`) is demoted to a non-authoritative hint kept only
* for backward route-JSON shape — no path greens on it alone (FF-08). The proof method is recorded in
* the evidence (`failFirstProof`, FF-07). This replaces the #1259 caller-attested red-first property
* with machine proof, closing the gap the ADR-550 D5d note tracked as a follow-up.
*
* Authored as strict TypeScript (`src/prohibition-enforcement.cts`) and compiled by
* `tsc -p tsconfig.build.json` (`npm run build:lib`) to the gitignored runtime artifact
* `gsd-core/bin/lib/prohibition-enforcement.cjs`. Do NOT hand-write the `.cjs`; it is emitted.
*
* DETERMINISM SCOPE: the DECISION layer is pure/deterministic and no-LLM — given a `runCheck` result
* the disposition is same-input-same-output and mutation-survivable, and the parse/filter helpers
* (`parseNodeTestSummary`, `tapTestNames`, `eslintJsonHasRule`, `eslintHasFatalError`, …) are pure.
* The DEFAULT REAL runner is NOT pure — it spawns `node --test` / eslint, so its result depends on the
* environment (eslint version + flat config, node version, the target file). That is why the runner is
* an injectable seam (`runCheck`): the contract is unit-tested against injected results, mirroring the
* injectable I/O pattern in `runProbeCli` / `ProbeCliOptions`.
*/
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync, spawnSync } from 'node:child_process';
// Import the leaf I/O module directly (core.cjs re-export spine retired in epic #1267).
// eslint-disable-next-line @typescript-eslint/no-require-imports
import io = require('./io.cjs');
const { output, error, ERROR_REASON } = io;
import { dispositionForProhibition } from './probe-core.cjs';
import type { ProhibitionDisposition } from './probe-core.cjs';
/** The two accepted wired-check kinds (ADR-550 D2). */
export type CheckKind = 'node-test' | 'lint-rule';
/**
* A descriptor of the wired mechanical check that asserts the must-NOT. `kind` selects the
* runner family; `target` is the negative-test file path (node-test) or the PATH to lint
* (lint-rule); `rule` is the eslint rule id (lint-rule only — e.g. `local/no-source-grep`) and is
* REQUIRED for the lint-rule kind (a lint-rule descriptor without it is not a valid wired check);
* `failFirst` is a DEMOTED, non-authoritative hint kept only for backward route-JSON shape — the
* machine prover (`proveFailFirst`) supersedes it, and NO path greens on attestation alone (FF-08,
* #1279). The descriptor's `violationFixture` is what the prover actually uses to prove fail-first.
*/
export interface CheckDescriptor {
kind: CheckKind;
target: string;
rule?: string;
failFirst?: boolean;
/**
* Author-supplied path to a KNOWN-BAD subject the prover runs the check against to machine-prove
* the check is fail-first (#1279). For `lint-rule`: a file whose content violates `rule` (the
* prover lints it and requires the rule id to appear). For `node-test`: a subject the negative
* test exercises via the `GSD_PROHIB_SUBJECT` env convention, expected to drive the test RED.
* A generic producer cannot synthesize a violation for an arbitrary check, so this is required to
* prove fail-first; ABSENT for node-test → the default prover fails closed (never attestation).
*/
violationFixture?: string;
/**
* Author-supplied path to a KNOWN-CLEAN control subject (#1346; MANDATORY for the node-test kind as
* of #1906). The prover runs the check against it as a CAUSATION CONTROL and requires it to stay
* GREEN — proof that the RED on `violationFixture` was caused by the subject's CONTENT, not merely by
* `GSD_PROHIB_SUBJECT` being set. A deceptive content-independent check reds on the clean subject too
* → control fails → not proven. For the `node-test` kind this is now REQUIRED: ABSENT → the node-test
* is UN-PROVABLE (fail-closed), never accepted under the weaker violation-only proof (#1906 supersedes
* #1346's opt-in; ADR-1606 Decision 4). A supplied-but-missing path fails closed. The field stays
* optional at the type level because the `lint-rule` kind needs no analog (its subject IS the linted
* file — no `GSD_PROHIB_SUBJECT` indirection, so the "reds because the env var is set" gap can't exist).
*/
cleanFixture?: string;
}
/**
* READ-BACK ADAPTER (#1278, plan 01-03): reconstruct a `CheckDescriptor` from the flat scalar keys
* `projectProhibitions` emits onto a prohibition item (`check_kind` / `check_target` / `check_rule`,
* src/probe-core.cts). This is the deterministic bridge from the projected descriptor back into the
* merged #1259 producer request — the verify-phase caller reads the projected scalars, this rebuilds
* the `{ kind, target, rule? }` request, and `runProhibitionEnforcement`'s EXISTING fail-closed LOCATE
* guard (validKind/validTarget/validRule, below) is the single source of fail-closed truth.
*
* Contract:
* - `null`/`undefined`/non-object input -> `null`.
* - `check_kind` ABSENT -> `null` (no descriptor -> producer locates nothing -> fail-closed).
* - `check_kind` present -> `{ kind: check_kind, target: check_target }`, adding `rule: check_rule`
* ONLY when `check_rule` is a non-empty string, `violationFixture: check_violation_fixture`
* ONLY when that scalar is a non-empty string (composes #1278 locate with #1279 proof), and
* `cleanFixture: check_clean_fixture` ONLY when that scalar is non-empty (#1346 causation control).
* - `failFirst` is NEVER sourced from the projection — it stays a verify-time caller attestation
* (#1279 machine-proves it; out of scope here). The returned descriptor carries no `failFirst`.
* - The adapter does NOT strictly validate kind/target/rule: it faithfully reconstructs whatever
* scalars are present (e.g. `{check_kind:'lint-rule', check_target:'src/'}` with no `check_rule`
* reconstructs to `{kind:'lint-rule', target:'src/'}`), letting the existing LOCATE guard reject
* an under-specified descriptor (located:false, never green). It does NOT re-implement that guard.
* - Pure, deterministic, no-throw.
*/
export function descriptorFromProjection(
projected: Record<string, unknown> | null | undefined,
): CheckDescriptor | null {
if (!projected || typeof projected !== 'object') return null;
if (!('check_kind' in projected)) return null;
// The shared `parseMustHavesBlock` (src/frontmatter.cts) coerces /^\d+$/ scalar values to NUMBERS on
// round-trip, so a numeric-looking check_kind/check_target/check_rule arrives here as a number. Normalize
// ONLY string|number scalars back to string — a non-scalar (object/array/bool) or absent value yields ''
// (never an `[object Object]` stringification, no `as string` lie over a number). This keeps the
// descriptor honestly typed and the round-trip lossless across the full string domain; an under-specified
// '' target/kind is rejected by the producer's locate guard (fail-closed; never green).
const scalar = (v: unknown): string =>
typeof v === 'string' ? v : typeof v === 'number' ? String(v) : '';
const kind = scalar(projected.check_kind) as CheckKind;
const target = scalar(projected.check_target);
const descriptor: CheckDescriptor = { kind, target };
// `rule` belongs only to the lint-rule kind; a stray check_rule on a node-test descriptor is dropped
// (the projector never emits one there — defense in depth). failFirst is NOT sourced here (#1279).
if (kind === 'lint-rule') {
const rule = scalar(projected.check_rule);
if (rule.trim().length > 0) descriptor.rule = rule;
}
// `violationFixture` (#1346) rides BOTH kinds — reconstruct it from `check_violation_fixture` so the
// deterministic #1278 locate path and the #1279 machine-proof COMPOSE: a projected fixture lets the
// default prover green end-to-end with zero hand-authoring. Absent/blank -> no fixture -> the prover
// hard-gates (fail-closed; green requires a fixture), never fabricated.
const fixture = scalar(projected.check_violation_fixture);
if (fixture.trim().length > 0) descriptor.violationFixture = fixture;
// `cleanFixture` (#1346) rides BOTH kinds — reconstruct it from `check_clean_fixture` so the
// causation control runs end-to-end: when present the prover also requires the check to stay GREEN
// against this known-clean subject (proving the violation RED is content-dependent). Absent/blank ->
// no control (the documented residual remains; backward-compatible with the #1314 compose path).
const clean = scalar(projected.check_clean_fixture);
if (clean.trim().length > 0) descriptor.cleanFixture = clean;
return descriptor;
}
/**
* The result a check-runner returns: whether the check genuinely, non-vacuously PASSED. The runner
* reports only what it can OBSERVE (a real clean pass) — it does NOT determine fail-first. Whether the
* check is a `regression-must-fail-first` proof is now MACHINE-PROVEN by the separate `proveFailFirst`
* seam (#1279), which runs the check against the descriptor's `violationFixture` and observes it go
* red — not the caller's word (`CheckDescriptor.failFirst` is a demoted, non-authoritative hint).
*/
export interface CheckRunResult {
passed: boolean;
}
/** A single typed enforcement-evidence record (the array `dispositionForProhibition` reads). */
export interface EnforcementEvidence {
kind: CheckKind;
target: string;
rule?: string;
/** Machine-proven fail-first (#1279): true ONLY when the prover observed the check go red on a
* violation. No longer the caller's attestation — a green item always carries a proven `true`. */
failFirst: boolean;
passed: boolean;
/** HOW fail-first was machine-proven (FF-07) — recorded from the proof result for provenance.
* Additive; keeps the array shape `dispositionForProhibition` reads stable. */
failFirstProof?: FailFirstProof['method'];
}
/**
* The result of machine-proving a check is fail-first (#1279). `provenFailFirst` is true ONLY when
* the prover independently ran the check against a KNOWN VIOLATION and observed it go red (lint rule
* flags the violation / negative test fails). `method` records HOW it was proven, for evidence
* provenance. Any un-provable outcome (no fixture, toothless rule, throw, timeout) → `provenFailFirst:
* false` (fail-closed) so attestation can never silently green a toothless check.
*/
export interface FailFirstProof {
provenFailFirst: boolean;
method?: 'violation-fixture';
}
/** Injectable options for `runProhibitionEnforcement` (defaults wire to the real runner). */
export interface EnforcementOptions {
/** Runs the located check; injected in tests so no real subprocess is spawned. */
runCheck?: (check: CheckDescriptor) => CheckRunResult;
/**
* Machine-proves the located check is fail-first by running it against the descriptor's
* `violationFixture` and confirming it goes red (#1279). Injected in tests so no real subprocess is
* spawned; defaults to the real `defaultProveFailFirst`. Mirrors the `runCheck` seam. Plan 03 wires
* the result into the green decision; this plan only constructs the prover.
*/
proveFailFirst?: (check: CheckDescriptor) => FailFirstProof;
/** Verify mode — recorded for transparency; the hard-gate applies in BOTH modes (ADR-550 D4). */
mode?: string;
/** Project root for the default real runner (defaults to process.cwd()). */
cwd?: string;
/** Override the per-kind subprocess timeout (ms); defaults to 30s (node-test) / 60s (eslint).
* Injected in tests to prove the fail-closed-on-timeout bound without a 30s wait. */
timeoutMs?: number;
}
/** The producer's verdict: the disposition PLUS the located/kind/evidence provenance. */
export interface EnforcementResult extends ProhibitionDisposition {
located: boolean;
kind: CheckKind | null;
evidence: EnforcementEvidence[];
mode?: string;
}
/** node --test argv. Forces the TAP reporter so the summary counts are parseable + version-stable;
* `--` before the target so a target starting with `-` is not parsed as a flag (option-injection). */
export function buildNodeTestArgs(check: CheckDescriptor): string[] {
return ['--test', '--test-reporter=tap', '--', check.target];
}
/** eslint argv (the args AFTER the eslint CLI path). Runs the project flat config so plugin rules
* (e.g. `local/*`) load — `--rule` CANNOT load a plugin, so we lint the TARGET path as JSON and
* filter by rule id. `--no-warn-ignored` makes an eslint-IGNORED target return `[]` (not a length-1
* "File ignored" result) so an ignored path fails closed via the vacuity guard, not a false green.
* `--` before the target so a target starting with `-` is not parsed as a flag (option-injection). */
export function buildLintArgs(check: CheckDescriptor): string[] {
return ['--no-warn-ignored', '--format', 'json', '--', check.target];
}
/**
* Resolve the project's eslint CLI entry portably (no `npx` — not spawnable via `execFileSync` on
* Windows). Resolves eslint's package.json from the target project's `node_modules` and derives
* `bin/eslint.js`, so it is run as `node <cli>` (portable). Returns null if eslint is not installed
* (→ the lint-rule check fails closed, never throws).
*/
function resolveEslintCli(cwd: string): string | null {
try {
const pkg = require.resolve('eslint/package.json', { paths: [cwd] });
const cli = path.join(path.dirname(pkg), 'bin', 'eslint.js');
return fs.existsSync(cli) ? cli : null;
} catch {
return null;
}
}
/** Basename of a path, separator-agnostic (handles `\` and `/` so node-test names compare stably
* across OSes / node versions that report the file-test by differing path forms). */
function baseOf(p: string): string {
return typeof p === 'string' ? (p.split(/[\\/]/).pop() ?? p) : '';
}
/**
* Pure parser for the `node --test` TAP summary. A genuine pass is NON-VACUOUS: exit 0 is NOT enough
* (an empty / all-skipped / deleted-negative-test file exits 0 with `# tests 0`). Mutation-pinned by
* unit tests so a threshold flip is caught.
*/
export function parseNodeTestSummary(out: string): { tests: number; pass: number; fail: number; cancelled: number } {
const num = (re: RegExp): number => {
const m = typeof out === 'string' ? out.match(re) : null;
return m ? Number(m[1]) : 0;
};
return {
tests: num(/^# tests (\d+)/m),
pass: num(/^# pass (\d+)/m),
fail: num(/^# fail (\d+)/m),
cancelled: num(/^# cancelled (\d+)/m),
};
}
/**
* Pure: did a `node --test` run go RED on the violation fixture? True iff the TAP summary reports at
* least one failure (`# fail >= 1`). The default node-test prover requires this — a negative test
* that does NOT go red against a known-bad subject is toothless and must not prove fail-first.
* Mutation-pinned (`>= 1` boundary): a mutant flipping `>=`→`>` (or the threshold) is caught by the
* `# fail 1` unit case. An unparseable summary yields `fail: 0` → false (fail-closed for the prover).
*/
export function isNodeTestRed(out: string): boolean {
return parseNodeTestSummary(out).fail >= 1;
}
/** The names of REAL (run) tests from TAP `ok N - <name>` / `not ok N - <name>` lines. A line with a
* `# SKIP` / `# TODO` directive is EXCLUDED — a skipped/todo negative test never executed, so it must
* not count toward non-vacuity (#1259 m1). */
export function tapTestNames(out: string): string[] {
if (typeof out !== 'string') return [];
const names: string[] = [];
const re = /^(?:not )?ok \d+ - (.+)$/gm;
let m: RegExpExecArray | null;
while ((m = re.exec(out)) !== null) {
const rest = m[1];
if (/\s#\s*(?:SKIP|TODO)\b/i.test(rest)) continue; // skipped/todo did not run
names.push(rest.replace(/\s+#\s.*$/, '').trim());
}
return names;
}
/**
* A non-vacuous node-test pass: at least one test, at least one pass, zero failures — AND at least
* one reported test whose name is NOT merely the target file. `node --test <file>` counts a file
* with ZERO `test()` calls as one passing "test" named after the file, so the counts alone cannot
* tell an empty/deleted negative test from a real one (the #1259 BL-01 false-green). Requiring a
* named test distinct from the file closes that hole.
*
* KNOWN CONSTRAINT (fail-closed, not a hole): a real test whose `test('...')` name is EXACTLY the
* target file's basename emits TAP indistinguishable from an empty file and is conservatively
* rejected (non-green). A wired negative test must carry a descriptive name, not be named after its
* own file — a benign authoring constraint, and the safe direction if violated.
*/
export function isNonVacuousNodeTestPass(out: string, target: string): boolean {
const s = parseNodeTestSummary(out);
// >=1 test, >=1 pass, ZERO failures AND ZERO cancelled (a cancelled run is not a clean pass, m1).
if (!(s.tests >= 1 && s.pass >= 1 && s.fail === 0 && s.cancelled === 0)) return false;
// Compare BASENAMES: node reports the file-test by varying path forms across OS / node version
// (absolute, relative, normalized), so an exact-string compare misfires. A real test name (e.g.
// "guards the must-NOT") has no separators, so its basename never equals the target file's.
const tgtBase = baseOf(target);
return tapTestNames(out).some((n) => baseOf(n) !== tgtBase);
}
/** The names of FAILING (run) tests from TAP `not ok N - <name>` lines, excluding `# SKIP`/`# TODO`
* directives (a skipped/todo line never ran). The fail-first analog of `tapTestNames`. */
export function tapFailedTestNames(out: string): string[] {
if (typeof out !== 'string') return [];
const names: string[] = [];
const re = /^not ok \d+ - (.+)$/gm;
let m: RegExpExecArray | null;
while ((m = re.exec(out)) !== null) {
const rest = m[1];
if (/\s#\s*(?:SKIP|TODO)\b/i.test(rest)) continue; // skipped/todo did not run
names.push(rest.replace(/\s+#\s.*$/, '').trim());
}
return names;
}
/**
* A NON-VACUOUS node-test RED — the fail-first proof analog of `isNonVacuousNodeTestPass`. True iff
* the run reports `# fail >= 1` AND at least one FAILING test is named DISTINCTLY from the target file.
*
* Why the distinct-name guard: a violation fixture that makes the negative test CRASH at load
* (ENOENT / throw-on-require / syntax error) emits a FILE-NAMED `not ok 1 - <file>` with `# fail 1`.
* That is a crash, NOT the negative assertion firing red — so it must not "prove" the test is a
* regression guard (the RED-side mirror of the BL-01 vacuity hole on the pass side). Requiring a
* failing test named distinctly from the file closes that hole, symmetric with the clean-pass guard.
*
* KNOWN CONSTRAINT (fail-closed, not a hole): a negative test whose `test('...')` name is EXACTLY its
* own file basename is conservatively rejected — same benign authoring constraint, same safe direction.
*/
export function isNonVacuousNodeTestRed(out: string, target: string): boolean {
if (!isNodeTestRed(out)) return false; // no `# fail >= 1` summary -> not red (fail-closed)
const tgtBase = baseOf(target);
return tapFailedTestNames(out).some((n) => baseOf(n) !== tgtBase);
}
/** Number of file results in an eslint `--format json` report (0 if unparseable / not an array). */
export function eslintFileResultCount(jsonText: string): number {
try {
const parsed: unknown = JSON.parse(jsonText);
return Array.isArray(parsed) ? parsed.length : 0;
} catch {
return 0;
}
}
/** Messages array of a single eslint file-result (empty if absent / wrong shape). */
function eslintMessages(file: unknown, key: 'messages' | 'suppressedMessages'): Array<{ ruleId?: unknown; fatal?: unknown }> {
return file && typeof file === 'object' && Array.isArray((file as Record<string, unknown>)[key])
? ((file as Record<string, unknown>)[key] as Array<{ ruleId?: unknown; fatal?: unknown }>)
: [];
}
/**
* True if the eslint `--format json` report has a FATAL / parse error — meaning the rule never got
* to run on the target. A prohibition gate must fail closed on "the rule didn't execute" (#1259 B1),
* NOT treat a length-1 fatal result as "clean". Unparseable report -> true (fail-closed).
*/
export function eslintHasFatalError(jsonText: string): boolean {
let parsed: unknown;
try {
parsed = JSON.parse(jsonText);
} catch {
return true;
}
if (!Array.isArray(parsed)) return true;
for (const file of parsed) {
const fec = file && typeof file === 'object' ? (file as { fatalErrorCount?: unknown }).fatalErrorCount : undefined;
if (typeof fec === 'number' && fec > 0) return true;
if (eslintMessages(file, 'messages').some((m) => m && m.fatal === true)) return true;
}
return false;
}
/** True if the eslint `--format json` report has ANY message for `rule` — in EITHER `messages` or
* `suppressedMessages` (an inline `// eslint-disable` of the rule is still a violation, #1259 B1).
* Unparseable -> true (fail-closed: treat an unreadable report as a violation, not a silent pass). */
export function eslintJsonHasRule(jsonText: string, rule: string): boolean {
let parsed: unknown;
try {
parsed = JSON.parse(jsonText);
} catch {
return true;
}
if (!Array.isArray(parsed)) return true;
for (const file of parsed) {
for (const key of ['messages', 'suppressedMessages'] as const) {
for (const msg of eslintMessages(file, key)) {
if (msg && typeof msg === 'object' && msg.ruleId === rule) return true;
}
}
}
return false;
}
/**
* The default REAL check runner (used when no `runCheck` is injected). Reports only an OBSERVED,
* genuinely-non-vacuous pass; guarded so a missing tool / non-zero exit yields a non-passing result,
* NEVER an uncaught throw (the no-throw contract). It does NOT determine fail-first — that is the
* separate `proveFailFirst` seam's job (machine-proven against the violation fixture, #1279).
* - node-test: runs `node --test` (TAP) and requires a NON-VACUOUS pass (>=1 test, >=1 pass, 0 fail
* AND a reported test named distinctly from the file). A bare exit 0 for an empty/zero-test file
* — which `node --test` counts as one passing "test" named after the file — is NOT a pass (the
* #1259 BL-01 false-green fix).
* - lint-rule: runs the project eslint as `node <eslint-cli> --format json <target>` (flat config
* loads `local/*` plugins) and requires the target to actually lint (>=1 file result) AND ZERO
* messages for the specific rule id. `--rule` cannot load a plugin rule, so we filter the
* structured report by `ruleId` instead (the #1259 SF-01 fix).
*
* Both kinds spawn via `process.execPath` (never bare `node`/`npx` — not portably spawnable via
* `execFileSync` on Windows) with arg arrays (no shell → no injection from a caller-supplied target).
*/
/**
* Env for spawned checks: strip `NODE_TEST_CONTEXT` and `NODE_OPTIONS` so an AMBIENT test-runner
* context (e.g. running verify under `node --test`, which sets `NODE_TEST_CONTEXT=child-v8`) cannot
* turn the child `node --test` into a silent v8-reporter worker that emits no parseable TAP — which
* would otherwise corrupt the verdict. Deterministic, environment-independent execution.
*/
function childEnv(): NodeJS.ProcessEnv {
const env = { ...process.env };
delete env.NODE_TEST_CONTEXT;
delete env.NODE_OPTIONS;
return env;
}
// Bounded subprocess limits (DEFECT.UNBOUNDED-SUBPROCESS): a stuck wired test / eslint must not hang
// verify forever. On timeout `execFileSync` throws -> caught -> fail-closed (degraded, non-passing).
// `maxBuffer` caps output so a runaway producer throws (safe direction) rather than OOMs the verifier.
const NODE_TEST_TIMEOUT_MS = 30_000;
const ESLINT_TIMEOUT_MS = 60_000;
const CHECK_MAX_BUFFER = 16 * 1024 * 1024;
/** Windows `taskkill` resolved by ABSOLUTE path — never a bare PATH-resolved name. A project
* directory used as the child's `cwd` could otherwise contain a planted `taskkill.exe`/`.bat`
* that Windows executable resolution picks up ahead of the real one (#3660 review, minor-9).
* Returns null (never a hardcoded fallback, per tests/hardcoded-paths.test.cjs) when neither env
* var is set -- not expected on a real Windows host (both are set by the OS itself), but a
* hostile/stripped env should degrade to "skip the reap" rather than guess a system path. */
function taskkillPath(): string | null {
const root = process.env.SystemRoot || process.env.windir;
return root ? path.join(root, 'System32', 'taskkill.exe') : null;
}
/**
* Reap the process TREE rooted at `pid` after THIS call's own bound killed it (#3660: `node --test`
* forks a per-file WORKER by default since Node 22 — `execFileSync`'s `timeout` signals only the
* direct child/runner, never the worker, which is reparented to PID 1 and can busy-loop forever).
*
* POSIX: the child was spawned `detached` (its own process group, pgid === pid), so `-pid` addresses
* the whole group. ESRCH (group already gone) is swallowed — the subject may have exited on its own
* between the timeout firing and this call.
*
* Windows has no process-group equivalent; `taskkill /PID <pid> /T /F` walks the live process tree by
* parent-PID instead, which does not require the parent PID to still be alive. A non-zero exit means
* "nothing left to kill" (already gone, or never had descendants) — not a failure, so it is never
* escalated; there is no portable stronger primitive to escalate TO.
*
* Never throws — this runs from a `catch`/`finally` path and must not itself become the error.
*/
function reapDescendants(pid: number | undefined): void {
if (typeof pid !== 'number' || pid <= 0) return; // defensive: never signal pid 0 (self) or negative
if (process.platform === 'win32') {
const exe = taskkillPath();
if (!exe) return; // no safe absolute path available -- best-effort, skip rather than guess
try {
// 5s bound (DEFECT.UNBOUNDED-SUBPROCESS): a local OS command, not a network call -- if
// taskkill itself hangs, this function's own "never throws" contract already treats that
// identically to any other failure (swallowed below), so a bounded timeout costs nothing
// and just prevents a stuck taskkill from blocking the caller forever.
spawnSync(exe, ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true, timeout: 5_000 });
} catch {
// best-effort: a missing taskkill.exe (or a timeout) is not this call's problem to escalate
}
return;
}
try {
process.kill(-pid, 'SIGKILL');
} catch {
// Swallows ESRCH (group already gone -- nothing to reap) and any other errno (e.g. EPERM) --
// this helper never throws regardless of cause; see the function's own doc comment above.
}
}
/**
* `execFileSync`, with descendant reaping layered on top. Same contract (same return value, throws
* the identical error) EXCEPT that when — and ONLY when — this call's OWN timeout killed the child,
* any descendants the child forked are also reaped.
*
* Gated on `error.code === 'ETIMEDOUT'`, NOT `error.signal`. `signal` is the field Node's own docs
* describe for this purpose, but it is not reliably populated across platforms/Node versions: on one
* real Linux CI run (Node 24) a genuine timeout-kill threw `{ signal: null, code: 'ETIMEDOUT',
* status: 7 }` — `signal` was simply absent, `code` was the only reliable marker (confirmed empirically
* before landing this; a macOS/Node run separately showed `signal: 'SIGTERM'` for the identical
* scenario, so neither field alone is safe to rely on everywhere — `code` was the one constant).
* Gating strictly on the timeout code (rather than reaping on every throw) matters: an ordinary
* non-zero exit (a real test/lint failure) has no `ETIMEDOUT` code — the child exited on its own, so a
* reap there would fire on every red run for no reason and, on POSIX, risks signalling a process group
* whose pgid was *already* recycled by something unrelated in the time since (the #3660 review's
* Blocker-3 defect in the prior attempt at this fix, PR #3681). Only the timeout-kill path is targeted.
*
* Spawns `detached` on POSIX so the reap above can address the whole process group; omitted on
* Windows (no such flag there — `@types/node`'s `ExecFileSyncOptions` doesn't declare `detached`
* either, hence the cast below, though libuv honors it identically to `spawnSync`).
*/
function execFileSyncReaping(
file: string,
args: readonly string[],
options: { cwd: string; encoding: 'utf-8'; stdio: ['ignore', 'pipe', 'pipe']; windowsHide: true; env: NodeJS.ProcessEnv; timeout: number; maxBuffer: number },
): string {
const spawnOptions = process.platform === 'win32' ? options : ({ ...options, detached: true } as typeof options & { detached: true });
try {
return execFileSync(file, args, spawnOptions);
} catch (e) {
const err = e as { pid?: unknown; code?: unknown };
if (typeof err.pid === 'number' && err.code === 'ETIMEDOUT') {
reapDescendants(err.pid);
}
throw e;
}
}
/** Resolve the effective timeout: only a POSITIVE override is honored — `0` (which Node treats as
* "no timeout") or a negative value falls back to the bounded default, so the subprocess is ALWAYS
* bounded (a `timeoutMs: 0` injection can never disable the bound). */
function posTimeout(timeoutMs: number | undefined, def: number): number {
return typeof timeoutMs === 'number' && timeoutMs > 0 ? timeoutMs : def;
}
/**
* Spawn the negative `node --test` against a single subject (set via the `GSD_PROHIB_SUBJECT`
* convention, #1279) and return its TAP output. Reuses the bounded-subprocess machinery
* (`process.execPath`, arg arrays → no shell, `childEnv`, bounded `timeout`/`maxBuffer`) and NEVER
* throws — a RED run exits non-zero, so the partial TAP (with the `# fail` summary) is recovered from
* the thrown error's `stdout`. The prover calls this once per subject: the KNOWN-BAD violation fixture
* (expect RED) and, for the #1346 causation control, the KNOWN-CLEAN control subject (expect GREEN).
*/
function runNodeTestWithSubject(check: CheckDescriptor, cwd: string, subject: string, timeoutMs?: number): string {
try {
return execFileSyncReaping(process.execPath, buildNodeTestArgs(check), {
cwd,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
env: { ...childEnv(), GSD_PROHIB_SUBJECT: subject },
timeout: posTimeout(timeoutMs, NODE_TEST_TIMEOUT_MS),
maxBuffer: CHECK_MAX_BUFFER,
});
} catch (e) {
const stdout = e && typeof e === 'object' && 'stdout' in e ? (e as { stdout?: unknown }).stdout : '';
return typeof stdout === 'string' ? stdout : '';
}
}
function defaultRunCheck(check: CheckDescriptor, cwd: string, timeoutMs?: number): CheckRunResult {
try {
if (check.kind === 'node-test') {
let out = '';
try {
out = execFileSyncReaping(process.execPath, buildNodeTestArgs(check), {
cwd,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
env: childEnv(),
timeout: posTimeout(timeoutMs, NODE_TEST_TIMEOUT_MS),
maxBuffer: CHECK_MAX_BUFFER,
});
} catch (e) {
// A failing/timed-out run exits non-zero or is killed (partial TAP on stdout, no `# pass`
// summary). Parse what we have: a real failure or timeout -> not a non-vacuous pass -> false.
const stdout = e && typeof e === 'object' && 'stdout' in e ? (e as { stdout?: unknown }).stdout : '';
out = typeof stdout === 'string' ? stdout : '';
}
return { passed: isNonVacuousNodeTestPass(out, check.target) };
}
if (check.kind === 'lint-rule') {
const eslintCli = resolveEslintCli(cwd);
if (!eslintCli) return { passed: false }; // eslint not installed -> fail closed, never throw
let json = '';
try {
json = execFileSyncReaping(process.execPath, [eslintCli, ...buildLintArgs(check)], {
cwd,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
env: childEnv(),
timeout: posTimeout(timeoutMs, ESLINT_TIMEOUT_MS),
maxBuffer: CHECK_MAX_BUFFER,
});
} catch (e) {
// eslint exits non-zero when ANY error is present; the JSON report is still on stdout.
// A timeout/kill leaves no parseable JSON -> eslintHasFatalError(unparseable) -> fail-closed.
const stdout = e && typeof e === 'object' && 'stdout' in e ? (e as { stdout?: unknown }).stdout : '';
json = typeof stdout === 'string' ? stdout : '';
}
// PASS requires: the target actually linted (>=1 file result), NO fatal/parse error (the rule
// must have RUN — #1259 B1), and ZERO messages for the rule (in messages OR suppressedMessages).
const lintedSomething = eslintFileResultCount(json) >= 1;
return {
passed: lintedSomething && !eslintHasFatalError(json) && !eslintJsonHasRule(json, check.rule as string),
};
}
// Unknown kind — defensive; the LOCATE guard already rejects it.
return { passed: false };
} catch {
return { passed: false };
}
}
/**
* The default REAL fail-first prover (#1279; used when no `proveFailFirst` is injected). It runs the
* wired check against the descriptor's `violationFixture` (a KNOWN-BAD subject) and requires it to go
* RED — the machine proof that replaces caller attestation. Like `defaultRunCheck`, it is the
* impure/injectable seam (spawns eslint / `node --test`), reuses the identical bounded-subprocess
* machinery (`childEnv`/`posTimeout`/`CHECK_MAX_BUFFER`, `execFileSync(process.execPath, …)`, arg
* arrays → no shell), and NEVER throws — every un-provable path returns `{ provenFailFirst: false }`.
*
* - lint-rule: lint the `violationFixture` via the project flat config (so `local/*` plugins load)
* and require the target to actually lint (>=1 file result) AND no fatal/parse error AND the rule
* id to appear in the report (messages OR suppressedMessages — an inline-disabled violation still
* proves the rule has teeth, #1259 B1). Absent fixture / unresolvable eslint → not proven.
* - node-test: spawn the negative test (TAP) with `GSD_PROHIB_SUBJECT` set to the `violationFixture`
* — the CONVENTION (#1279) by which a negative test reads its subject-under-test — and require a
* NON-VACUOUS red (`isNonVacuousNodeTestRed`: `# fail >= 1` AND a failing test named distinctly
* from the file, so a load-CRASH on the bad subject is not mistaken for the assertion firing red).
* A toothless test that passes anyway → not proven. Absent fixture → not proven (fail-closed;
* NEVER falls back to attestation).
*/
function defaultProveFailFirst(check: CheckDescriptor, cwd: string, timeoutMs?: number): FailFirstProof {
try {
if (check.kind === 'lint-rule') {
const fixture = check.violationFixture;
if (!fixture) return { provenFailFirst: false }; // can't prove without a known violation -> hard-gate
const eslintCli = resolveEslintCli(cwd);
if (!eslintCli) return { provenFailFirst: false }; // eslint not installed -> fail closed, never throw
let json = '';
try {
json = execFileSyncReaping(process.execPath, [eslintCli, ...buildLintArgs({ ...check, target: fixture })], {
cwd,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
env: childEnv(),
timeout: posTimeout(timeoutMs, ESLINT_TIMEOUT_MS),
maxBuffer: CHECK_MAX_BUFFER,
});
} catch (e) {
// eslint exits non-zero on any error; the JSON report is still on stdout. A timeout/kill
// leaves no parseable JSON -> eslintHasFatalError(unparseable) -> not proven (fail-closed).
const stdout = e && typeof e === 'object' && 'stdout' in e ? (e as { stdout?: unknown }).stdout : '';
json = typeof stdout === 'string' ? stdout : '';
}
// Proven iff: the fixture actually linted (>=1 file result), the rule RAN (no fatal/parse
// error), and the rule id appears (the violation was flagged -> the rule has teeth).
const proven = eslintFileResultCount(json) >= 1
&& !eslintHasFatalError(json)
&& eslintJsonHasRule(json, check.rule as string);
return { provenFailFirst: proven, method: 'violation-fixture' };
}
if (check.kind === 'node-test') {
const fixture = check.violationFixture;
// Fail-CLOSED on a missing/typo'd/stale fixture path, SYMMETRIC with the lint-rule path's
// `eslintFileResultCount >= 1` guard. Without the existence check, a non-existent fixture makes
// `GSD_PROHIB_SUBJECT` point at a missing file; an honest negative test reading that subject
// throws ENOENT *inside its callback* — a failing test named DISTINCTLY from the file, which
// `isNonVacuousNodeTestRed` would accept as proof. That is fail-OPEN: a typo forges a green from
// a setup crash, not from the prohibition firing. Requiring the fixture to exist before spawning
// closes the realistic typo/stale-path case (#1279 review, Major 1).
//
// CAUSATION (#1346; MANDATORY as of #1906): existence + a non-vacuous red is necessary but not
// sufficient — a deceptive negative test that reds merely BECAUSE `GSD_PROHIB_SUBJECT` is set
// (rather than because the subject's CONTENT violates the must-NOT) would otherwise be accepted.
// The `cleanFixture` control below proves content-dependence (red on bad AND green on clean) and is
// now REQUIRED for the node-test kind: absent it, the check is un-provable (fail-closed), not
// accepted under the weaker violation-only proof (#1906 supersedes #1346's opt-in; ADR-1606 D4).
// Resolve the fixture against `cwd` (NOT the verify process's cwd): the spawned test reads
// `GSD_PROHIB_SUBJECT` and resolves a relative subject against `cwd`, so the existence check must
// use the SAME base or it could pass here yet ENOENT in the child (re-opening the fail-open hole).
if (!fixture || !fs.existsSync(path.resolve(cwd, fixture))) return { provenFailFirst: false };
// Run the negative test against the KNOWN-BAD subject and require a NON-VACUOUS red.
const redOut = runNodeTestWithSubject(check, cwd, fixture, timeoutMs);
if (!isNonVacuousNodeTestRed(redOut, check.target)) return { provenFailFirst: false, method: 'violation-fixture' };
// #1906 CAUSATION CONTROL (MANDATORY for node-test — supersedes #1346's opt-in, ADR-1606 D4): the
// clean control subject is REQUIRED. Run the SAME test against it and require it to stay GREEN,
// proving the red above was caused by the subject's CONTENT — a deceptive test that reds merely
// because GSD_PROHIB_SUBJECT is SET reds here too → not content-dependent → not proven. ABSENT →
// the control cannot run → un-provable → fail-closed (NOT accepted under the weaker violation-only
// proof). This is the one behavior change vs #1346: absent `cleanFixture` was previously proven.
const clean = check.cleanFixture;
if (!clean) return { provenFailFirst: false, method: 'violation-fixture' };
// A supplied-but-missing/typo'd control path can't run the control → fail-closed, symmetric
// with the violation-fixture existence guard (resolve against the SAME `cwd` as the child).
if (!fs.existsSync(path.resolve(cwd, clean))) return { provenFailFirst: false, method: 'violation-fixture' };
const cleanOut = runNodeTestWithSubject(check, cwd, clean, timeoutMs);
if (!isNonVacuousNodeTestPass(cleanOut, check.target)) return { provenFailFirst: false, method: 'violation-fixture' };
return { provenFailFirst: true, method: 'violation-fixture' };
}
// Unknown kind — defensive; the LOCATE guard already rejects it.
return { provenFailFirst: false };
} catch {
return { provenFailFirst: false };
}
}
export { defaultProveFailFirst };
/**
* LOCATE -> PROVE fail-first -> RUN -> build enforcementEvidence -> dispositionForProhibition.
*
* (1) LOCATE: if no well-formed check descriptor is locatable -> fail-closed
* (`dispositionForProhibition` with empty evidence) plus `{ located: false, kind: null, evidence: [] }`.
* (2) PROVE + RUN: machine-prove fail-first via `proveFailFirst` (default `defaultProveFailFirst`) AND
* run the check via `runCheck`. The green AND is `proof.provenFailFirst === true && run.passed ===
* true` — caller attestation is NOT consulted (FF-08). A check that cannot be proven fail-first, or
* that does not genuinely (non-vacuously) PASS -> fail-closed disposition with `located: true` (a
* real located miss, non-green, flagged) in BOTH modes.
* (3) PASS: build a typed `enforcementEvidence` array (recording the proof method) and call
* `dispositionForProhibition` — the non-empty array flips a test-tier item to green.
*
* Pure/deterministic DECISION layer: the only impure seams are the injectable `runCheck`/`proveFailFirst`;
* given their results the disposition is same-input-same-output and mutation-survivable.
*/
export function runProhibitionEnforcement(
prohibition: unknown,
check: CheckDescriptor | null | undefined,
options: EnforcementOptions = {},
): EnforcementResult {
const mode = options.mode;
// (1) LOCATE — no locatable, well-formed wired check -> fail-closed, located: false. The kind must
// be one of the two known kinds; the target must be a non-empty string; a lint-rule descriptor MUST
// also carry a non-empty `rule` id (its target is the lint PATH, not the rule). An under-specified
// descriptor is not a valid wired check, so it is not locatable (does not rely on the runner failing).
const c = check && typeof check === 'object' ? check : null;
const validKind = !!c && (c.kind === 'node-test' || c.kind === 'lint-rule');
const validTarget = !!c && typeof c.target === 'string' && c.target.trim().length > 0;
const validRule = !!c && (c.kind !== 'lint-rule' || (typeof c.rule === 'string' && c.rule.trim().length > 0));
if (!c || !validKind || !validTarget || !validRule) {
const disposition = dispositionForProhibition(prohibition, { enforcementEvidence: [] });
return { ...disposition, located: false, kind: null, evidence: [], ...(mode ? { mode } : {}) };
}
const runCheck = options.runCheck ?? ((toRun: CheckDescriptor) => defaultRunCheck(toRun, options.cwd ?? process.cwd(), options.timeoutMs));
const proveFailFirst = options.proveFailFirst ?? ((toProve: CheckDescriptor) => defaultProveFailFirst(toProve, options.cwd ?? process.cwd(), options.timeoutMs));
// (2) PROVE fail-first (MACHINE) + RUN. The prover must INDEPENDENTLY run the check against the
// descriptor's violation fixture and observe it go red (`proof.provenFailFirst`), AND the runner
// must observe a genuine NON-VACUOUS pass. Caller attestation (`c.failFirst`) is NOT consulted for
// the green verdict (FF-08) — a check that cannot be machine-proven fail-first hard-gates (never
// green) in BOTH modes, even if attested.
// No-throw contract end-to-end: even a (test-injected) prover/runner that throws must fail closed,
// never propagate. The default real prover/runner already never throw.
let proof: FailFirstProof;
try {
proof = proveFailFirst(c);
} catch {
proof = { provenFailFirst: false };
}
let run: CheckRunResult;
try {
run = runCheck(c);
} catch {
run = { passed: false };
}
// The `&&` and `=== true` are mutation-load-bearing — both directions (proven-red AND clean-pass)
// must hold for green; Plan 01/02 guards pin them.
const passed = proof.provenFailFirst === true && run.passed === true;
if (!passed) {
// NOT machine-proven fail-first OR did not genuinely pass -> fail-closed, located: true (an actual
// located miss/fail). Hard-gate applies in BOTH modes; the disposition stays non-green / flagged.
const disposition = dispositionForProhibition(prohibition, { enforcementEvidence: [] });
return {
...disposition,
located: true,
kind: c.kind,
evidence: [],
...(mode ? { mode } : {}),
};
}
// (3) PASS -> build typed enforcementEvidence and let the policy flip a test-tier item green.
// `failFirst: true` here means MACHINE-PROVEN (the green AND required `proof.provenFailFirst`),
// and `failFirstProof` records HOW it was proven (FF-07).
const evidence: EnforcementEvidence[] = [{
kind: c.kind,
target: c.target,
...(typeof c.rule === 'string' ? { rule: c.rule } : {}),
failFirst: true,
passed: true,
...(proof.method ? { failFirstProof: proof.method } : {}),
}];
const disposition = dispositionForProhibition(prohibition, { enforcementEvidence: evidence });
return {
...disposition,
located: true,
kind: c.kind,
evidence,
...(mode ? { mode } : {}),
};
}
/**
* Parse a `{ prohibition, check, mode }` request from a JSON file path or inline `--json` string.
* Returns null on any parse failure (the caller surfaces a structured error, never a throw).
*/
function parseRequest(args: string[]): { prohibition: unknown; check: CheckDescriptor | null; mode?: string } | null {
// args[0] = 'check', args[1] = 'prohibition-enforcement', args[2] = <json-file-path | --json>
const jsonFlagIdx = args.indexOf('--json');
let payload = '';
if (jsonFlagIdx !== -1 && typeof args[jsonFlagIdx + 1] === 'string') {
payload = args[jsonFlagIdx + 1];
} else if (typeof args[2] === 'string' && args[2]) {
try {
payload = fs.readFileSync(args[2], 'utf-8');
} catch {
return null;
}
} else {
return null;
}
try {
const parsed = JSON.parse(payload) as Record<string, unknown>;
const checkRaw = parsed['check'];
const check: CheckDescriptor | null = (checkRaw && typeof checkRaw === 'object')
? (checkRaw as CheckDescriptor)
: null;
const modeRaw = parsed['mode'];
const mode = typeof modeRaw === 'string' ? modeRaw : undefined;
return { prohibition: parsed['prohibition'] ?? null, check, ...(mode ? { mode } : {}) };
} catch {
return null;
}
}
/**
* CLI surface: `gsd_run check prohibition-enforcement <request.json>` (or `--json '<inline>'`).
* Parses the request, runs the producer, and emits the result as JSON. Honors the no-throw
* contract: malformed input -> structured `error(...)`, never an uncaught throw.
*/
export function routeProhibitionEnforcement(args: string[], raw: boolean): void {
const req = parseRequest(args);
if (!req) {
error(
'prohibition-enforcement requires a JSON request: check prohibition-enforcement <request.json> | --json \'{"prohibition":{...},"check":{...}}\'',
ERROR_REASON.SDK_MISSING_ARG,
);
return;
}
const result = runProhibitionEnforcement(req.prohibition, req.check, req.mode ? { mode: req.mode } : {});
output(result, raw, undefined);
}
export {};