fix(1259-01): lint-rule real runner — keep rule id distinct from lint target

The default lint-rule runner passed check.target as BOTH the --rule id and the
eslint path, so it could never pass (eslint tried to lint a file named after the
rule). Add a distinct check.rule field (rule id) vs check.target (path to lint),
extract a pure exported buildLintArgs() so the mapping is mutation-testable
without spawning eslint, fail-closed on a lint-rule missing its rule id, and carry
the rule into enforcement evidence. Updates verify-phase descriptor docs.
This commit is contained in:
Dave
2026-06-15 13:10:49 -04:00
parent 1919c2fd8f
commit 676436258a
3 changed files with 33 additions and 8 deletions

View File

@@ -76,7 +76,7 @@ Aggregate all must_haves across plans for phase-level verification.
gsd_run check prohibition-enforcement <request.json>
```
where `<request.json>` carries `{ prohibition, check, mode }` — `check` being the wired mechanical-check descriptor `{ kind: 'node-test' | 'lint-rule', target, failFirst: true }`. The producer LOCATES the wired check, CONFIRMS it is fail-first (`regression-must-fail-first`), RUNS it, builds `enforcementEvidence`, and emits the `dispositionForProhibition()` verdict (#1259, ADR-550 D5d). Route the result by its typed fields:
where `<request.json>` carries `{ prohibition, check, mode }` — `check` being the wired mechanical-check descriptor `{ kind: 'node-test' | 'lint-rule', target, rule?, failFirst: true }`. For `node-test`, `target` is the negative-test file path; for `lint-rule`, `target` is the PATH to lint and `rule` is the eslint rule id (e.g. `local/no-source-grep`) — both required (a lint-rule without `rule` is not a valid wired check). The producer LOCATES the wired check, CONFIRMS it is fail-first (`regression-must-fail-first`), RUNS it, builds `enforcementEvidence`, and emits the `dispositionForProhibition()` verdict (#1259, ADR-550 D5d). Route the result by its typed fields:
- **`status: 'green'`, `flagged: false`** (a passing wired negative test / lint rule, `located: true`, non-empty `evidence`) → the item is satisfiable → it can reach **passed**.
- **missing, failing, or non-fail-first check** (`located: false` OR `status: 'unverified'`, `flagged: true`) → **hard-gate**: disposes flagged-unverified, NEVER green, routing to `gaps_found` in BOTH interactive and autonomous modes (a failing mechanical check blocks even AFK; ADR-550 D4 / D3). The deterministic fail-closed default backing every miss/fail is `dispositionForProhibition()` in probe-core (`status: 'unverified'`, `flagged: true` on empty `enforcementEvidence`).

View File

@@ -40,12 +40,15 @@ 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 rule id (lint-rule);
* 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` records whether the check is a genuine `regression-must-fail-first` proof.
*/
export interface CheckDescriptor {
kind: CheckKind;
target: string;
rule?: string;
failFirst?: boolean;
}
@@ -59,6 +62,7 @@ export interface CheckRunResult {
export interface EnforcementEvidence {
kind: CheckKind;
target: string;
rule?: string;
failFirst: boolean;
passed: boolean;
}
@@ -87,8 +91,19 @@ export interface EnforcementResult extends ProhibitionDisposition {
* (the no-throw contract). A real run is fail-first by construction here — the descriptor's
* `failFirst` marker is the authoritative regression-must-fail-first signal the producer confirms.
* - node-test: runs `node --test <target>`; exit 0 = passed.
* - lint-rule: runs `eslint --rule '<rule>: error' <target?>` (or the repo's lint), exit 0 = passed.
* - lint-rule: runs `eslint --rule '<rule>: error' <target>`, exit 0 = passed.
*/
/**
* Pure mapper from a lint-rule descriptor to the eslint argv (the args AFTER `npx`). The rule id
* (`check.rule`, forced to `error`) and the lint TARGET path (`check.target`) are DISTINCT tokens —
* reusing `target` as both (the #1259 pre-fix bug) makes eslint try to lint a file named after the
* rule, which can never pass. Exported so the mapping is unit-testable without spawning eslint.
*/
export function buildLintArgs(check: CheckDescriptor): string[] {
return ['eslint', '--rule', `${check.rule}: error`, check.target];
}
function defaultRunCheck(check: CheckDescriptor, cwd: string): CheckRunResult {
const failFirst = check.failFirst === true;
try {
@@ -101,8 +116,9 @@ function defaultRunCheck(check: CheckDescriptor, cwd: string): CheckRunResult {
});
return { failFirst, passed: true };
}
// lint-rule: run the rule via eslint. A clean exit (0) means no violation -> the must-NOT holds.
execFileSync('npx', ['eslint', '--rule', `${check.target}: error`, check.target], {
// lint-rule: force the rule to error and lint the TARGET path (distinct from the rule id). A
// clean exit (0) means no violation surfaced -> the must-NOT holds across the target.
execFileSync('npx', buildLintArgs(check), {
cwd,
encoding: 'utf-8',
stdio: 'ignore',
@@ -135,8 +151,16 @@ export function runProhibitionEnforcement(
): EnforcementResult {
const mode = options.mode;
// (1) LOCATE — no locatable wired check -> fail-closed, located: false.
if (!check || typeof check !== 'object' || typeof check.kind !== 'string' || typeof check.target !== 'string') {
// (1) LOCATE — no locatable wired check -> fail-closed, located: false. A lint-rule descriptor
// MUST also carry a string `rule` id (its target is the lint PATH, not the rule) — an
// under-specified lint-rule is not a valid wired check, so it is not locatable.
if (
!check ||
typeof check !== 'object' ||
typeof check.kind !== 'string' ||
typeof check.target !== 'string' ||
(check.kind === 'lint-rule' && typeof check.rule !== 'string')
) {
const disposition = dispositionForProhibition(prohibition, { enforcementEvidence: [] });
return { ...disposition, located: false, kind: null, evidence: [], ...(mode ? { mode } : {}) };
}
@@ -166,6 +190,7 @@ export function runProhibitionEnforcement(
const evidence: EnforcementEvidence[] = [{
kind: check.kind,
target: check.target,
...(typeof check.rule === 'string' ? { rule: check.rule } : {}),
failFirst: true,
passed: true,
}];

View File

@@ -104,7 +104,7 @@ describe('prohibition-probe verify-tier: test-tier ENFORCEMENT (REQ-PROHIB-07 /
const enforce = require(ENFORCEMENT_LIB);
const result = enforce.runProhibitionEnforcement(
testTierProhibition,
{ kind: 'lint-rule', target: 'no-source-grep', failFirst: true },
{ kind: 'lint-rule', rule: 'local/no-source-grep', target: 'tests/', failFirst: true },
{ runCheck: () => ({ failFirst: true, passed: true }) },
);
assert.equal(result.status, 'green', 'a passing wired lint-rule check must dispose green');