// 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 { join } from 'node:path'; import { writeFileSync, readFileSync, rmSync } from 'node:fs'; import { gatePlan, recordVerdict, planHash, acceptOpen, extractVerdict, REVIEW_LOG_SECTIONS, } from '../../lib/validators/plan-gate.mjs'; import { buildPlan } from '../helpers/plan-v18-builder.mjs'; import { PATHS, at, baseOf, recorder, dd, keyedFinding as f, R1_CHANGES, editA, runGate as run, lastJson, tempRepo, } from '../helpers/plan-gate-harness.mjs'; const NOW = '2026-11-08T00:00:00.000Z'; const rec = recorder(NOW); 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); }); const T3_FROM = 'Why: the brief asks for a parser CLI.'; const T3_TO = 'Why: the brief asks for a parser CLI with clear errors.'; test('gate: a stale verdict after a fired stop rule never says "run the next round"; without the stop rule it does', () => { const { r1, r2 } = sameClassTwice(); assert.ok(r2.text.includes(T3_FROM)); const g = gatePlan(r2.text.replace(T3_FROM, T3_TO)); assert.equal(g.code, 1); const stale = g.reasons.find(x => x.code === 'PLAN_GATE_VERDICT_STALE'); assert.ok(stale, JSON.stringify(g.reasons)); assert.doesNotMatch(stale.message, /run the next round/); assert.match(stale.message, /stop rule fired/); assert.match(stale.message, /no next round/); // Control: the stop rule did not fire after round 1. assert.equal(r1.verdict.stop_rule.fired, false); const c = gatePlan(r1.text.replace(T3_FROM, T3_TO)); const cs = c.reasons.find(x => x.code === 'PLAN_GATE_VERDICT_STALE'); assert.ok(cs, JSON.stringify(c.reasons)); assert.match(cs.message, /run the next round/); }); /** The verdict block of `text` replaced by `fn(verdict)`. */ const putVerdict = (text, fn) => text.replace(/```json\n[\s\S]*?\n```/, () => '```json\n' + JSON.stringify(fn(extractVerdict(text).verdict), null, 2) + '\n```'); test('gate guard: a planted stop-rule acceptance that claims an open SECURITY blocker leaves the gate at STOP', () => { 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); const planted = putVerdict(r2.text, v => ({ ...v, accepted_open: { round: v.round, plan_sha256: v.plan_sha256, accepted_at: NOW, under: 'stop-rule', findings: v.findings.map(x => ({ id: x.id ?? null, text: x.text ?? '' })) }, })); const ex = extractVerdict(planted); assert.equal(ex.error, null); assert.equal(ex.verdict.accepted_open.under, 'stop-rule'); assert.equal(planHash(planted), planHash(r2.text), 'the planted acceptance is review log'); const g = gatePlan(planted); assert.equal(g.code, 1, JSON.stringify(g)); assert.equal(g.status, 'stop'); assert.ok(g.reasons.some(x => x.code === 'PLAN_GATE_STOP_RULE'), JSON.stringify(g.reasons)); }); test('gate guard: a planted acceptance covering a blocker without `under: "stop-rule"` leaves the gate at STOP', () => { const { r2 } = sameClassTwice(); const planted = putVerdict(r2.text, v => ({ ...v, accepted_open: { round: v.round, plan_sha256: v.plan_sha256, accepted_at: NOW, findings: v.findings.map(x => ({ id: x.id ?? null, text: x.text ?? '' })) }, })); const ex = extractVerdict(planted); assert.equal(ex.error, null); assert.equal(ex.verdict.accepted_open.under, undefined); assert.equal(planHash(planted), planHash(r2.text), 'the planted acceptance is review log'); const g = gatePlan(planted); assert.equal(g.code, 1, JSON.stringify(g)); assert.equal(g.status, 'stop'); assert.ok(g.reasons.some(x => x.code === 'PLAN_GATE_STOP_RULE'), JSON.stringify(g.reasons)); }); // ---- --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/); // A blocker open, the stop rule not fired: refused as a blocker. const b1 = rec(t1, dd({ pc: [f('blocker', at(t1, R1_CHANGES), B1, 'dep/manifest')] })); assert.equal(b1.verdict.stop_rule?.fired, false); const b = acceptOpen(b1.text, { now: NOW }); assert.equal(b.code, 1); assert.match(b.message, /a blocker is never accepted/); assert.equal(b.text, undefined, 'nothing is written'); }); const M3 = 'Rollback wording in the second step names a script that the repository lacks'; test('accept-open: after a fired stop rule it covers an open MAJOR (no blocker) and the gate passes with a WARN', () => { const t1 = buildPlan(); const r1 = rec(t1, dd({ pc: [f('major', at(t1, R1_CHANGES), M2, 'rollback/wording')] })); const id = r1.verdict.findings[0].id; const t2 = editA(r1.text); const r2 = rec(t2, dd({ pc: [f('major', at(t2, 'its error path'), M3, 'rollback/missing-script')], pcPrev: [{ id, resolved: true }], }), { base: baseOf(r1.text) }); assert.equal(r2.verdict.stop_rule?.fired, true, JSON.stringify(r2.verdict.stop_rule)); assert.deepEqual(r2.verdict.findings.map(x => x.severity.toLowerCase()), ['major']); // Before the acceptance: STOP on the stop rule. assert.deepEqual(gatePlan(r2.text).reasons.map(x => x.code), ['PLAN_GATE_STOP_RULE']); 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), [M3]); const g = gatePlan(a.text); assert.equal(g.code, 0, JSON.stringify(g.reasons)); assert.equal(g.status, 'pass'); assert.ok(g.warnings.some(w => w.code === 'PLAN_GATE_OPEN_MAJOR' && w.message.includes(M3)), JSON.stringify(g.warnings)); assert.ok(!g.warnings.some(w => w.code === 'PLAN_GATE_OPEN_BLOCKER'), JSON.stringify(g.warnings)); }); // ---- 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 ------------------------------------------------------------------------------ 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 = tempRepo('plan-gate-stop-'); try { 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 }); } });