voyage/tests/validators/plan-gate.test.mjs
Kjell Tore Guttormsen a576391e28
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>
2026-11-08 07:04:48 +01:00

248 lines
12 KiB
JavaScript

// tests/validators/plan-gate.test.mjs
// K3 — the plan gate. `plan-gate.mjs <plan.md>`: 0 = the plan may go on,
// 1 = stop (invalid plan, plan_version < 1.8, no critic verdict, a verdict the
// plan has moved away from, or an open BLOCKER), 2 = could not run. The
// critic's verdict lives machine-readably INSIDE the plan (`## Critic Verdict`),
// because Claude Code's plan approval hands a hook the plan file's text and
// nothing next to it. `--record` writes the verdict from plan-review-dedup
// output, bound to the hash taken (`--hash`) before the reviewers read the
// plan; at most 3 rounds, then stop and ask — never silently on.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import {
gatePlan, recordVerdict, planHash, operativeText, extractVerdict, MAX_ROUNDS,
} from '../../lib/validators/plan-gate.mjs';
import { buildPlan, step } from '../helpers/plan-v18-builder.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const GATE = join(HERE, '..', '..', 'lib', 'validators', 'plan-gate.mjs');
const BOTH = [{ agent: 'plan-critic', present: true }, { agent: 'scope-guardian', present: true }];
const finding = (severity, text, rule_key = 'step-quality') =>
({ id: `${severity}-${rule_key}`, file: 'plan.md', line: 10, rule_key, severity, text, raised_by: ['plan-critic'] });
const dedupOut = (...findings) => ({ findings, sources: BOTH, dedup_stats: { total_in: findings.length, total_out: findings.length } });
// One round as Phase 9 runs it: hash before the review, record after it.
const rec = (text, dedup, opts = {}) => recordVerdict(text, dedup, { reviewed: planHash(text), ...opts });
function run(args, input) {
const r = spawnSync('node', [GATE, ...args], { input, encoding: 'utf-8' });
return { code: r.status, out: r.stdout, err: r.stderr };
}
function withPlan(text, fn) {
const dir = mkdtempSync(join(tmpdir(), 'plan-gate-'));
const p = join(dir, 'plan.md');
writeFileSync(p, text);
try { return fn(p, dir); } finally { rmSync(dir, { recursive: true, force: true }); }
}
// ---- the test that failed first ----
test('K3: a plan with an open critic BLOCKER exits 1', () => {
const r0 = rec(buildPlan(), dedupOut(finding('blocker', 'Step 2 reads a value no step writes')));
assert.equal(r0.code, 0, r0.message);
withPlan(r0.text, (p) => {
const r = run([p, '--json']);
assert.equal(r.code, 1, r.out + r.err);
const j = JSON.parse(r.out);
assert.equal(j.status, 'stop');
assert.ok(j.reasons.some(x => x.code === 'PLAN_GATE_OPEN_BLOCKER'), r.out);
});
});
// Operator decision 2026-10-08 (voyage-lekerepo trial point 6): an open MAJOR
// no longer passes as a warning. It stops until it is fixed or the operator
// accepts it (tests/validators/plan-gate-open-major.test.mjs).
test('K3: a valid plan whose latest verdict has an open MAJOR stops (fix it, or the operator accepts it)', () => {
const r0 = rec(buildPlan(), dedupOut(finding('major', 'Step 1 lacks an error path'), finding('minor', 'typo')));
withPlan(r0.text, (p) => {
const r = run([p, '--json']);
assert.equal(r.code, 1, r.out + r.err);
const j = JSON.parse(r.out);
assert.equal(j.verdict.verdict, 'REVISE');
assert.ok(j.reasons.some(w => w.code === 'PLAN_GATE_OPEN_MAJOR'));
});
});
test('K3: a valid plan whose latest verdict has only minors exits 0', () => {
const r0 = rec(buildPlan(), dedupOut(finding('minor', 'typo')));
withPlan(r0.text, (p) => {
assert.equal(run([p, '--json']).code, 0);
});
});
test('K3: no critic verdict → 1 (the plan has not been through the critic)', () => {
const g = gatePlan(buildPlan());
assert.equal(g.code, 1);
assert.ok(g.reasons.some(x => x.code === 'PLAN_GATE_NO_VERDICT'));
});
test('K3: an invalid plan → 1 even with a clean verdict', () => {
const bad = buildPlan({ steps: [{ testFirst: false }, { endToEnd: 'bash tests/e2e.sh' }] });
const g = gatePlan(rec(bad, dedupOut()).text);
assert.equal(g.code, 1);
assert.ok(g.reasons.some(x => x.code === 'PLAN_GATE_INVALID_PLAN'));
});
test('K3: a plan below plan_version 1.8 → 1, even with a clean verdict', () => {
const old = buildPlan({ version: '1.7', ceremony: null, design: false });
const g = gatePlan(rec(old, dedupOut()).text);
assert.equal(g.code, 1);
assert.ok(g.reasons.some(x => x.code === 'PLAN_GATE_VERSION'), JSON.stringify(g.reasons));
});
test('K3: editing a step after the verdict makes it stale → 1', () => {
const r0 = rec(buildPlan(), dedupOut());
assert.equal(gatePlan(r0.text).code, 0);
const edited = r0.text.replace('Add `run1()`', 'Add `run1()` and `cleanup1()`');
const g = gatePlan(edited);
assert.equal(g.code, 1);
assert.ok(g.reasons.some(x => x.code === 'PLAN_GATE_VERDICT_STALE'));
});
test('K3: a step appended after the verdict (inside a review-log section) stales it', () => {
const r0 = rec(buildPlan(), dedupOut());
const appended = `${r0.text}\n${step(3, { title: 'Sneaked in' })}\n`;
assert.notEqual(planHash(appended), planHash(r0.text));
const g = gatePlan(appended);
assert.equal(g.code, 1);
assert.ok(g.reasons.some(x => x.code === 'PLAN_GATE_VERDICT_STALE'), JSON.stringify(g.reasons));
});
test('K3: review-log sections (quality header, score, revisions, verdict) do not stale the verdict', () => {
const r0 = rec(buildPlan(), dedupOut(finding('minor', 'nit')));
const logged = r0.text
.replace('> **Plan quality: A** (92/100) — APPROVE', '> **Plan quality: B** (80/100) — APPROVE_WITH_NOTES')
.replace('## Verification', '## Revisions\n\n| # | Finding | Severity | Resolution |\n|---|---|---|---|\n| 1 | nit | minor | left |\n\n## Verification');
assert.equal(planHash(logged), planHash(r0.text));
assert.equal(gatePlan(logged).code, 0);
});
test('K3: only exact review-log titles are excluded ("Revisions to the API" is plan)', () => {
const plan = buildPlan().replace('## Verification', '## Revisions to the API\n\nThe endpoint moves.\n\n## Verification');
const changed = plan.replace('The endpoint moves.', 'The endpoint is deleted.');
assert.notEqual(planHash(changed), planHash(plan));
});
test('K3: whitespace at line ends and CRLF do not stale the verdict', () => {
const r0 = rec(buildPlan(), dedupOut());
assert.equal(gatePlan(r0.text.replace(/\n/g, ' \r\n')).code, 0);
});
test('K3: a malformed verdict block → 1 (bad JSON, a null finding, max_rounds raised); a missing file → 2', () => {
const r0 = rec(buildPlan(), dedupOut(finding('minor', 'n')));
for (const broken of [
r0.text.replace('"schema"', '"schema" oops'),
r0.text.replace(/"findings": \[\n\s*\{/, '"findings": [\n null,\n {'),
r0.text.replace('"max_rounds": 3', '"max_rounds": 99'),
]) {
const g = gatePlan(broken);
assert.equal(g.code, 1);
assert.ok(g.reasons.some(x => x.code === 'PLAN_GATE_VERDICT_MALFORMED'), JSON.stringify(g.reasons));
}
assert.equal(run(['/nonexistent/plan.md', '--json']).code, 2);
});
// ---- what --record refuses ----
test('--record refuses a dedup input where a reviewer is absent (an absent review is not a clean one)', () => {
const plan = buildPlan();
const oneMissing = { findings: [], sources: [{ agent: 'plan-critic', present: true }, { agent: 'scope-guardian', present: false }] };
const r1 = rec(plan, oneMissing);
assert.equal(r1.code, 2);
assert.match(r1.message, /scope-guardian/);
assert.equal(rec(plan, { findings: [] }).code, 2, 'no sources at all');
});
test('--record binds the verdict to the text the reviewers read: no hash or a changed plan → 2', () => {
const plan = buildPlan();
assert.equal(recordVerdict(plan, dedupOut()).code, 2, '--reviewed is required');
const before = planHash(plan);
const revised = plan.replace('Add `run1()`', 'Add `run1()` after review');
const r = recordVerdict(revised, dedupOut(), { reviewed: before });
assert.equal(r.code, 2);
assert.match(r.message, /changed after the reviewers read it/);
});
test(`--record counts rounds; round ${MAX_ROUNDS + 1} needs the operator's extra round, which is only for then`, () => {
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 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;
}
const fourth = rec(text, dedupOut());
assert.equal(fourth.code, 1);
assert.match(fourth.message, /ask the operator/i);
const g = gatePlan(text);
assert.equal(g.code, 1);
assert.ok(g.reasons.some(x => x.code === 'PLAN_GATE_ROUNDS_EXHAUSTED'), JSON.stringify(g.reasons));
const extra = rec(text, dedupOut(), { extraRound: true });
assert.equal(extra.code, 0);
assert.equal(extra.verdict.round, 4);
assert.equal(extra.verdict.extra_rounds, 1);
assert.equal(gatePlan(extra.text).code, 0);
assert.equal(rec(extra.text, dedupOut()).code, 1, 'one yes buys one round');
});
test('--record clamps a raised round cap to MAX_ROUNDS', () => {
const r = rec(buildPlan(), dedupOut(), { maxRounds: 99 });
assert.equal(r.verdict.max_rounds, MAX_ROUNDS);
});
test('--record replaces the verdict section instead of stacking a second one, and replaces a copied placeholder', () => {
const once = rec(buildPlan(), dedupOut()).text;
const twice = rec(once, dedupOut()).text;
assert.equal((twice.match(/^## Critic Verdict$/gm) || []).length, 1);
assert.equal(extractVerdict(twice).verdict.round, 2);
const withPlaceholder = `${buildPlan()}\n## Critic Verdict\n\n*Written by plan-gate --record.*\n`;
const r = rec(withPlaceholder, dedupOut());
assert.equal(r.code, 0, r.message);
assert.equal(r.verdict.round, 1);
assert.doesNotMatch(r.text, /\*Written by plan-gate --record\.\*/);
});
test('--record: a finding without a valid severity counts as a blocker', () => {
const r = rec(buildPlan(), dedupOut({ file: 'plan.md', line: 1, rule_key: 'x', text: 'unrated' }));
assert.equal(r.verdict.verdict, 'BLOCK');
assert.equal(r.verdict.findings[0].severity, 'blocker');
assert.equal(r.verdict.findings[0].severity_defaulted, true);
});
// ---- the CLI as Phase 9 calls it ----
test('CLI: --hash, then --record --reviewed writes in place; malformed stdin or unknown flags → 2, nothing written', () => {
withPlan(buildPlan(), (p, dir) => {
const before = readFileSync(p, 'utf-8');
const hash = run(['--hash', p]).out.trim();
assert.match(hash, /^[0-9a-f]{64}$/);
assert.equal(run(['--record', p, '--reviewed', hash], 'not json').code, 2);
assert.equal(run(['--record', p, '--reviewed', hash, '--max-rounds', '9'], JSON.stringify(dedupOut())).code, 2);
assert.equal(run(['--record', p], JSON.stringify(dedupOut())).code, 2, '--reviewed is required');
assert.equal(readFileSync(p, 'utf-8'), before);
const ok = run(['--record', p, '--reviewed', hash], JSON.stringify(dedupOut(finding('major', 'm'))));
assert.equal(ok.code, 0, ok.out + ok.err);
assert.equal(JSON.parse(ok.out).verdict, 'REVISE');
assert.equal(run([p]).code, 1, 'the recorded open MAJOR stops the gate (operator decision 2026-10-08)');
assert.ok(!existsSync(join(dir, 'plan.md.plan-gate.tmp')));
});
});
test('operativeText drops only the review-log sections', () => {
const r0 = rec(buildPlan(), dedupOut());
const op = operativeText(r0.text);
assert.ok(!op.includes('## Critic Verdict'));
assert.ok(!op.includes('Plan quality:'));
assert.ok(op.includes('### Step 2:'));
assert.ok(op.includes('## Design'));
});