@opengsd/gsd-core 1.5.0-rc.4 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/LICENSE +1 -1
- package/agents/gsd-executor.md +22 -0
- package/agents/gsd-phase-researcher.md +1 -1
- package/agents/gsd-planner.md +4 -0
- package/agents/gsd-project-researcher.md +1 -1
- package/agents/gsd-verifier.md +45 -13
- package/bin/install.js +56 -100
- package/commands/gsd/autonomous.md +1 -1
- package/commands/gsd/execute-phase.md +1 -1
- package/commands/gsd/plan-phase.md +1 -1
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/gsd-tools.cjs +85 -53
- package/gsd-core/bin/lib/agent-command-router.cjs +2 -2
- package/gsd-core/bin/lib/agent-install-check.cjs +143 -0
- package/gsd-core/bin/lib/audit-command-router.cjs +4 -4
- package/gsd-core/bin/lib/capability-activation.cjs +37 -10
- package/gsd-core/bin/lib/capability-registry.cjs +2 -0
- package/gsd-core/bin/lib/capability-state.cjs +80 -30
- package/gsd-core/bin/lib/capability-writer.cjs +5 -4
- package/gsd-core/bin/lib/check-command-router.cjs +50 -51
- package/gsd-core/bin/lib/commands.cjs +20 -2
- package/gsd-core/bin/lib/config-loader.cjs +3 -4
- package/gsd-core/bin/lib/config-schema.cjs +1 -1
- package/gsd-core/bin/lib/config-types.cjs +2 -1
- package/gsd-core/bin/lib/config.cjs +5 -2
- package/gsd-core/bin/lib/decisions.cjs +19 -1
- package/gsd-core/bin/lib/docs.cjs +14 -2
- package/gsd-core/bin/lib/frontmatter.cjs +2 -2
- package/gsd-core/bin/lib/gap-checker.cjs +5 -2
- package/gsd-core/bin/lib/git-base-branch.cjs +27 -1
- package/gsd-core/bin/lib/graphify-command-router.cjs +6 -8
- package/gsd-core/bin/lib/graphify.cjs +7 -31
- package/gsd-core/bin/lib/gsd2-import.cjs +2 -2
- package/gsd-core/bin/lib/init.cjs +30 -4
- package/gsd-core/bin/lib/intel-command-router.cjs +6 -3
- package/gsd-core/bin/lib/intel.cjs +28 -34
- package/gsd-core/bin/lib/io.cjs +2 -4
- package/gsd-core/bin/lib/learnings.cjs +2 -2
- package/gsd-core/bin/lib/loop-resolver.cjs +45 -167
- package/gsd-core/bin/lib/milestone.cjs +13 -5
- package/gsd-core/bin/lib/model-resolver.cjs +3 -4
- package/gsd-core/bin/lib/phase-id.cjs +3 -5
- package/gsd-core/bin/lib/phase-locator.cjs +3 -6
- package/gsd-core/bin/lib/phase.cjs +59 -13
- package/gsd-core/bin/lib/probe-core.cjs +40 -11
- package/gsd-core/bin/lib/profile-output.cjs +5 -2
- package/gsd-core/bin/lib/prohibition-enforcement.cjs +660 -0
- package/gsd-core/bin/lib/roadmap-command-router.cjs +2 -2
- package/gsd-core/bin/lib/roadmap-parser.cjs +28 -25
- package/gsd-core/bin/lib/roadmap.cjs +9 -4
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +4 -1
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +24 -3
- package/gsd-core/bin/lib/state.cjs +75 -17
- package/gsd-core/bin/lib/task-command-router.cjs +2 -2
- package/gsd-core/bin/lib/teams-status.cjs +74 -0
- package/gsd-core/bin/lib/template.cjs +11 -2
- package/gsd-core/bin/lib/uat.cjs +64 -2
- package/gsd-core/bin/lib/verification.cjs +8 -5
- package/gsd-core/bin/lib/verify.cjs +311 -4
- package/gsd-core/bin/lib/workstream-inventory.cjs +2 -2
- package/gsd-core/bin/lib/workstream.cjs +8 -2
- package/gsd-core/bin/lib/worktree-safety.cjs +44 -3
- package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
- package/gsd-core/references/planner-antipatterns.md +46 -0
- package/gsd-core/references/planning-config.md +5 -1
- package/gsd-core/references/prohibition-probe.md +80 -2
- package/gsd-core/references/worktree-branch-check.md +11 -5
- package/gsd-core/templates/verification-report.md +16 -3
- package/gsd-core/workflows/docs-update.md +23 -31
- package/gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md +9 -0
- package/gsd-core/workflows/execute-phase.md +7 -2
- package/gsd-core/workflows/map-codebase.md +8 -10
- package/gsd-core/workflows/plan-phase.md +6 -0
- package/gsd-core/workflows/quick.md +26 -2
- package/gsd-core/workflows/settings-advanced.md +5 -5
- package/gsd-core/workflows/settings-integrations.md +5 -5
- package/gsd-core/workflows/spec-phase.md +30 -2
- package/gsd-core/workflows/verify-phase.md +22 -7
- package/hooks/dist/gsd-worktree-path-guard.js +34 -18
- package/hooks/gsd-worktree-path-guard.js +34 -18
- package/package.json +3 -3
- package/scripts/ci-prepare-test-scope.cjs +56 -14
- package/scripts/diff-touches-shipped-paths.cjs +5 -11
- package/scripts/gen-capability-registry.cjs +27 -1
- package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -2
- package/scripts/research-profiles.cjs +2 -2
- package/scripts/run-tests.cjs +1 -0
- package/gsd-core/bin/lib/core.cjs +0 -345
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* prohibition-enforcement — the deterministic PRODUCER for test-tier prohibition verification
|
|
4
|
+
* (#1259, ADR-550 Decision 5d "heavy half"; the D1 seam — a NEW deterministic gsd-tools
|
|
5
|
+
* sub-command, NOT free-form workflow prose).
|
|
6
|
+
*
|
|
7
|
+
* Today `dispositionForProhibition()` (src/probe-core.cts) already carries the POLICY seam: with
|
|
8
|
+
* non-empty `enforcementEvidence` AND `tier === 'test'` it returns `{ status: 'green' }` (the
|
|
9
|
+
* branch at probe-core 420-427); with empty evidence it fails closed to flagged-unverified. But
|
|
10
|
+
* NOTHING in the live pipeline ever produced `enforcementEvidence`, so the green branch was
|
|
11
|
+
* unreachable. This module is the missing producer: it LOCATES the wired mechanical check from a
|
|
12
|
+
* check descriptor, RUNS it and requires a genuine NON-VACUOUS pass, builds a typed
|
|
13
|
+
* `enforcementEvidence` array on PASS, and emits the `dispositionForProhibition` verdict as JSON.
|
|
14
|
+
* The green/fail-closed policy itself is untouched (no src/probe-core.cts edit).
|
|
15
|
+
*
|
|
16
|
+
* Accepts BOTH wired-check kinds (ADR-550 D2): a `node --test` negative test OR a lint/AST rule
|
|
17
|
+
* (e.g. the in-tree `local/no-source-grep` rule — the D4 dogfood anchor, run via the project flat
|
|
18
|
+
* config so the plugin loads). A missing, non-attested, or genuinely-non-passing check hard-gates
|
|
19
|
+
* (flagged, non-green) in BOTH interactive and autonomous modes (ADR-550 D4 / D3) — never a silent
|
|
20
|
+
* green.
|
|
21
|
+
*
|
|
22
|
+
* FAIL-FIRST IS MACHINE-PROVEN (#1279): the producer no longer greens on caller attestation. The
|
|
23
|
+
* green verdict requires the injectable prover (`proveFailFirst`, default `defaultProveFailFirst`) to
|
|
24
|
+
* INDEPENDENTLY run the check against a known violation fixture and observe it go red, AND the runner
|
|
25
|
+
* to observe a real non-vacuous pass: `passed = proof.provenFailFirst === true && run.passed === true`.
|
|
26
|
+
* Caller attestation (`CheckDescriptor.failFirst`) is demoted to a non-authoritative hint kept only
|
|
27
|
+
* for backward route-JSON shape — no path greens on it alone (FF-08). The proof method is recorded in
|
|
28
|
+
* the evidence (`failFirstProof`, FF-07). This replaces the #1259 caller-attested red-first property
|
|
29
|
+
* with machine proof, closing the gap the ADR-550 D5d note tracked as a follow-up.
|
|
30
|
+
*
|
|
31
|
+
* Authored as strict TypeScript (`src/prohibition-enforcement.cts`) and compiled by
|
|
32
|
+
* `tsc -p tsconfig.build.json` (`npm run build:lib`) to the gitignored runtime artifact
|
|
33
|
+
* `gsd-core/bin/lib/prohibition-enforcement.cjs`. Do NOT hand-write the `.cjs`; it is emitted.
|
|
34
|
+
*
|
|
35
|
+
* DETERMINISM SCOPE: the DECISION layer is pure/deterministic and no-LLM — given a `runCheck` result
|
|
36
|
+
* the disposition is same-input-same-output and mutation-survivable, and the parse/filter helpers
|
|
37
|
+
* (`parseNodeTestSummary`, `tapTestNames`, `eslintJsonHasRule`, `eslintHasFatalError`, …) are pure.
|
|
38
|
+
* The DEFAULT REAL runner is NOT pure — it spawns `node --test` / eslint, so its result depends on the
|
|
39
|
+
* environment (eslint version + flat config, node version, the target file). That is why the runner is
|
|
40
|
+
* an injectable seam (`runCheck`): the contract is unit-tested against injected results, mirroring the
|
|
41
|
+
* injectable I/O pattern in `runProbeCli` / `ProbeCliOptions`.
|
|
42
|
+
*/
|
|
43
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
44
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
45
|
+
};
|
|
46
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
47
|
+
exports.descriptorFromProjection = descriptorFromProjection;
|
|
48
|
+
exports.buildNodeTestArgs = buildNodeTestArgs;
|
|
49
|
+
exports.buildLintArgs = buildLintArgs;
|
|
50
|
+
exports.parseNodeTestSummary = parseNodeTestSummary;
|
|
51
|
+
exports.isNodeTestRed = isNodeTestRed;
|
|
52
|
+
exports.tapTestNames = tapTestNames;
|
|
53
|
+
exports.isNonVacuousNodeTestPass = isNonVacuousNodeTestPass;
|
|
54
|
+
exports.tapFailedTestNames = tapFailedTestNames;
|
|
55
|
+
exports.isNonVacuousNodeTestRed = isNonVacuousNodeTestRed;
|
|
56
|
+
exports.eslintFileResultCount = eslintFileResultCount;
|
|
57
|
+
exports.eslintHasFatalError = eslintHasFatalError;
|
|
58
|
+
exports.eslintJsonHasRule = eslintJsonHasRule;
|
|
59
|
+
exports.defaultProveFailFirst = defaultProveFailFirst;
|
|
60
|
+
exports.runProhibitionEnforcement = runProhibitionEnforcement;
|
|
61
|
+
exports.routeProhibitionEnforcement = routeProhibitionEnforcement;
|
|
62
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
63
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
64
|
+
const node_child_process_1 = require("node:child_process");
|
|
65
|
+
// Import the leaf I/O module directly (core.cjs re-export spine retired in epic #1267).
|
|
66
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
67
|
+
const io = require("./io.cjs");
|
|
68
|
+
const { output, error, ERROR_REASON } = io;
|
|
69
|
+
const probe_core_cjs_1 = require("./probe-core.cjs");
|
|
70
|
+
/**
|
|
71
|
+
* READ-BACK ADAPTER (#1278, plan 01-03): reconstruct a `CheckDescriptor` from the flat scalar keys
|
|
72
|
+
* `projectProhibitions` emits onto a prohibition item (`check_kind` / `check_target` / `check_rule`,
|
|
73
|
+
* src/probe-core.cts). This is the deterministic bridge from the projected descriptor back into the
|
|
74
|
+
* merged #1259 producer request — the verify-phase caller reads the projected scalars, this rebuilds
|
|
75
|
+
* the `{ kind, target, rule? }` request, and `runProhibitionEnforcement`'s EXISTING fail-closed LOCATE
|
|
76
|
+
* guard (validKind/validTarget/validRule, below) is the single source of fail-closed truth.
|
|
77
|
+
*
|
|
78
|
+
* Contract:
|
|
79
|
+
* - `null`/`undefined`/non-object input -> `null`.
|
|
80
|
+
* - `check_kind` ABSENT -> `null` (no descriptor -> producer locates nothing -> fail-closed).
|
|
81
|
+
* - `check_kind` present -> `{ kind: check_kind, target: check_target }`, adding `rule: check_rule`
|
|
82
|
+
* ONLY when `check_rule` is a non-empty string, and `violationFixture: check_violation_fixture`
|
|
83
|
+
* ONLY when that scalar is a non-empty string (#1346 — composes #1278 locate with #1279 proof).
|
|
84
|
+
* - `failFirst` is NEVER sourced from the projection — it stays a verify-time caller attestation
|
|
85
|
+
* (#1279 machine-proves it; out of scope here). The returned descriptor carries no `failFirst`.
|
|
86
|
+
* - The adapter does NOT strictly validate kind/target/rule: it faithfully reconstructs whatever
|
|
87
|
+
* scalars are present (e.g. `{check_kind:'lint-rule', check_target:'src/'}` with no `check_rule`
|
|
88
|
+
* reconstructs to `{kind:'lint-rule', target:'src/'}`), letting the existing LOCATE guard reject
|
|
89
|
+
* an under-specified descriptor (located:false, never green). It does NOT re-implement that guard.
|
|
90
|
+
* - Pure, deterministic, no-throw.
|
|
91
|
+
*/
|
|
92
|
+
function descriptorFromProjection(projected) {
|
|
93
|
+
if (!projected || typeof projected !== 'object')
|
|
94
|
+
return null;
|
|
95
|
+
if (!('check_kind' in projected))
|
|
96
|
+
return null;
|
|
97
|
+
// The shared `parseMustHavesBlock` (src/frontmatter.cts) coerces /^\d+$/ scalar values to NUMBERS on
|
|
98
|
+
// round-trip, so a numeric-looking check_kind/check_target/check_rule arrives here as a number. Normalize
|
|
99
|
+
// ONLY string|number scalars back to string — a non-scalar (object/array/bool) or absent value yields ''
|
|
100
|
+
// (never an `[object Object]` stringification, no `as string` lie over a number). This keeps the
|
|
101
|
+
// descriptor honestly typed and the round-trip lossless across the full string domain; an under-specified
|
|
102
|
+
// '' target/kind is rejected by the producer's locate guard (fail-closed; never green).
|
|
103
|
+
const scalar = (v) => typeof v === 'string' ? v : typeof v === 'number' ? String(v) : '';
|
|
104
|
+
const kind = scalar(projected.check_kind);
|
|
105
|
+
const target = scalar(projected.check_target);
|
|
106
|
+
const descriptor = { kind, target };
|
|
107
|
+
// `rule` belongs only to the lint-rule kind; a stray check_rule on a node-test descriptor is dropped
|
|
108
|
+
// (the projector never emits one there — defense in depth). failFirst is NOT sourced here (#1279).
|
|
109
|
+
if (kind === 'lint-rule') {
|
|
110
|
+
const rule = scalar(projected.check_rule);
|
|
111
|
+
if (rule.trim().length > 0)
|
|
112
|
+
descriptor.rule = rule;
|
|
113
|
+
}
|
|
114
|
+
// `violationFixture` (#1346) rides BOTH kinds — reconstruct it from `check_violation_fixture` so the
|
|
115
|
+
// deterministic #1278 locate path and the #1279 machine-proof COMPOSE: a projected fixture lets the
|
|
116
|
+
// default prover green end-to-end with zero hand-authoring. Absent/blank -> no fixture -> the prover
|
|
117
|
+
// hard-gates (fail-closed; green requires a fixture), never fabricated.
|
|
118
|
+
const fixture = scalar(projected.check_violation_fixture);
|
|
119
|
+
if (fixture.trim().length > 0)
|
|
120
|
+
descriptor.violationFixture = fixture;
|
|
121
|
+
return descriptor;
|
|
122
|
+
}
|
|
123
|
+
/** node --test argv. Forces the TAP reporter so the summary counts are parseable + version-stable;
|
|
124
|
+
* `--` before the target so a target starting with `-` is not parsed as a flag (option-injection). */
|
|
125
|
+
function buildNodeTestArgs(check) {
|
|
126
|
+
return ['--test', '--test-reporter=tap', '--', check.target];
|
|
127
|
+
}
|
|
128
|
+
/** eslint argv (the args AFTER the eslint CLI path). Runs the project flat config so plugin rules
|
|
129
|
+
* (e.g. `local/*`) load — `--rule` CANNOT load a plugin, so we lint the TARGET path as JSON and
|
|
130
|
+
* filter by rule id. `--no-warn-ignored` makes an eslint-IGNORED target return `[]` (not a length-1
|
|
131
|
+
* "File ignored" result) so an ignored path fails closed via the vacuity guard, not a false green.
|
|
132
|
+
* `--` before the target so a target starting with `-` is not parsed as a flag (option-injection). */
|
|
133
|
+
function buildLintArgs(check) {
|
|
134
|
+
return ['--no-warn-ignored', '--format', 'json', '--', check.target];
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Resolve the project's eslint CLI entry portably (no `npx` — not spawnable via `execFileSync` on
|
|
138
|
+
* Windows). Resolves eslint's package.json from the target project's `node_modules` and derives
|
|
139
|
+
* `bin/eslint.js`, so it is run as `node <cli>` (portable). Returns null if eslint is not installed
|
|
140
|
+
* (→ the lint-rule check fails closed, never throws).
|
|
141
|
+
*/
|
|
142
|
+
function resolveEslintCli(cwd) {
|
|
143
|
+
try {
|
|
144
|
+
const pkg = require.resolve('eslint/package.json', { paths: [cwd] });
|
|
145
|
+
const cli = node_path_1.default.join(node_path_1.default.dirname(pkg), 'bin', 'eslint.js');
|
|
146
|
+
return node_fs_1.default.existsSync(cli) ? cli : null;
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** Basename of a path, separator-agnostic (handles `\` and `/` so node-test names compare stably
|
|
153
|
+
* across OSes / node versions that report the file-test by differing path forms). */
|
|
154
|
+
function baseOf(p) {
|
|
155
|
+
return typeof p === 'string' ? (p.split(/[\\/]/).pop() ?? p) : '';
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Pure parser for the `node --test` TAP summary. A genuine pass is NON-VACUOUS: exit 0 is NOT enough
|
|
159
|
+
* (an empty / all-skipped / deleted-negative-test file exits 0 with `# tests 0`). Mutation-pinned by
|
|
160
|
+
* unit tests so a threshold flip is caught.
|
|
161
|
+
*/
|
|
162
|
+
function parseNodeTestSummary(out) {
|
|
163
|
+
const num = (re) => {
|
|
164
|
+
const m = typeof out === 'string' ? out.match(re) : null;
|
|
165
|
+
return m ? Number(m[1]) : 0;
|
|
166
|
+
};
|
|
167
|
+
return {
|
|
168
|
+
tests: num(/^# tests (\d+)/m),
|
|
169
|
+
pass: num(/^# pass (\d+)/m),
|
|
170
|
+
fail: num(/^# fail (\d+)/m),
|
|
171
|
+
cancelled: num(/^# cancelled (\d+)/m),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Pure: did a `node --test` run go RED on the violation fixture? True iff the TAP summary reports at
|
|
176
|
+
* least one failure (`# fail >= 1`). The default node-test prover requires this — a negative test
|
|
177
|
+
* that does NOT go red against a known-bad subject is toothless and must not prove fail-first.
|
|
178
|
+
* Mutation-pinned (`>= 1` boundary): a mutant flipping `>=`→`>` (or the threshold) is caught by the
|
|
179
|
+
* `# fail 1` unit case. An unparseable summary yields `fail: 0` → false (fail-closed for the prover).
|
|
180
|
+
*/
|
|
181
|
+
function isNodeTestRed(out) {
|
|
182
|
+
return parseNodeTestSummary(out).fail >= 1;
|
|
183
|
+
}
|
|
184
|
+
/** The names of REAL (run) tests from TAP `ok N - <name>` / `not ok N - <name>` lines. A line with a
|
|
185
|
+
* `# SKIP` / `# TODO` directive is EXCLUDED — a skipped/todo negative test never executed, so it must
|
|
186
|
+
* not count toward non-vacuity (#1259 m1). */
|
|
187
|
+
function tapTestNames(out) {
|
|
188
|
+
if (typeof out !== 'string')
|
|
189
|
+
return [];
|
|
190
|
+
const names = [];
|
|
191
|
+
const re = /^(?:not )?ok \d+ - (.+)$/gm;
|
|
192
|
+
let m;
|
|
193
|
+
while ((m = re.exec(out)) !== null) {
|
|
194
|
+
const rest = m[1];
|
|
195
|
+
if (/\s#\s*(?:SKIP|TODO)\b/i.test(rest))
|
|
196
|
+
continue; // skipped/todo did not run
|
|
197
|
+
names.push(rest.replace(/\s+#\s.*$/, '').trim());
|
|
198
|
+
}
|
|
199
|
+
return names;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* A non-vacuous node-test pass: at least one test, at least one pass, zero failures — AND at least
|
|
203
|
+
* one reported test whose name is NOT merely the target file. `node --test <file>` counts a file
|
|
204
|
+
* with ZERO `test()` calls as one passing "test" named after the file, so the counts alone cannot
|
|
205
|
+
* tell an empty/deleted negative test from a real one (the #1259 BL-01 false-green). Requiring a
|
|
206
|
+
* named test distinct from the file closes that hole.
|
|
207
|
+
*
|
|
208
|
+
* KNOWN CONSTRAINT (fail-closed, not a hole): a real test whose `test('...')` name is EXACTLY the
|
|
209
|
+
* target file's basename emits TAP indistinguishable from an empty file and is conservatively
|
|
210
|
+
* rejected (non-green). A wired negative test must carry a descriptive name, not be named after its
|
|
211
|
+
* own file — a benign authoring constraint, and the safe direction if violated.
|
|
212
|
+
*/
|
|
213
|
+
function isNonVacuousNodeTestPass(out, target) {
|
|
214
|
+
const s = parseNodeTestSummary(out);
|
|
215
|
+
// >=1 test, >=1 pass, ZERO failures AND ZERO cancelled (a cancelled run is not a clean pass, m1).
|
|
216
|
+
if (!(s.tests >= 1 && s.pass >= 1 && s.fail === 0 && s.cancelled === 0))
|
|
217
|
+
return false;
|
|
218
|
+
// Compare BASENAMES: node reports the file-test by varying path forms across OS / node version
|
|
219
|
+
// (absolute, relative, normalized), so an exact-string compare misfires. A real test name (e.g.
|
|
220
|
+
// "guards the must-NOT") has no separators, so its basename never equals the target file's.
|
|
221
|
+
const tgtBase = baseOf(target);
|
|
222
|
+
return tapTestNames(out).some((n) => baseOf(n) !== tgtBase);
|
|
223
|
+
}
|
|
224
|
+
/** The names of FAILING (run) tests from TAP `not ok N - <name>` lines, excluding `# SKIP`/`# TODO`
|
|
225
|
+
* directives (a skipped/todo line never ran). The fail-first analog of `tapTestNames`. */
|
|
226
|
+
function tapFailedTestNames(out) {
|
|
227
|
+
if (typeof out !== 'string')
|
|
228
|
+
return [];
|
|
229
|
+
const names = [];
|
|
230
|
+
const re = /^not ok \d+ - (.+)$/gm;
|
|
231
|
+
let m;
|
|
232
|
+
while ((m = re.exec(out)) !== null) {
|
|
233
|
+
const rest = m[1];
|
|
234
|
+
if (/\s#\s*(?:SKIP|TODO)\b/i.test(rest))
|
|
235
|
+
continue; // skipped/todo did not run
|
|
236
|
+
names.push(rest.replace(/\s+#\s.*$/, '').trim());
|
|
237
|
+
}
|
|
238
|
+
return names;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* A NON-VACUOUS node-test RED — the fail-first proof analog of `isNonVacuousNodeTestPass`. True iff
|
|
242
|
+
* the run reports `# fail >= 1` AND at least one FAILING test is named DISTINCTLY from the target file.
|
|
243
|
+
*
|
|
244
|
+
* Why the distinct-name guard: a violation fixture that makes the negative test CRASH at load
|
|
245
|
+
* (ENOENT / throw-on-require / syntax error) emits a FILE-NAMED `not ok 1 - <file>` with `# fail 1`.
|
|
246
|
+
* That is a crash, NOT the negative assertion firing red — so it must not "prove" the test is a
|
|
247
|
+
* regression guard (the RED-side mirror of the BL-01 vacuity hole on the pass side). Requiring a
|
|
248
|
+
* failing test named distinctly from the file closes that hole, symmetric with the clean-pass guard.
|
|
249
|
+
*
|
|
250
|
+
* KNOWN CONSTRAINT (fail-closed, not a hole): a negative test whose `test('...')` name is EXACTLY its
|
|
251
|
+
* own file basename is conservatively rejected — same benign authoring constraint, same safe direction.
|
|
252
|
+
*/
|
|
253
|
+
function isNonVacuousNodeTestRed(out, target) {
|
|
254
|
+
if (!isNodeTestRed(out))
|
|
255
|
+
return false; // no `# fail >= 1` summary -> not red (fail-closed)
|
|
256
|
+
const tgtBase = baseOf(target);
|
|
257
|
+
return tapFailedTestNames(out).some((n) => baseOf(n) !== tgtBase);
|
|
258
|
+
}
|
|
259
|
+
/** Number of file results in an eslint `--format json` report (0 if unparseable / not an array). */
|
|
260
|
+
function eslintFileResultCount(jsonText) {
|
|
261
|
+
try {
|
|
262
|
+
const parsed = JSON.parse(jsonText);
|
|
263
|
+
return Array.isArray(parsed) ? parsed.length : 0;
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return 0;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/** Messages array of a single eslint file-result (empty if absent / wrong shape). */
|
|
270
|
+
function eslintMessages(file, key) {
|
|
271
|
+
return file && typeof file === 'object' && Array.isArray(file[key])
|
|
272
|
+
? file[key]
|
|
273
|
+
: [];
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* True if the eslint `--format json` report has a FATAL / parse error — meaning the rule never got
|
|
277
|
+
* to run on the target. A prohibition gate must fail closed on "the rule didn't execute" (#1259 B1),
|
|
278
|
+
* NOT treat a length-1 fatal result as "clean". Unparseable report -> true (fail-closed).
|
|
279
|
+
*/
|
|
280
|
+
function eslintHasFatalError(jsonText) {
|
|
281
|
+
let parsed;
|
|
282
|
+
try {
|
|
283
|
+
parsed = JSON.parse(jsonText);
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
if (!Array.isArray(parsed))
|
|
289
|
+
return true;
|
|
290
|
+
for (const file of parsed) {
|
|
291
|
+
const fec = file && typeof file === 'object' ? file.fatalErrorCount : undefined;
|
|
292
|
+
if (typeof fec === 'number' && fec > 0)
|
|
293
|
+
return true;
|
|
294
|
+
if (eslintMessages(file, 'messages').some((m) => m && m.fatal === true))
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
/** True if the eslint `--format json` report has ANY message for `rule` — in EITHER `messages` or
|
|
300
|
+
* `suppressedMessages` (an inline `// eslint-disable` of the rule is still a violation, #1259 B1).
|
|
301
|
+
* Unparseable -> true (fail-closed: treat an unreadable report as a violation, not a silent pass). */
|
|
302
|
+
function eslintJsonHasRule(jsonText, rule) {
|
|
303
|
+
let parsed;
|
|
304
|
+
try {
|
|
305
|
+
parsed = JSON.parse(jsonText);
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
if (!Array.isArray(parsed))
|
|
311
|
+
return true;
|
|
312
|
+
for (const file of parsed) {
|
|
313
|
+
for (const key of ['messages', 'suppressedMessages']) {
|
|
314
|
+
for (const msg of eslintMessages(file, key)) {
|
|
315
|
+
if (msg && typeof msg === 'object' && msg.ruleId === rule)
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* The default REAL check runner (used when no `runCheck` is injected). Reports only an OBSERVED,
|
|
324
|
+
* genuinely-non-vacuous pass; guarded so a missing tool / non-zero exit yields a non-passing result,
|
|
325
|
+
* NEVER an uncaught throw (the no-throw contract). It does NOT determine fail-first — that is the
|
|
326
|
+
* separate `proveFailFirst` seam's job (machine-proven against the violation fixture, #1279).
|
|
327
|
+
* - node-test: runs `node --test` (TAP) and requires a NON-VACUOUS pass (>=1 test, >=1 pass, 0 fail
|
|
328
|
+
* AND a reported test named distinctly from the file). A bare exit 0 for an empty/zero-test file
|
|
329
|
+
* — which `node --test` counts as one passing "test" named after the file — is NOT a pass (the
|
|
330
|
+
* #1259 BL-01 false-green fix).
|
|
331
|
+
* - lint-rule: runs the project eslint as `node <eslint-cli> --format json <target>` (flat config
|
|
332
|
+
* loads `local/*` plugins) and requires the target to actually lint (>=1 file result) AND ZERO
|
|
333
|
+
* messages for the specific rule id. `--rule` cannot load a plugin rule, so we filter the
|
|
334
|
+
* structured report by `ruleId` instead (the #1259 SF-01 fix).
|
|
335
|
+
*
|
|
336
|
+
* Both kinds spawn via `process.execPath` (never bare `node`/`npx` — not portably spawnable via
|
|
337
|
+
* `execFileSync` on Windows) with arg arrays (no shell → no injection from a caller-supplied target).
|
|
338
|
+
*/
|
|
339
|
+
/**
|
|
340
|
+
* Env for spawned checks: strip `NODE_TEST_CONTEXT` and `NODE_OPTIONS` so an AMBIENT test-runner
|
|
341
|
+
* context (e.g. running verify under `node --test`, which sets `NODE_TEST_CONTEXT=child-v8`) cannot
|
|
342
|
+
* turn the child `node --test` into a silent v8-reporter worker that emits no parseable TAP — which
|
|
343
|
+
* would otherwise corrupt the verdict. Deterministic, environment-independent execution.
|
|
344
|
+
*/
|
|
345
|
+
function childEnv() {
|
|
346
|
+
const env = { ...process.env };
|
|
347
|
+
delete env.NODE_TEST_CONTEXT;
|
|
348
|
+
delete env.NODE_OPTIONS;
|
|
349
|
+
return env;
|
|
350
|
+
}
|
|
351
|
+
// Bounded subprocess limits (DEFECT.UNBOUNDED-SUBPROCESS): a stuck wired test / eslint must not hang
|
|
352
|
+
// verify forever. On timeout `execFileSync` throws -> caught -> fail-closed (degraded, non-passing).
|
|
353
|
+
// `maxBuffer` caps output so a runaway producer throws (safe direction) rather than OOMs the verifier.
|
|
354
|
+
const NODE_TEST_TIMEOUT_MS = 30_000;
|
|
355
|
+
const ESLINT_TIMEOUT_MS = 60_000;
|
|
356
|
+
const CHECK_MAX_BUFFER = 16 * 1024 * 1024;
|
|
357
|
+
/** Resolve the effective timeout: only a POSITIVE override is honored — `0` (which Node treats as
|
|
358
|
+
* "no timeout") or a negative value falls back to the bounded default, so the subprocess is ALWAYS
|
|
359
|
+
* bounded (a `timeoutMs: 0` injection can never disable the bound). */
|
|
360
|
+
function posTimeout(timeoutMs, def) {
|
|
361
|
+
return typeof timeoutMs === 'number' && timeoutMs > 0 ? timeoutMs : def;
|
|
362
|
+
}
|
|
363
|
+
function defaultRunCheck(check, cwd, timeoutMs) {
|
|
364
|
+
try {
|
|
365
|
+
if (check.kind === 'node-test') {
|
|
366
|
+
let out = '';
|
|
367
|
+
try {
|
|
368
|
+
out = (0, node_child_process_1.execFileSync)(process.execPath, buildNodeTestArgs(check), {
|
|
369
|
+
cwd,
|
|
370
|
+
encoding: 'utf-8',
|
|
371
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
372
|
+
windowsHide: true,
|
|
373
|
+
env: childEnv(),
|
|
374
|
+
timeout: posTimeout(timeoutMs, NODE_TEST_TIMEOUT_MS),
|
|
375
|
+
maxBuffer: CHECK_MAX_BUFFER,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
catch (e) {
|
|
379
|
+
// A failing/timed-out run exits non-zero or is killed (partial TAP on stdout, no `# pass`
|
|
380
|
+
// summary). Parse what we have: a real failure or timeout -> not a non-vacuous pass -> false.
|
|
381
|
+
const stdout = e && typeof e === 'object' && 'stdout' in e ? e.stdout : '';
|
|
382
|
+
out = typeof stdout === 'string' ? stdout : '';
|
|
383
|
+
}
|
|
384
|
+
return { passed: isNonVacuousNodeTestPass(out, check.target) };
|
|
385
|
+
}
|
|
386
|
+
if (check.kind === 'lint-rule') {
|
|
387
|
+
const eslintCli = resolveEslintCli(cwd);
|
|
388
|
+
if (!eslintCli)
|
|
389
|
+
return { passed: false }; // eslint not installed -> fail closed, never throw
|
|
390
|
+
let json = '';
|
|
391
|
+
try {
|
|
392
|
+
json = (0, node_child_process_1.execFileSync)(process.execPath, [eslintCli, ...buildLintArgs(check)], {
|
|
393
|
+
cwd,
|
|
394
|
+
encoding: 'utf-8',
|
|
395
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
396
|
+
windowsHide: true,
|
|
397
|
+
env: childEnv(),
|
|
398
|
+
timeout: posTimeout(timeoutMs, ESLINT_TIMEOUT_MS),
|
|
399
|
+
maxBuffer: CHECK_MAX_BUFFER,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
catch (e) {
|
|
403
|
+
// eslint exits non-zero when ANY error is present; the JSON report is still on stdout.
|
|
404
|
+
// A timeout/kill leaves no parseable JSON -> eslintHasFatalError(unparseable) -> fail-closed.
|
|
405
|
+
const stdout = e && typeof e === 'object' && 'stdout' in e ? e.stdout : '';
|
|
406
|
+
json = typeof stdout === 'string' ? stdout : '';
|
|
407
|
+
}
|
|
408
|
+
// PASS requires: the target actually linted (>=1 file result), NO fatal/parse error (the rule
|
|
409
|
+
// must have RUN — #1259 B1), and ZERO messages for the rule (in messages OR suppressedMessages).
|
|
410
|
+
const lintedSomething = eslintFileResultCount(json) >= 1;
|
|
411
|
+
return {
|
|
412
|
+
passed: lintedSomething && !eslintHasFatalError(json) && !eslintJsonHasRule(json, check.rule),
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
// Unknown kind — defensive; the LOCATE guard already rejects it.
|
|
416
|
+
return { passed: false };
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
return { passed: false };
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* The default REAL fail-first prover (#1279; used when no `proveFailFirst` is injected). It runs the
|
|
424
|
+
* wired check against the descriptor's `violationFixture` (a KNOWN-BAD subject) and requires it to go
|
|
425
|
+
* RED — the machine proof that replaces caller attestation. Like `defaultRunCheck`, it is the
|
|
426
|
+
* impure/injectable seam (spawns eslint / `node --test`), reuses the identical bounded-subprocess
|
|
427
|
+
* machinery (`childEnv`/`posTimeout`/`CHECK_MAX_BUFFER`, `execFileSync(process.execPath, …)`, arg
|
|
428
|
+
* arrays → no shell), and NEVER throws — every un-provable path returns `{ provenFailFirst: false }`.
|
|
429
|
+
*
|
|
430
|
+
* - lint-rule: lint the `violationFixture` via the project flat config (so `local/*` plugins load)
|
|
431
|
+
* and require the target to actually lint (>=1 file result) AND no fatal/parse error AND the rule
|
|
432
|
+
* id to appear in the report (messages OR suppressedMessages — an inline-disabled violation still
|
|
433
|
+
* proves the rule has teeth, #1259 B1). Absent fixture / unresolvable eslint → not proven.
|
|
434
|
+
* - node-test: spawn the negative test (TAP) with `GSD_PROHIB_SUBJECT` set to the `violationFixture`
|
|
435
|
+
* — the CONVENTION (#1279) by which a negative test reads its subject-under-test — and require a
|
|
436
|
+
* NON-VACUOUS red (`isNonVacuousNodeTestRed`: `# fail >= 1` AND a failing test named distinctly
|
|
437
|
+
* from the file, so a load-CRASH on the bad subject is not mistaken for the assertion firing red).
|
|
438
|
+
* A toothless test that passes anyway → not proven. Absent fixture → not proven (fail-closed;
|
|
439
|
+
* NEVER falls back to attestation).
|
|
440
|
+
*/
|
|
441
|
+
function defaultProveFailFirst(check, cwd, timeoutMs) {
|
|
442
|
+
try {
|
|
443
|
+
if (check.kind === 'lint-rule') {
|
|
444
|
+
const fixture = check.violationFixture;
|
|
445
|
+
if (!fixture)
|
|
446
|
+
return { provenFailFirst: false }; // can't prove without a known violation -> hard-gate
|
|
447
|
+
const eslintCli = resolveEslintCli(cwd);
|
|
448
|
+
if (!eslintCli)
|
|
449
|
+
return { provenFailFirst: false }; // eslint not installed -> fail closed, never throw
|
|
450
|
+
let json = '';
|
|
451
|
+
try {
|
|
452
|
+
json = (0, node_child_process_1.execFileSync)(process.execPath, [eslintCli, ...buildLintArgs({ ...check, target: fixture })], {
|
|
453
|
+
cwd,
|
|
454
|
+
encoding: 'utf-8',
|
|
455
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
456
|
+
windowsHide: true,
|
|
457
|
+
env: childEnv(),
|
|
458
|
+
timeout: posTimeout(timeoutMs, ESLINT_TIMEOUT_MS),
|
|
459
|
+
maxBuffer: CHECK_MAX_BUFFER,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
catch (e) {
|
|
463
|
+
// eslint exits non-zero on any error; the JSON report is still on stdout. A timeout/kill
|
|
464
|
+
// leaves no parseable JSON -> eslintHasFatalError(unparseable) -> not proven (fail-closed).
|
|
465
|
+
const stdout = e && typeof e === 'object' && 'stdout' in e ? e.stdout : '';
|
|
466
|
+
json = typeof stdout === 'string' ? stdout : '';
|
|
467
|
+
}
|
|
468
|
+
// Proven iff: the fixture actually linted (>=1 file result), the rule RAN (no fatal/parse
|
|
469
|
+
// error), and the rule id appears (the violation was flagged -> the rule has teeth).
|
|
470
|
+
const proven = eslintFileResultCount(json) >= 1
|
|
471
|
+
&& !eslintHasFatalError(json)
|
|
472
|
+
&& eslintJsonHasRule(json, check.rule);
|
|
473
|
+
return { provenFailFirst: proven, method: 'violation-fixture' };
|
|
474
|
+
}
|
|
475
|
+
if (check.kind === 'node-test') {
|
|
476
|
+
const fixture = check.violationFixture;
|
|
477
|
+
// Fail-CLOSED on a missing/typo'd/stale fixture path, SYMMETRIC with the lint-rule path's
|
|
478
|
+
// `eslintFileResultCount >= 1` guard. Without the existence check, a non-existent fixture makes
|
|
479
|
+
// `GSD_PROHIB_SUBJECT` point at a missing file; an honest negative test reading that subject
|
|
480
|
+
// throws ENOENT *inside its callback* — a failing test named DISTINCTLY from the file, which
|
|
481
|
+
// `isNonVacuousNodeTestRed` would accept as proof. That is fail-OPEN: a typo forges a green from
|
|
482
|
+
// a setup crash, not from the prohibition firing. Requiring the fixture to exist before spawning
|
|
483
|
+
// closes the realistic typo/stale-path case (#1279 review, Major 1).
|
|
484
|
+
//
|
|
485
|
+
// KNOWN RESIDUAL (documented, fail-open direction, tracked follow-up #1346): existence is
|
|
486
|
+
// necessary but not sufficient — a deliberately deceptive negative test that reds merely BECAUSE
|
|
487
|
+
// `GSD_PROHIB_SUBJECT` is set (rather than because the subject's CONTENT violates the must-NOT)
|
|
488
|
+
// is still accepted. Proving "the red was CAUSED BY the violation" cannot be done generically for
|
|
489
|
+
// an arbitrary author-supplied test, so it is recorded as a constraint, not silently implied-solved.
|
|
490
|
+
// Resolve the fixture against `cwd` (NOT the verify process's cwd): the spawned test reads
|
|
491
|
+
// `GSD_PROHIB_SUBJECT` and resolves a relative subject against `cwd`, so the existence check must
|
|
492
|
+
// use the SAME base or it could pass here yet ENOENT in the child (re-opening the fail-open hole).
|
|
493
|
+
if (!fixture || !node_fs_1.default.existsSync(node_path_1.default.resolve(cwd, fixture)))
|
|
494
|
+
return { provenFailFirst: false };
|
|
495
|
+
let out = '';
|
|
496
|
+
try {
|
|
497
|
+
out = (0, node_child_process_1.execFileSync)(process.execPath, buildNodeTestArgs(check), {
|
|
498
|
+
cwd,
|
|
499
|
+
encoding: 'utf-8',
|
|
500
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
501
|
+
windowsHide: true,
|
|
502
|
+
// CONVENTION (#1279): the negative test reads its subject-under-test from this env var.
|
|
503
|
+
env: { ...childEnv(), GSD_PROHIB_SUBJECT: fixture },
|
|
504
|
+
timeout: posTimeout(timeoutMs, NODE_TEST_TIMEOUT_MS),
|
|
505
|
+
maxBuffer: CHECK_MAX_BUFFER,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
catch (e) {
|
|
509
|
+
// A negative test that goes RED exits non-zero; the partial TAP (with the `# fail` summary)
|
|
510
|
+
// is on stdout. Parse what we have: a real failure here is the PROOF the test is fail-first.
|
|
511
|
+
const stdout = e && typeof e === 'object' && 'stdout' in e ? e.stdout : '';
|
|
512
|
+
out = typeof stdout === 'string' ? stdout : '';
|
|
513
|
+
}
|
|
514
|
+
return { provenFailFirst: isNonVacuousNodeTestRed(out, check.target), method: 'violation-fixture' };
|
|
515
|
+
}
|
|
516
|
+
// Unknown kind — defensive; the LOCATE guard already rejects it.
|
|
517
|
+
return { provenFailFirst: false };
|
|
518
|
+
}
|
|
519
|
+
catch {
|
|
520
|
+
return { provenFailFirst: false };
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* LOCATE -> PROVE fail-first -> RUN -> build enforcementEvidence -> dispositionForProhibition.
|
|
525
|
+
*
|
|
526
|
+
* (1) LOCATE: if no well-formed check descriptor is locatable -> fail-closed
|
|
527
|
+
* (`dispositionForProhibition` with empty evidence) plus `{ located: false, kind: null, evidence: [] }`.
|
|
528
|
+
* (2) PROVE + RUN: machine-prove fail-first via `proveFailFirst` (default `defaultProveFailFirst`) AND
|
|
529
|
+
* run the check via `runCheck`. The green AND is `proof.provenFailFirst === true && run.passed ===
|
|
530
|
+
* true` — caller attestation is NOT consulted (FF-08). A check that cannot be proven fail-first, or
|
|
531
|
+
* that does not genuinely (non-vacuously) PASS -> fail-closed disposition with `located: true` (a
|
|
532
|
+
* real located miss, non-green, flagged) in BOTH modes.
|
|
533
|
+
* (3) PASS: build a typed `enforcementEvidence` array (recording the proof method) and call
|
|
534
|
+
* `dispositionForProhibition` — the non-empty array flips a test-tier item to green.
|
|
535
|
+
*
|
|
536
|
+
* Pure/deterministic DECISION layer: the only impure seams are the injectable `runCheck`/`proveFailFirst`;
|
|
537
|
+
* given their results the disposition is same-input-same-output and mutation-survivable.
|
|
538
|
+
*/
|
|
539
|
+
function runProhibitionEnforcement(prohibition, check, options = {}) {
|
|
540
|
+
const mode = options.mode;
|
|
541
|
+
// (1) LOCATE — no locatable, well-formed wired check -> fail-closed, located: false. The kind must
|
|
542
|
+
// be one of the two known kinds; the target must be a non-empty string; a lint-rule descriptor MUST
|
|
543
|
+
// also carry a non-empty `rule` id (its target is the lint PATH, not the rule). An under-specified
|
|
544
|
+
// descriptor is not a valid wired check, so it is not locatable (does not rely on the runner failing).
|
|
545
|
+
const c = check && typeof check === 'object' ? check : null;
|
|
546
|
+
const validKind = !!c && (c.kind === 'node-test' || c.kind === 'lint-rule');
|
|
547
|
+
const validTarget = !!c && typeof c.target === 'string' && c.target.trim().length > 0;
|
|
548
|
+
const validRule = !!c && (c.kind !== 'lint-rule' || (typeof c.rule === 'string' && c.rule.trim().length > 0));
|
|
549
|
+
if (!c || !validKind || !validTarget || !validRule) {
|
|
550
|
+
const disposition = (0, probe_core_cjs_1.dispositionForProhibition)(prohibition, { enforcementEvidence: [] });
|
|
551
|
+
return { ...disposition, located: false, kind: null, evidence: [], ...(mode ? { mode } : {}) };
|
|
552
|
+
}
|
|
553
|
+
const runCheck = options.runCheck ?? ((toRun) => defaultRunCheck(toRun, options.cwd ?? process.cwd(), options.timeoutMs));
|
|
554
|
+
const proveFailFirst = options.proveFailFirst ?? ((toProve) => defaultProveFailFirst(toProve, options.cwd ?? process.cwd(), options.timeoutMs));
|
|
555
|
+
// (2) PROVE fail-first (MACHINE) + RUN. The prover must INDEPENDENTLY run the check against the
|
|
556
|
+
// descriptor's violation fixture and observe it go red (`proof.provenFailFirst`), AND the runner
|
|
557
|
+
// must observe a genuine NON-VACUOUS pass. Caller attestation (`c.failFirst`) is NOT consulted for
|
|
558
|
+
// the green verdict (FF-08) — a check that cannot be machine-proven fail-first hard-gates (never
|
|
559
|
+
// green) in BOTH modes, even if attested.
|
|
560
|
+
// No-throw contract end-to-end: even a (test-injected) prover/runner that throws must fail closed,
|
|
561
|
+
// never propagate. The default real prover/runner already never throw.
|
|
562
|
+
let proof;
|
|
563
|
+
try {
|
|
564
|
+
proof = proveFailFirst(c);
|
|
565
|
+
}
|
|
566
|
+
catch {
|
|
567
|
+
proof = { provenFailFirst: false };
|
|
568
|
+
}
|
|
569
|
+
let run;
|
|
570
|
+
try {
|
|
571
|
+
run = runCheck(c);
|
|
572
|
+
}
|
|
573
|
+
catch {
|
|
574
|
+
run = { passed: false };
|
|
575
|
+
}
|
|
576
|
+
// The `&&` and `=== true` are mutation-load-bearing — both directions (proven-red AND clean-pass)
|
|
577
|
+
// must hold for green; Plan 01/02 guards pin them.
|
|
578
|
+
const passed = proof.provenFailFirst === true && run.passed === true;
|
|
579
|
+
if (!passed) {
|
|
580
|
+
// NOT machine-proven fail-first OR did not genuinely pass -> fail-closed, located: true (an actual
|
|
581
|
+
// located miss/fail). Hard-gate applies in BOTH modes; the disposition stays non-green / flagged.
|
|
582
|
+
const disposition = (0, probe_core_cjs_1.dispositionForProhibition)(prohibition, { enforcementEvidence: [] });
|
|
583
|
+
return {
|
|
584
|
+
...disposition,
|
|
585
|
+
located: true,
|
|
586
|
+
kind: c.kind,
|
|
587
|
+
evidence: [],
|
|
588
|
+
...(mode ? { mode } : {}),
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
// (3) PASS -> build typed enforcementEvidence and let the policy flip a test-tier item green.
|
|
592
|
+
// `failFirst: true` here means MACHINE-PROVEN (the green AND required `proof.provenFailFirst`),
|
|
593
|
+
// and `failFirstProof` records HOW it was proven (FF-07).
|
|
594
|
+
const evidence = [{
|
|
595
|
+
kind: c.kind,
|
|
596
|
+
target: c.target,
|
|
597
|
+
...(typeof c.rule === 'string' ? { rule: c.rule } : {}),
|
|
598
|
+
failFirst: true,
|
|
599
|
+
passed: true,
|
|
600
|
+
...(proof.method ? { failFirstProof: proof.method } : {}),
|
|
601
|
+
}];
|
|
602
|
+
const disposition = (0, probe_core_cjs_1.dispositionForProhibition)(prohibition, { enforcementEvidence: evidence });
|
|
603
|
+
return {
|
|
604
|
+
...disposition,
|
|
605
|
+
located: true,
|
|
606
|
+
kind: c.kind,
|
|
607
|
+
evidence,
|
|
608
|
+
...(mode ? { mode } : {}),
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Parse a `{ prohibition, check, mode }` request from a JSON file path or inline `--json` string.
|
|
613
|
+
* Returns null on any parse failure (the caller surfaces a structured error, never a throw).
|
|
614
|
+
*/
|
|
615
|
+
function parseRequest(args) {
|
|
616
|
+
// args[0] = 'check', args[1] = 'prohibition-enforcement', args[2] = <json-file-path | --json>
|
|
617
|
+
const jsonFlagIdx = args.indexOf('--json');
|
|
618
|
+
let payload = '';
|
|
619
|
+
if (jsonFlagIdx !== -1 && typeof args[jsonFlagIdx + 1] === 'string') {
|
|
620
|
+
payload = args[jsonFlagIdx + 1];
|
|
621
|
+
}
|
|
622
|
+
else if (typeof args[2] === 'string' && args[2]) {
|
|
623
|
+
try {
|
|
624
|
+
payload = node_fs_1.default.readFileSync(args[2], 'utf-8');
|
|
625
|
+
}
|
|
626
|
+
catch {
|
|
627
|
+
return null;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
else {
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
633
|
+
try {
|
|
634
|
+
const parsed = JSON.parse(payload);
|
|
635
|
+
const checkRaw = parsed['check'];
|
|
636
|
+
const check = (checkRaw && typeof checkRaw === 'object')
|
|
637
|
+
? checkRaw
|
|
638
|
+
: null;
|
|
639
|
+
const modeRaw = parsed['mode'];
|
|
640
|
+
const mode = typeof modeRaw === 'string' ? modeRaw : undefined;
|
|
641
|
+
return { prohibition: parsed['prohibition'] ?? null, check, ...(mode ? { mode } : {}) };
|
|
642
|
+
}
|
|
643
|
+
catch {
|
|
644
|
+
return null;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* CLI surface: `gsd_run check prohibition-enforcement <request.json>` (or `--json '<inline>'`).
|
|
649
|
+
* Parses the request, runs the producer, and emits the result as JSON. Honors the no-throw
|
|
650
|
+
* contract: malformed input -> structured `error(...)`, never an uncaught throw.
|
|
651
|
+
*/
|
|
652
|
+
function routeProhibitionEnforcement(args, raw) {
|
|
653
|
+
const req = parseRequest(args);
|
|
654
|
+
if (!req) {
|
|
655
|
+
error('prohibition-enforcement requires a JSON request: check prohibition-enforcement <request.json> | --json \'{"prohibition":{...},"check":{...}}\'', ERROR_REASON.SDK_MISSING_ARG);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
const result = runProhibitionEnforcement(req.prohibition, req.check, req.mode ? { mode: req.mode } : {});
|
|
659
|
+
output(result, raw, undefined);
|
|
660
|
+
}
|