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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue