voyage/tests/validators/plan-gate-stop-rule.test.mjs
Kjell Tore Guttormsen 07a3c06c57
fix(gate): part 1 verifier findings — stop-rule wording, guard tests, unreadable round
- plan-gate: the bare gate's stale-verdict reason no longer says "run the
  next round" once the stop rule fired; it says there is no next round, as
  acceptOpen already does.
- trekplan Phase 9: "REVISE after the last round" (Fix now with
  --extra-round) is qualified "and the stop rule did not fire", pinned in
  review-loop-docs.test.mjs next to the [STP] pins.
- plan-gate-stop-rule tests: a planted stop-rule acceptance claiming an open
  security blocker stays STOP; a planted acceptance without under:
  "stop-rule" stays STOP; the "a blocker -> refused" test now asserts the
  blocker refusal; --accept-open under the stop rule covers an open MAJOR
  and the gate passes with a WARN.
- review-rounds prepare: when the stop rule is judged (P >= 2) and either
  round's JSON block cannot be read, exit 2 (not measured) with "stop rule:
  a round has no readable JSON block" instead of skipping the rule. The
  [RND] differing-archive and [CAP] fixtures now use readable archives; one
  new [STP] test covers an unreadable archive and an unreadable review.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:39:00 +01:00

379 lines
19 KiB
JavaScript

// 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);
});
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 ------------------------------------------------------------------------------
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 });
}
});