feat(plan): the plan gate keeps each reviewed text in the git directory and prints the diff for round 2+ [skip-docs]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-16 00:57:03 +02:00
commit fb9a551ed8
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
3 changed files with 567 additions and 5 deletions

View file

@ -18,6 +18,15 @@ import { gatePlan, recordVerdict, planHash, operativeText, acceptOpen } from '..
import * as diffMod from '../../lib/review/critic-diff.mjs';
import { dedupFindings, contentOverlap } from '../../lib/review/plan-review-dedup.mjs';
import { buildPlan } from '../helpers/plan-v18-builder.mjs';
import { spawnSync } from 'node:child_process';
import {
mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync, readdirSync, appendFileSync, realpathSync,
} from 'node:fs';
import { join, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { createHash } from 'node:crypto';
import { childEnv } from '../helpers/child-env.mjs';
const NOW = '2026-10-16T00:00:00.000Z';
const PLAN = resolve('plan.md');
@ -652,3 +661,266 @@ test('acceptOpen after round 3 lists only the real majors, never a lowered one',
assert.deepEqual(v.accepted_open.findings.map(x => x.text), [REAL]);
assert.equal(gatePlan(a.text).code, 0);
});
// ---- Step 5: the CLI keeps snapshots in the git directory and prints the diff ----
//
// Every case runs plan-gate.mjs as a child (spawnSync, childEnv) on a plan in
// its own mkdtemp `git init` repo, so a snapshot can only land in that repo's
// .git. Expected hashes come from the CLI's own --hash (taken before the
// round), expected line numbers from findIndex over the plan text.
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const GATE = join(ROOT, 'lib', 'validators', 'plan-gate.mjs');
const E2E = join(ROOT, 'tests', 'e2e-critic-diff-mode.sh');
const GIT_CLEAN = { GIT_DIR: undefined, GIT_WORK_TREE: undefined, GIT_INDEX_FILE: undefined };
function gate(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 git = (cwd, args) => spawnSync('git', args, { cwd, encoding: 'utf-8', env: childEnv(GIT_CLEAN) });
/** A fresh temp dir (real path), `git init`-ed when `repo` is true. */
function tempDir(prefix, repo) {
const dir = realpathSync(mkdtempSync(join(tmpdir(), prefix)));
if (repo) {
const g = git(dir, ['init', '-q']);
assert.equal(g.status, 0, g.stderr);
}
return dir;
}
/** The plan in its own temp git repo; the gate runs with that repo as cwd. */
function withRepo(text, fn) {
const dir = tempDir('plan-gate-diff-', true);
const p = join(dir, 'plan.md');
writeFileSync(p, text);
try { return fn(p, dir); } finally { rmSync(dir, { recursive: true, force: true }); }
}
const snapDir = dir => join(dir, '.git', 'voyage', 'plan-snapshots');
const snaps = dir => (existsSync(snapDir(dir)) ? readdirSync(snapDir(dir)).sort() : []);
const sha256 = s => createHash('sha256').update(s).digest('hex');
function hashOf(dir, p) {
const r = gate(dir, ['--hash', p]);
assert.equal(r.code, 0, r.out + r.err);
assert.match(r.out.trim(), /^[0-9a-f]{64}$/);
return r.out.trim();
}
/** One round as Phase 9 runs it: --hash, then --record with the payloads through the real dedup. */
function cliRound(dir, p, payload = {}, extra = []) {
const reviewed = hashOf(dir, p);
const r = gate(dir, ['--record', p, '--reviewed', reviewed, ...extra], JSON.stringify(dd(payload)));
const lines = r.out.split('\n').filter(l => l !== '');
return { ...r, reviewed, lines, j: lines.length ? JSON.parse(lines[lines.length - 1]) : null };
}
const readPlan = p => readFileSync(p, 'utf-8');
const verdictIn = p => JSON.parse(readPlan(p).match(/```json\n([\s\S]*?)\n```/)[1]);
/** Rewrite the previous verdict's plan_sha256 (outside the operative text, so --hash is unchanged). */
function setPrevSha(p, value) {
const t = readPlan(p);
const m = t.match(/"plan_sha256": "([0-9a-f]{64})"/);
assert.ok(m, 'the plan holds a recorded plan_sha256');
writeFileSync(p, t.replace(m[0], `"plan_sha256": ${JSON.stringify(value)}`));
}
test('19 (CLI): a missing, tampered, mismatched or oversized base snapshot, or a ../ name, counts fully with exactly one fallback line', () => {
const OTHER = operativeText(editB(buildPlan()));
const big = 'x'.repeat(4 * 1024 * 1024 + 1);
const cases = [
{ name: 'missing', note: /no snapshot/, mutate: (dir, sha) => rmSync(join(snapDir(dir), `${sha}.md`)) },
{ name: 'tampered', note: /does not hash to its name/, mutate: (dir, sha) => appendFileSync(join(snapDir(dir), `${sha}.md`), 'tampered\n') },
{ name: 'mismatched', note: /does not hash to its name/, mutate: (dir, sha) => writeFileSync(join(snapDir(dir), `${sha}.md`), OTHER) },
{
name: 'over 4 MB', note: /over 4 MB/,
mutate: (dir, sha, p) => {
const s = sha256(big);
writeFileSync(join(snapDir(dir), `${s}.md`), big);
setPrevSha(p, s);
},
},
{
name: '../ name', note: /not a sha256/, never: /does not hash/,
mutate: (dir, sha, p) => {
// A real snapshot text planted where the ../ name would lead.
writeFileSync(join(snapDir(dir), '..', '..', '..', 'planted.md'), readFileSync(join(snapDir(dir), `${sha}.md`)));
assert.ok(existsSync(join(dir, 'planted.md')));
setPrevSha(p, '../../../planted');
},
},
];
for (const c of cases) {
withRepo(buildPlan(), (p, dir) => {
const r1 = cliRound(dir, p);
assert.equal(r1.code, 0, `${c.name}: ${r1.out}${r1.err}`);
assert.deepEqual(snaps(dir), [`${r1.reviewed}.md`], `${c.name}: round 1 keeps its snapshot`);
const t2 = editA(readPlan(p));
writeFileSync(p, t2);
c.mutate(dir, r1.reviewed, p);
const r2 = cliRound(dir, p, { pc: [guard(t2)] });
assert.equal(r2.code, 0, `${c.name}: ${r2.out}${r2.err}`);
assert.equal(r2.lines.length, 1, `${c.name}: one output line`);
assert.equal(r2.j.verdict, 'REVISE', `${c.name}: the outside major counts fully`);
assert.equal(r2.j.diff_base, null, `${c.name}: no diff base`);
assert.equal(typeof r2.j.diff_note, 'string', `${c.name}: diff_note in the output: ${r2.out}`);
assert.ok(r2.j.diff_note.length > 0 && !r2.j.diff_note.includes('\n'), `${c.name}: one line`);
assert.match(r2.j.diff_note, c.note, c.name);
if (c.never) assert.doesNotMatch(r2.j.diff_note, c.never, c.name);
assert.equal(r2.j.lowered, 0, c.name);
const v = verdictIn(p);
assert.equal(v.diff_note, r2.j.diff_note, `${c.name}: the same line in the verdict`);
assert.ok(!('diff_base' in v), c.name);
assert.equal(byText(v, GUARD).severity, 'major', c.name);
});
}
});
test('20 (CLI): a refused round writes nothing: exit 2 on a stale --reviewed, exit 1 at the cap', () => {
withRepo(buildPlan(), (p, dir) => {
const r1 = cliRound(dir, p);
assert.equal(r1.code, 0, r1.out + r1.err);
assert.match(r1.j.snapshot, /^kept/);
assert.deepEqual(snaps(dir), [`${r1.reviewed}.md`]);
// exit 2: the plan changed after --hash
const stale = hashOf(dir, p);
const t2 = editA(readPlan(p));
writeFileSync(p, t2);
const s = gate(dir, ['--record', p, '--reviewed', stale], JSON.stringify(dd()));
assert.equal(s.code, 2, s.out + s.err);
assert.equal(readPlan(p), t2, 'the plan is not written on exit 2');
assert.deepEqual(snaps(dir), [`${r1.reviewed}.md`], 'no snapshot on exit 2');
// rounds 2 and 3, then round 4 on an edited plan is refused at the cap
const r2 = cliRound(dir, p);
assert.equal(r2.code, 0, r2.out + r2.err);
writeFileSync(p, editB(readPlan(p)));
const r3 = cliRound(dir, p);
assert.equal(r3.code, 0, r3.out + r3.err);
assert.equal(r3.j.round, 3);
const kept = snaps(dir);
assert.equal(kept.length, 3, JSON.stringify(kept));
const t4 = readPlan(p).replace('with clear errors.', 'with clear errors and exit codes.');
assert.notEqual(t4, readPlan(p));
writeFileSync(p, t4);
const r4 = cliRound(dir, p);
assert.equal(r4.code, 1, r4.out + r4.err);
assert.equal(readPlan(p), t4, 'the plan is not written on exit 1');
assert.deepEqual(snaps(dir), kept, 'no snapshot on exit 1');
assert.ok(!kept.includes(`${r4.reviewed}.md`));
});
});
test('21 (CLI): round 2 diffs against the kept snapshot; no snapshot shows in git status; the bare gate exits the same with the snapshot dir removed', () => {
withRepo(buildPlan(), (p, dir) => {
const r1 = cliRound(dir, p, { pc: [f('major', at(buildPlan(), S1_VERIFIES), 'Step 1 verification never states the expected return value')] });
assert.equal(r1.code, 0, r1.out + r1.err);
assert.equal(r1.j.verdict, 'REVISE');
assert.equal(r1.j.diff_note, null, 'round 1 has no diff_note');
const id = verdictIn(p).findings[0].id;
const t2 = editA(readPlan(p));
writeFileSync(p, t2);
const r2 = cliRound(dir, p, { pc: [guard(t2)], pcPrev: [{ id, resolved: true }], sgPrev: [{ id, resolved: true }] });
assert.equal(r2.code, 0, r2.out + r2.err);
assert.equal(r2.j.verdict, 'PASS', r2.out);
assert.equal(r2.j.diff_base, r1.reviewed);
assert.equal(r2.j.diff_note, null);
assert.equal(r2.j.lowered, 1);
assert.match(r2.j.snapshot, /^kept/);
assert.deepEqual(snaps(dir), [`${r1.reviewed}.md`, `${r2.reviewed}.md`].sort());
assert.equal(readFileSync(join(snapDir(dir), `${r2.reviewed}.md`), 'utf-8'), operativeText(t2));
const st = git(dir, ['status', '--porcelain', '--untracked-files=all']);
assert.equal(st.status, 0, st.stderr);
assert.deepEqual(st.stdout.split('\n').filter(l => l !== ''), ['?? plan.md'], 'only the plan is untracked');
const g1 = gate(dir, [p]);
assert.equal(g1.code, 0, g1.out);
rmSync(snapDir(dir), { recursive: true, force: true });
const g2 = gate(dir, [p]);
assert.equal(g2.code, g1.code);
assert.equal(g2.out, g1.out);
});
});
test('22 (CLI): --diff prints diff-base none with a reason, or the base and each hunk in full-file lines', () => {
withRepo(buildPlan(), (p, dir) => {
let d = gate(dir, ['--diff', p]);
assert.equal(d.code, 0, d.out + d.err);
assert.match(d.out.split('\n')[0], /^diff-base: none — .+; review the whole plan$/, 'before round 1');
const r1 = cliRound(dir, p);
assert.equal(r1.code, 0, r1.out + r1.err);
d = gate(dir, ['--diff', p]);
assert.equal(d.code, 0, d.out + d.err);
assert.match(d.out.split('\n')[0], /^diff-base: none — .*has not changed since round 1.*; review the whole plan$/);
const t2 = editA(readPlan(p));
writeFileSync(p, t2);
d = gate(dir, ['--diff', p]);
assert.equal(d.code, 0, d.out + d.err);
const lines = d.out.split('\n');
assert.equal(lines[0], `diff-base: ${r1.reviewed} (round 1)`);
const first = at(t2, 'its error path');
const last = at(t2, 'throws on empty input');
const [e1, e2] = R2_EDITED.split('\n');
assert.deepEqual(lines.filter(l => l.startsWith('@@')), [`@@ lines ${first}-${last} @@`]);
assert.ok(lines.includes(`-${R2_CHANGES}`), d.out);
assert.ok(lines.includes(`+${first} ${e1}`), d.out);
assert.ok(lines.includes(`+${last} ${e2}`), d.out);
rmSync(snapDir(dir), { recursive: true, force: true });
d = gate(dir, ['--diff', p]);
assert.equal(d.code, 0, d.out + d.err);
assert.match(d.out.split('\n')[0], /^diff-base: none — .*no snapshot.*; review the whole plan$/);
d = gate(dir, ['--diff', join(dir, 'no-such-plan.md')]);
assert.equal(d.code, 2, d.out + d.err);
});
});
test('23 (CLI): --record on a plan outside any git repo keeps no snapshot anywhere and says so', () => {
const dir = tempDir('plan-gate-norepo-', false);
// The gate's cwd is a decoy repo: a cwd fallback would write its snapshot there.
const decoy = tempDir('plan-gate-decoy-', true);
try {
assert.notEqual(git(dir, ['rev-parse', '--git-dir']).status, 0, 'the temp dir is in no git repo');
const p = join(dir, 'plan.md');
writeFileSync(p, buildPlan());
const run = () => {
const reviewed = gate(decoy, ['--hash', p]).out.trim();
const r = gate(decoy, ['--record', p, '--reviewed', reviewed], JSON.stringify(dd()));
return { ...r, j: JSON.parse(r.out.trim().split('\n').pop()) };
};
const r1 = run();
assert.equal(r1.code, 0, r1.out + r1.err);
assert.equal(r1.j.snapshot, 'not kept: no git repository');
writeFileSync(p, editA(readPlan(p)));
const r2 = run();
assert.equal(r2.code, 0, r2.out + r2.err);
assert.equal(r2.j.snapshot, 'not kept: no git repository');
assert.match(r2.j.diff_note, /no git repository/);
assert.deepEqual(readdirSync(dir), ['plan.md'], 'nothing beside the plan');
assert.deepEqual(snaps(decoy), [], 'nothing in the cwd repo');
assert.ok(!existsSync(join(decoy, '.git', 'voyage')));
const d = gate(decoy, ['--diff', p]);
assert.equal(d.code, 0, d.out + d.err);
assert.match(d.out.split('\n')[0], /^diff-base: none — .*no git repository.*; review the whole plan$/);
} finally {
rmSync(dir, { recursive: true, force: true });
rmSync(decoy, { recursive: true, force: true });
}
});
test('24: the e2e script is ASCII only and bash -n clean', () => {
assert.ok(existsSync(E2E), 'tests/e2e-critic-diff-mode.sh exists');
const buf = readFileSync(E2E);
assert.equal(buf.findIndex(b => b > 127), -1, 'ASCII only');
assert.match(buf.toString('utf-8'), /e2e: ok/);
const r = spawnSync('/bin/bash', ['-n', E2E], { encoding: 'utf-8', env: childEnv() });
assert.equal(r.status, 0, r.stderr);
});