feat(gate): stop rule in the plan gate (decision 217, 220)
recordVerdict appends each round's summary to a new optional verdict field `rounds` (carried from the previous verdict; newByClass null for a round counted in full) and stores `stop_rule`; extractVerdict shape-checks both. After a fired stop rule --record exits 1 with PLAN_GATE_STOP_RULE and writes nothing (--extra-round included). The bare gate stops with PLAN_GATE_STOP_RULE and the operator line in place of the open-blocker and open-major reasons; --json and --record output carry the decision. --accept-open after a fired stop rule is allowed with rounds left, covers open blockers and majors (`under: "stop-rule"`), refuses a counting security finding (PLAN_GATE_SECURITY_OPEN); the gate then warns. Known issue fixed: REVIEW_LOG_SECTIONS drops "Adversarial Pass 2 (v5.1.1 high-effort)"; the CHANGELOG known-issue line closes and Handover 4 names the heading list, the new fields and codes. Existing tests updated for the planned rule: fixtures that ran a later round after one with no fewer blocking findings now use a falling count (plan-gate.test round cap, plan-gate-open-major lastRound, diff-mode 5/18/27 resolve two earlier majors in round 2); the diff-mode CLI pipe case now expects PLAN_GATE_STOP_RULE (round 3: 2 blocking) after its clean round 2. Part 1, step 1.2 of the E plan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
7e09edb24f
commit
a576391e28
7 changed files with 448 additions and 32 deletions
|
|
@ -108,6 +108,17 @@ function assertGuardLowered(v, round, text = GUARD) {
|
|||
/** Round 1 on buildPlan() with the given payloads; returns its result. */
|
||||
const round1 = (payload = {}) => rec(buildPlan(), dd(payload));
|
||||
|
||||
// The stop rule (lib/review/stop-rule.mjs, decision 217) gives no round after
|
||||
// one whose blocking findings did not get fewer. A case about a later round's
|
||||
// counting opens with `EARLIER`: two round-1 majors of their own class, marked
|
||||
// resolved in round 2 by `earlierGone`, so the blocking count falls.
|
||||
const EARLIER = [
|
||||
['Earlier point: the manifest omits the generated fixture folder entirely', S1_VERIFY],
|
||||
['Earlier point: the checkpoint message pattern never matches a scoped commit', S1_ROLLBACK],
|
||||
];
|
||||
const earlier = t => EARLIER.map(([text, line]) => f('major', at(t, line), text, { rule_key: 'earlier' }));
|
||||
const earlierGone = v => EARLIER.map(([text]) => ({ id: byText(v, text).id, resolved: true }));
|
||||
|
||||
// ---- exports ---------------------------------------------------------------------
|
||||
|
||||
test('critic-diff exports the canonical rule sentences, the new verdict fields and the security check', () => {
|
||||
|
|
@ -214,9 +225,10 @@ test('4: a new security major outside the diff stays major (catalogue key in any
|
|||
// ---- 5 -------------------------------------------------------------------------
|
||||
|
||||
test('5: a new blocker outside the diff stays blocker (BLOCK, PLAN_GATE_OPEN_BLOCKER)', () => {
|
||||
const r1 = round1();
|
||||
const r1 = round1({ pc: earlier(buildPlan()) });
|
||||
const t2 = editA(r1.text);
|
||||
const r2 = rec(t2, dd({ pc: [guard(t2), f('blocker', at(t2, S1_CHANGES), 'Step 1 reads a config value that no step ever writes')] }), { base: baseOf(r1.text) });
|
||||
const r2 = rec(t2, dd({ pc: [guard(t2), f('blocker', at(t2, S1_CHANGES), 'Step 1 reads a config value that no step ever writes')], pcPrev: earlierGone(r1.verdict) }), { base: baseOf(r1.text) });
|
||||
assert.equal(r2.verdict.stop_rule?.fired, false, 'fewer blocking findings: the stop rule is quiet');
|
||||
const v = r2.verdict;
|
||||
assertGuardLowered(v, 1);
|
||||
assert.equal(byText(v, 'Step 1 reads a config value that no step ever writes').severity, 'blocker');
|
||||
|
|
@ -630,11 +642,13 @@ test('18: carried positions — another file keeps its line; an edited own line
|
|||
{ file: 'src/mod1.mjs', line: 12, rule_key: 'step-quality', severity: 'major', text: 'Source module already exports a conflicting run helper' },
|
||||
f('major', ownOld, 'Second step change names no return value for run2'),
|
||||
f('major', shiftedOld, 'Step 1 never says what run1 returns on an empty input file'),
|
||||
...earlier(p0),
|
||||
],
|
||||
});
|
||||
const ids = Object.fromEntries(r1.verdict.findings.map(x => [x.text, x.id]));
|
||||
const t2 = editA(editC(r1.text));
|
||||
const r2 = rec(t2, dd({ pc: [guard(t2)] }), { base: baseOf(r1.text) });
|
||||
const r2 = rec(t2, dd({ pc: [guard(t2)], pcPrev: earlierGone(r1.verdict) }), { base: baseOf(r1.text) });
|
||||
assert.equal(r2.verdict.stop_rule?.fired, false, 'fewer blocking findings: the stop rule is quiet');
|
||||
const v2 = r2.verdict;
|
||||
assertGuardLowered(v2, 1);
|
||||
|
||||
|
|
@ -985,7 +999,9 @@ test('CLI pipe: dedup --stdin into --record counts round 2 by rule A, and a refu
|
|||
assert.match(r3.out, /"blocker":2/, `round 3 counts two blockers: ${r3.out}`);
|
||||
const g3 = gate(dir, [p]);
|
||||
assert.equal(g3.code, 1, g3.out + g3.err);
|
||||
assert.match(g3.out + g3.err, /PLAN_GATE_OPEN_BLOCKER/);
|
||||
// Two blockers after a clean round 2: the stop rule ("not fewer") fired, and
|
||||
// its STOP line counts both as blocking in place of PLAN_GATE_OPEN_BLOCKER.
|
||||
assert.match(g3.out + g3.err, /PLAN_GATE_STOP_RULE\] .*round 3: 2 blocking/);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1038,7 +1054,7 @@ test('26: an unrelated major at an old finding\'s place, from the other reviewer
|
|||
test('27: a new file-level major with an old rule-B-lowered finding\'s id counts at major with its own text and id, and survives the old one\'s resolution', () => {
|
||||
const OLD = 'The plan names no owner for the generated fixtures';
|
||||
const r1 = round1({
|
||||
pc: [{ file: 'plan.md', line: 0, rule_key: 'gap', severity: 'minor', lowered_from: 'major', caught_by: 'npm test', text: OLD }],
|
||||
pc: [{ file: 'plan.md', line: 0, rule_key: 'gap', severity: 'minor', lowered_from: 'major', caught_by: 'npm test', text: OLD }, ...earlier(buildPlan())],
|
||||
});
|
||||
const o1 = byText(r1.verdict, OLD);
|
||||
assert.equal(o1.severity, 'minor');
|
||||
|
|
@ -1049,7 +1065,9 @@ test('27: a new file-level major with an old rule-B-lowered finding\'s id counts
|
|||
const NEW2 = 'The rollout order of the two migrations is never stated';
|
||||
const r2 = rec(t2, dd({
|
||||
pc: [{ file: 'plan.md', line: 0, rule_key: 'gap', severity: 'major', text: NEW2 }, guard(t2)],
|
||||
pcPrev: earlierGone(r1.verdict),
|
||||
}), { base: baseOf(r1.text) });
|
||||
assert.equal(r2.verdict.stop_rule?.fired, false, 'fewer blocking findings: the stop rule is quiet');
|
||||
const v2 = r2.verdict;
|
||||
const n2 = byText(v2, NEW2);
|
||||
assert.equal(n2.severity, 'major');
|
||||
|
|
|
|||
|
|
@ -29,12 +29,19 @@ const dedup = (...f) => ({ findings: f, sources: BOTH });
|
|||
const rec = (text, d, opts = {}) => recordVerdict(text, d, { reviewed: planHash(text), ...opts });
|
||||
const codes = (list) => list.map(x => x.code);
|
||||
|
||||
// The earlier rounds hold more open majors than the last (a falling count), so
|
||||
// the stop rule (lib/review/stop-rule.mjs, "not fewer") does not fire and the
|
||||
// last round is judged by the open-major rules alone.
|
||||
function lastRound(...f) {
|
||||
let text = buildPlan();
|
||||
for (let i = 1; i < MAX_ROUNDS; i++) text = rec(text, dedup()).text;
|
||||
for (let i = 1; i < MAX_ROUNDS; i++) {
|
||||
const earlier = Array.from({ length: MAX_ROUNDS - i + 5 }, (_, k) => finding('major', `earlier round ${i} open point ${'x'.repeat(k)}`));
|
||||
text = rec(text, dedup(...earlier)).text;
|
||||
}
|
||||
const r = rec(text, dedup(...f));
|
||||
assert.equal(r.code, 0, r.message);
|
||||
assert.equal(r.verdict.round, MAX_ROUNDS);
|
||||
assert.equal(r.verdict.stop_rule?.fired, false, 'the stop rule does not fire on a falling count');
|
||||
return r.text;
|
||||
}
|
||||
|
||||
|
|
@ -76,7 +83,8 @@ test('--accept-open never accepts a BLOCKER, and refuses when no MAJOR is open',
|
|||
});
|
||||
|
||||
test('an acceptance does not carry over to a later round', () => {
|
||||
const accepted = acceptOpen(lastRound(finding('major', WEAK))).text;
|
||||
// Two majors accepted, one in the extra round: fewer, so the stop rule stays quiet.
|
||||
const accepted = acceptOpen(lastRound(finding('major', WEAK), finding('major', 'Step 3 verify line never names the failing assertion'))).text;
|
||||
const revised = accepted.replace('Add `run1()`', 'Add `run1()` and its error path');
|
||||
const next = rec(revised, dedup(finding('major', 'Step 1 error path untested')), { extraRound: true });
|
||||
assert.equal(next.code, 0, next.message);
|
||||
|
|
|
|||
281
tests/validators/plan-gate-stop-rule.test.mjs
Normal file
281
tests/validators/plan-gate-stop-rule.test.mjs
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
// tests/validators/plan-gate-stop-rule.test.mjs
|
||||
// Part 1, step 1.2 of the E plan (decisions 217, 220): the stop rule in the
|
||||
// plan gate (lib/validators/plan-gate.mjs, rules in lib/review/stop-rule.mjs).
|
||||
//
|
||||
// - recordVerdict appends each round's summary to the optional verdict field
|
||||
// `rounds` (carried from the previous verdict) and stores `stop_rule`;
|
||||
// extractVerdict shape-checks both.
|
||||
// - --record after a verdict whose stop rule fired: exit 1,
|
||||
// PLAN_GATE_STOP_RULE, nothing written; --extra-round cannot open it.
|
||||
// - The bare gate: the stop rule fired and a blocking finding open and not
|
||||
// accepted -> STOP [PLAN_GATE_STOP_RULE], replacing the open-blocker and
|
||||
// open-major reasons.
|
||||
// - --accept-open (220): allowed with rounds left once the stop rule fired,
|
||||
// covers blockers and majors, refuses a counting security finding
|
||||
// (PLAN_GATE_SECURITY_OPEN), recorded `under: "stop-rule"`; the gate turns
|
||||
// each covered blocker into a WARN.
|
||||
// - Known issue fixed: "Adversarial Pass 2 (v5.1.1 high-effort)" is no longer
|
||||
// review log, so a plan holding it hashes differently.
|
||||
//
|
||||
// The record cases follow tests/validators/plan-gate-diff-mode.test.mjs test 1
|
||||
// (round 1 on buildPlan(), an edit, round 2 against the base); the CLI case
|
||||
// runs the gate as a child in its own temp `git init` repo.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { resolve, join } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, writeFileSync, readFileSync, rmSync, realpathSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
gatePlan, recordVerdict, planHash, operativeText, acceptOpen, extractVerdict, REVIEW_LOG_SECTIONS,
|
||||
} from '../../lib/validators/plan-gate.mjs';
|
||||
import { dedupFindings } from '../../lib/review/plan-review-dedup.mjs';
|
||||
import { buildPlan } from '../helpers/plan-v18-builder.mjs';
|
||||
import { childEnv } from '../helpers/child-env.mjs';
|
||||
|
||||
const NOW = '2026-11-08T00:00:00.000Z';
|
||||
const PLAN = resolve('plan.md');
|
||||
const PATHS = [PLAN];
|
||||
|
||||
function at(text, s) {
|
||||
const n = text.split('\n').findIndex(l => l.includes(s)) + 1;
|
||||
assert.ok(n > 0, `the plan holds ${JSON.stringify(s)}`);
|
||||
return n;
|
||||
}
|
||||
const baseOf = t => ({ sha256: planHash(t), text: operativeText(t) });
|
||||
function rec(text, d, opts = {}) {
|
||||
const r = recordVerdict(text, d, { reviewed: planHash(text), planPaths: PATHS, now: NOW, ...opts });
|
||||
assert.equal(r.code, 0, r.message);
|
||||
return r;
|
||||
}
|
||||
function dd({ pc = [], sg = [], pcPrev, sgPrev } = {}) {
|
||||
return dedupFindings([
|
||||
{ agent: 'plan-critic', payload: { findings: pc, ...(pcPrev ? { previous: pcPrev } : {}) } },
|
||||
{ agent: 'scope-guardian', payload: { findings: sg, ...(sgPrev ? { previous: sgPrev } : {}) } },
|
||||
]);
|
||||
}
|
||||
const f = (severity, line, text, rule_key, extra = {}) => ({ file: 'plan.md', line, rule_key, severity, text, ...extra });
|
||||
|
||||
const R2_CHANGES = '- **Changes:** Add `run2()` to `src/mod2.mjs`. (new file)';
|
||||
const R2_EDITED = '- **Changes:** Add `run2()` and its error path to `src/mod2.mjs`. (new file)\n - `run2()` throws on empty input.';
|
||||
const R1_CHANGES = 'Add `run1()`';
|
||||
const editA = t => { assert.ok(t.includes(R2_CHANGES)); return t.replace(R2_CHANGES, R2_EDITED); };
|
||||
|
||||
const B1 = 'The dependency on the parser module is never declared in the manifest list';
|
||||
const B2 = 'A second import chain reaches the CLI without passing through its argument checker';
|
||||
const M2 = 'Rollback wording names a file the step never touches anywhere in this plan';
|
||||
|
||||
/** Round 1: one blocker of class `dep`; round 2 (after editA): the round-1 blocker resolved, one new `dep` blocker. */
|
||||
function sameClassTwice() {
|
||||
const t1 = buildPlan();
|
||||
const r1 = rec(t1, dd({ pc: [f('blocker', at(t1, R1_CHANGES), B1, 'dep/manifest')] }));
|
||||
const id = r1.verdict.findings[0].id;
|
||||
const t2 = editA(r1.text);
|
||||
const r2 = rec(t2, dd({
|
||||
pc: [f('blocker', at(t2, 'its error path'), B2, 'Dep/import-chain')],
|
||||
pcPrev: [{ id, resolved: true }],
|
||||
}), { base: baseOf(r1.text) });
|
||||
return { r1, r2 };
|
||||
}
|
||||
|
||||
// ---- recordVerdict: rounds and stop_rule ---------------------------------------
|
||||
|
||||
test('record: each round appends its summary to `rounds` and stores `stop_rule`', () => {
|
||||
const { r1, r2 } = sameClassTwice();
|
||||
assert.deepEqual(r1.verdict.rounds, [{ round: 1, blocking: 1, newByClass: { dep: 1 } }]);
|
||||
assert.equal(r1.verdict.stop_rule.fired, false);
|
||||
assert.deepEqual(r2.verdict.rounds, [
|
||||
{ round: 1, blocking: 1, newByClass: { dep: 1 } },
|
||||
{ round: 2, blocking: 1, newByClass: { dep: 1 } },
|
||||
]);
|
||||
assert.equal(r2.verdict.stop_rule.fired, true);
|
||||
assert.equal(r2.verdict.stop_rule.reason, 'same-class');
|
||||
assert.deepEqual(r2.verdict.stop_rule.classes, ['dep']);
|
||||
// The written block reads back with both fields.
|
||||
const ex = extractVerdict(r2.text);
|
||||
assert.equal(ex.error, null);
|
||||
assert.deepEqual(ex.verdict.rounds, r2.verdict.rounds);
|
||||
});
|
||||
|
||||
test('record: a round counted in full (a diff_note) has newByClass null; a falling count does not fire', () => {
|
||||
const t1 = buildPlan();
|
||||
const r1 = rec(t1, dd({ pc: [
|
||||
f('blocker', at(t1, R1_CHANGES), B1, 'dep/manifest'),
|
||||
f('major', at(t1, R1_CHANGES), M2, 'rollback/wording'),
|
||||
] }));
|
||||
const t2 = editA(r1.text);
|
||||
const r2 = rec(t2, dd({ pc: [f('blocker', at(t2, 'its error path'), B2, 'dep/import-chain')] }),
|
||||
{ baseNote: 'no snapshot of round 1; full counting' });
|
||||
assert.ok(typeof r2.verdict.diff_note === 'string');
|
||||
assert.ok(Array.isArray(r2.verdict.rounds), 'rounds is recorded');
|
||||
const last = r2.verdict.rounds.at(-1);
|
||||
assert.equal(last.round, 2);
|
||||
assert.equal(last.newByClass, null);
|
||||
assert.equal(last.blocking, 1);
|
||||
assert.equal(r2.verdict.stop_rule.fired, false);
|
||||
});
|
||||
|
||||
test('record: an old verdict without history starts one and does not fire', () => {
|
||||
const { r1 } = sameClassTwice();
|
||||
const ex = extractVerdict(r1.text);
|
||||
const old = { ...ex.verdict };
|
||||
delete old.rounds;
|
||||
delete old.stop_rule;
|
||||
const oldText = r1.text.replace(/```json\n[\s\S]*?\n```/, '```json\n' + JSON.stringify(old, null, 2) + '\n```');
|
||||
assert.equal(extractVerdict(oldText).error, null);
|
||||
const t2 = editA(oldText);
|
||||
const r2 = rec(t2, dd({ pc: [f('blocker', at(t2, 'its error path'), B2, 'dep/import-chain')] }), { base: baseOf(oldText) });
|
||||
assert.equal(r2.verdict.rounds?.length, 1);
|
||||
assert.equal(r2.verdict.rounds[0].round, 2);
|
||||
assert.equal(r2.verdict.stop_rule.fired, false);
|
||||
});
|
||||
|
||||
test('record: after a fired stop rule --record exits 1 with PLAN_GATE_STOP_RULE and writes nothing, --extra-round too', () => {
|
||||
const { r2 } = sameClassTwice();
|
||||
const t3 = r2.text.replace('Why: the brief asks for a parser CLI.', 'Why: the brief asks for a parser CLI with clear errors.');
|
||||
for (const extraRound of [false, true]) {
|
||||
const r3 = recordVerdict(t3, dd(), { reviewed: planHash(t3), planPaths: PATHS, now: NOW, base: baseOf(r2.text), extraRound });
|
||||
assert.equal(r3.code, 1, r3.message);
|
||||
assert.equal(r3.reason, 'PLAN_GATE_STOP_RULE');
|
||||
assert.match(r3.message, /PLAN_GATE_STOP_RULE/);
|
||||
assert.match(r3.message, /dep/);
|
||||
assert.equal(r3.text, undefined, 'nothing is written');
|
||||
}
|
||||
});
|
||||
|
||||
test('extractVerdict shape-checks rounds and stop_rule', () => {
|
||||
const { r2 } = sameClassTwice();
|
||||
const v = extractVerdict(r2.text).verdict;
|
||||
const put = obj => r2.text.replace(/```json\n[\s\S]*?\n```/, '```json\n' + JSON.stringify(obj, null, 2) + '\n```');
|
||||
for (const bad of [
|
||||
{ ...v, rounds: 'x' },
|
||||
{ ...v, rounds: [{ round: 0, blocking: 1, newByClass: null }] },
|
||||
{ ...v, rounds: [{ round: 1, blocking: -1, newByClass: null }] },
|
||||
{ ...v, rounds: [{ round: 1, blocking: 1, newByClass: { dep: 'one' } }] },
|
||||
{ ...v, stop_rule: { fired: 'yes' } },
|
||||
{ ...v, stop_rule: 'fired' },
|
||||
]) {
|
||||
const ex = extractVerdict(put(bad));
|
||||
assert.ok(ex.error, `malformed: ${JSON.stringify({ rounds: bad.rounds, stop_rule: bad.stop_rule })}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- the bare gate ------------------------------------------------------------------
|
||||
|
||||
test('gate: a fired stop rule with an open blocker stops with PLAN_GATE_STOP_RULE in place of the open-finding reasons', () => {
|
||||
const { r2 } = sameClassTwice();
|
||||
const g = gatePlan(r2.text);
|
||||
assert.equal(g.code, 1);
|
||||
const codes = g.reasons.map(x => x.code);
|
||||
assert.deepEqual(codes, ['PLAN_GATE_STOP_RULE'], JSON.stringify(g.reasons));
|
||||
assert.match(g.reasons[0].message, /class: dep/);
|
||||
assert.match(g.reasons[0].message, /round 1: 1 blocking/);
|
||||
assert.match(g.reasons[0].message, /round 2: 1 blocking/);
|
||||
assert.doesNotMatch(g.reasons[0].message, /one more round/i);
|
||||
assert.equal(g.stop_rule.fired, true);
|
||||
});
|
||||
|
||||
// ---- --accept-open under the stop rule (220) ----------------------------------------
|
||||
|
||||
test('accept-open: after a fired stop rule it is allowed with rounds left, covers the blocker, and the gate warns', () => {
|
||||
const { r2 } = sameClassTwice();
|
||||
assert.ok(r2.verdict.max_rounds - r2.verdict.round > 0, 'rounds are left');
|
||||
const a = acceptOpen(r2.text, { now: NOW });
|
||||
assert.equal(a.code, 0, a.message);
|
||||
const v = extractVerdict(a.text).verdict;
|
||||
assert.equal(v.accepted_open.under, 'stop-rule');
|
||||
assert.deepEqual(v.accepted_open.findings.map(x => x.text), [B2]);
|
||||
const g = gatePlan(a.text);
|
||||
assert.equal(g.code, 0, JSON.stringify(g.reasons));
|
||||
assert.ok(g.warnings.some(w => w.code === 'PLAN_GATE_OPEN_BLOCKER' && w.message.includes(B2)), JSON.stringify(g.warnings));
|
||||
});
|
||||
|
||||
test('accept-open: a counting security finding is refused with PLAN_GATE_SECURITY_OPEN', () => {
|
||||
const t1 = buildPlan();
|
||||
const r1 = rec(t1, dd({ pc: [f('blocker', at(t1, R1_CHANGES), B1, 'SECURITY_INJECTION')] }));
|
||||
const id = r1.verdict.findings[0].id;
|
||||
const t2 = editA(r1.text);
|
||||
const r2 = rec(t2, dd({
|
||||
pc: [f('blocker', at(t2, 'its error path'), B2, 'SECURITY_INJECTION')],
|
||||
pcPrev: [{ id, resolved: true }],
|
||||
}), { base: baseOf(r1.text) });
|
||||
assert.equal(r2.verdict.stop_rule?.fired, true);
|
||||
assert.deepEqual(r2.verdict.stop_rule.classes, ['SECURITY_INJECTION']);
|
||||
const a = acceptOpen(r2.text, { now: NOW });
|
||||
assert.equal(a.code, 1);
|
||||
assert.match(a.message, /PLAN_GATE_SECURITY_OPEN/);
|
||||
assert.equal(a.text, undefined);
|
||||
});
|
||||
|
||||
test('accept-open: without the stop rule it behaves as before (rounds left -> refused, a blocker -> refused)', () => {
|
||||
const t1 = buildPlan();
|
||||
const r1 = rec(t1, dd({ pc: [f('major', at(t1, R1_CHANGES), M2, 'rollback/wording')] }));
|
||||
assert.equal(r1.verdict.stop_rule?.fired, false);
|
||||
const a = acceptOpen(r1.text, { now: NOW });
|
||||
assert.equal(a.code, 1);
|
||||
assert.match(a.message, /round\(s\) left/);
|
||||
});
|
||||
|
||||
// ---- known issue: the Pass 2 heading is plan text now ---------------------------------
|
||||
|
||||
test('the "Adversarial Pass 2 (v5.1.1 high-effort)" heading is no longer review log: a plan holding it hashes differently', () => {
|
||||
assert.ok(!REVIEW_LOG_SECTIONS.includes('Adversarial Pass 2 (v5.1.1 high-effort)'));
|
||||
const t = buildPlan();
|
||||
const withPass2 = `${t.replace(/\s*$/, '\n')}\n## Adversarial Pass 2 (v5.1.1 high-effort)\n\nSkipped.\n`;
|
||||
assert.notEqual(planHash(withPass2), planHash(t));
|
||||
});
|
||||
|
||||
// ---- CLI ------------------------------------------------------------------------------
|
||||
|
||||
const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..');
|
||||
const GATE = join(ROOT, 'lib', 'validators', 'plan-gate.mjs');
|
||||
const GIT_CLEAN = { GIT_DIR: undefined, GIT_WORK_TREE: undefined, GIT_INDEX_FILE: undefined };
|
||||
const run = (cwd, args, input) => {
|
||||
const r = spawnSync(process.execPath, [GATE, ...args], { cwd, input, encoding: 'utf-8', env: childEnv(GIT_CLEAN) });
|
||||
return { code: r.status, out: r.stdout, err: r.stderr };
|
||||
};
|
||||
const lastJson = out => JSON.parse(out.split('\n').filter(l => l !== '').at(-1));
|
||||
|
||||
test('CLI: two rounds with a new dep blocker each -> the gate stops with PLAN_GATE_STOP_RULE and a third --record is refused', () => {
|
||||
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'plan-gate-stop-')));
|
||||
try {
|
||||
assert.equal(spawnSync('git', ['init', '-q'], { cwd: dir, env: childEnv(GIT_CLEAN) }).status, 0);
|
||||
const p = join(dir, 'plan.md');
|
||||
writeFileSync(p, buildPlan());
|
||||
const round = (payload, extra = []) => run(dir, ['--record', p, '--reviewed', planHash(readFileSync(p, 'utf-8')), ...extra], JSON.stringify(dd(payload)));
|
||||
const t1 = readFileSync(p, 'utf-8');
|
||||
const c1 = round({ pc: [{ ...f('blocker', at(t1, R1_CHANGES), B1, 'dep/manifest'), file: p }] });
|
||||
assert.equal(c1.code, 0, c1.out + c1.err);
|
||||
const id = lastJson(c1.out).stop_rule === undefined ? null : extractVerdict(readFileSync(p, 'utf-8')).verdict.findings[0].id;
|
||||
assert.ok(id, 'the --record output carries stop_rule');
|
||||
writeFileSync(p, editA(readFileSync(p, 'utf-8')));
|
||||
const t2 = readFileSync(p, 'utf-8');
|
||||
const c2 = round({ pc: [{ ...f('blocker', at(t2, 'its error path'), B2, 'dep/import-chain'), file: p }], pcPrev: [{ id, resolved: true }] });
|
||||
assert.equal(c2.code, 0, c2.out + c2.err);
|
||||
const j2 = lastJson(c2.out);
|
||||
assert.equal(j2.stop_rule.fired, true);
|
||||
assert.equal(j2.stop_rule.reason, 'same-class');
|
||||
|
||||
const g = run(dir, [p]);
|
||||
assert.equal(g.code, 1, g.out);
|
||||
assert.match(g.out, /STOP {2}\[PLAN_GATE_STOP_RULE\] .*class: dep.*round 1: 1 blocking.*round 2: 1 blocking/);
|
||||
assert.doesNotMatch(g.out, /PLAN_GATE_OPEN_BLOCKER/);
|
||||
const gj = run(dir, [p, '--json']);
|
||||
assert.equal(gj.code, 1);
|
||||
assert.equal(JSON.parse(gj.out).stop_rule.fired, true);
|
||||
|
||||
const before = readFileSync(p, 'utf-8');
|
||||
for (const extra of [[], ['--extra-round']]) {
|
||||
const c3 = round({}, extra);
|
||||
assert.equal(c3.code, 1, c3.out + c3.err);
|
||||
const j3 = lastJson(c3.out);
|
||||
assert.equal(j3.recorded, false);
|
||||
assert.equal(j3.reason, 'PLAN_GATE_STOP_RULE');
|
||||
assert.equal(readFileSync(p, 'utf-8'), before, 'nothing written');
|
||||
}
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
|
@ -172,8 +172,12 @@ test(`--record counts rounds; round ${MAX_ROUNDS + 1} needs the operator's extra
|
|||
assert.equal(MAX_ROUNDS, 3);
|
||||
let text = buildPlan();
|
||||
assert.equal(rec(text, dedupOut(), { extraRound: true }).code, 2, '--extra-round within the cap is refused, not banked');
|
||||
// A falling count of blockers (3, 2, 1), so the stop rule (lib/review/stop-rule.mjs,
|
||||
// "not fewer") never fires and the cap is what refuses round 4.
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const r = rec(text, dedupOut(finding('blocker', `still broken ${i}`)));
|
||||
const blockers = Array.from({ length: 4 - i }, (_, k) => finding('blocker', `still broken ${i}.${k}`));
|
||||
const r = rec(text, dedupOut(...blockers));
|
||||
assert.equal(r.verdict?.stop_rule?.fired, false, `round ${i}: the stop rule does not fire on a falling count`);
|
||||
assert.equal(r.code, 0, r.message);
|
||||
assert.equal(r.verdict.round, i);
|
||||
text = r.text;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue