enhance(#1279): defaultProveFailFirst real prover (lint-rule + node-test, fail-closed)
- FailFirstProof type + proveFailFirst injectable option on EnforcementOptions - defaultProveFailFirst: lint-rule proves red via eslintJsonHasRule on the violation fixture; node-test proves red via isNodeTestRed against GSD_PROHIB_SUBJECT; fail-closed + no-throw - reuses childEnv/posTimeout/CHECK_MAX_BUFFER/execFileSync(process.execPath) (bounded, no shell) - decision green-path UNCHANGED (still attestation) — Plan 03 wires proof into the AND
This commit is contained in:
@@ -95,10 +95,29 @@ export interface EnforcementEvidence {
|
||||
passed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()). */
|
||||
@@ -379,6 +398,86 @@ function defaultRunCheck(check: CheckDescriptor, cwd: string, timeoutMs?: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 the
|
||||
* run to go RED (`isNodeTestRed`: `# fail >= 1`). 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 = execFileSync(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;
|
||||
if (!fixture) return { provenFailFirst: false }; // no fixture -> hard-gate (NEVER attestation)
|
||||
let out = '';
|
||||
try {
|
||||
out = execFileSync(process.execPath, buildNodeTestArgs(check), {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
// CONVENTION (#1279): the negative test reads its subject-under-test from this env var.
|
||||
env: { ...childEnv(), GSD_PROHIB_SUBJECT: fixture },
|
||||
timeout: posTimeout(timeoutMs, NODE_TEST_TIMEOUT_MS),
|
||||
maxBuffer: CHECK_MAX_BUFFER,
|
||||
});
|
||||
} catch (e) {
|
||||
// A negative test that goes RED exits non-zero; the partial TAP (with the `# fail` summary)
|
||||
// is on stdout. Parse what we have: a real failure here is the PROOF the test is fail-first.
|
||||
const stdout = e && typeof e === 'object' && 'stdout' in e ? (e as { stdout?: unknown }).stdout : '';
|
||||
out = typeof stdout === 'string' ? stdout : '';
|
||||
}
|
||||
return { provenFailFirst: isNodeTestRed(out), method: 'violation-fixture' };
|
||||
}
|
||||
// Unknown kind — defensive; the LOCATE guard already rejects it.
|
||||
return { provenFailFirst: false };
|
||||
} catch {
|
||||
return { provenFailFirst: false };
|
||||
}
|
||||
}
|
||||
|
||||
export { defaultProveFailFirst };
|
||||
|
||||
/**
|
||||
* LOCATE -> CONFIRM fail-first -> RUN -> build enforcementEvidence -> dispositionForProhibition.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user