// tests/validators/plan-gate.test.mjs // K3 — the plan gate. `plan-gate.mjs `: 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); }); }); test('K3: a valid plan whose latest verdict has no blocker exits 0 (majors are warnings)', () => { 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, 0, r.out + r.err); const j = JSON.parse(r.out); assert.equal(j.verdict.verdict, 'REVISE'); assert.ok(j.warnings.some(w => w.code === 'PLAN_GATE_OPEN_MAJOR')); }); }); 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'); for (let i = 1; i <= 3; i++) { const r = rec(text, dedupOut(finding('blocker', `still broken ${i}`))); 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, 0); 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')); });