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:
parent
7b46cdd788
commit
fb9a551ed8
3 changed files with 567 additions and 5 deletions
|
|
@ -20,6 +20,25 @@
|
|||
// 2 = could not run: bad stdin, a reviewer missing from the dedup input,
|
||||
// the plan changed since --hash (the verdict would bless unreviewed text),
|
||||
// an unreadable plan or a failed write. Nothing is written on 1 or 2.
|
||||
// From round 2 on it counts against the snapshot named by the previous
|
||||
// verdict's plan_sha256 (rule A, below); a missing, oversized, misnamed or
|
||||
// tampered snapshot means full counting, said in one `diff_note` line. On
|
||||
// 0 it keeps this round's operative text as a snapshot (tmp + rename)
|
||||
// before the plan is written. Output: one JSON line, which adds
|
||||
// `diff_base`, `diff_note`, `lowered` (a number) and `snapshot` ("kept: ..."
|
||||
// or "not kept: <reason>"; a failed snapshot write does not fail the round).
|
||||
//
|
||||
// node plan-gate.mjs --diff <plan.md>
|
||||
// Prints what the next critic round reviews: `diff-base: <sha256> (round N)`
|
||||
// and each hunk as `@@ lines A-B @@` in the plan's full-file line numbers,
|
||||
// `-` old lines and `+NNN ` new lines; or `diff-base: none — <reason>;
|
||||
// review the whole plan`. 0 = printed; 2 = unreadable plan, bad arguments.
|
||||
//
|
||||
// Snapshots: `<git dir>/voyage/plan-snapshots/<sha256>.md`, the operative text
|
||||
// the round reviewed, named by its own sha256. The git dir is found from the
|
||||
// plan file's own directory only (no cwd fallback); a plan in no repository
|
||||
// keeps none. git never tracks its own directory, so a snapshot is never
|
||||
// committed, whatever the repo's .gitignore or artifact_store says.
|
||||
//
|
||||
// node plan-gate.mjs --accept-open <plan.md>
|
||||
// After the operator's explicit yes to "build as is": records the open
|
||||
|
|
@ -71,12 +90,16 @@
|
|||
// rewrite the previous verdict's plan_sha256 and plant a matching base; rule A
|
||||
// raises what such a forgery buys, and an unchanged base lowers nothing.
|
||||
|
||||
import { readFileSync, existsSync, writeFileSync, renameSync, rmSync } from 'node:fs';
|
||||
import {
|
||||
readFileSync, existsSync, writeFileSync, renameSync, rmSync, statSync, mkdirSync, realpathSync,
|
||||
} from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { validatePlanContent } from './plan-validator.mjs';
|
||||
import { splitSections, fenceTracker, extractPlanVersion, versionAtLeast } from '../parsers/plan-schema.mjs';
|
||||
import { normalizeSeverity } from '../review/plan-review-dedup.mjs';
|
||||
import { countRound } from '../review/critic-diff.mjs';
|
||||
import { countRound, lineDiff } from '../review/critic-diff.mjs';
|
||||
import { resolveRepo } from '../verification/repo-tools.mjs';
|
||||
import { isMain } from '../util/is-main.mjs';
|
||||
|
||||
export const MAX_ROUNDS = 3;
|
||||
|
|
@ -417,10 +440,113 @@ function out(obj) {
|
|||
process.stdout.write(JSON.stringify(obj) + '\n');
|
||||
}
|
||||
|
||||
const SNAPSHOT_MAX_BYTES = 4 * 1024 * 1024;
|
||||
const oneLine = s => String(s).replace(/\s*[\r\n]+\s*/g, ' ').trim();
|
||||
|
||||
/** `<git dir>/voyage/plan-snapshots` of the repo holding the plan file, or null (no cwd fallback). */
|
||||
function snapshotDir(planPath) {
|
||||
const repo = resolveRepo(dirname(resolve(planPath)));
|
||||
return repo ? join(repo.gitDir, 'voyage', 'plan-snapshots') : null;
|
||||
}
|
||||
|
||||
/** The absolute forms of the plan path: as given, and its realpath. */
|
||||
function planPathsOf(planPath) {
|
||||
const paths = [resolve(planPath)];
|
||||
try {
|
||||
const real = realpathSync(planPath);
|
||||
if (!paths.includes(real)) paths.push(real);
|
||||
} catch { /* the given form only */ }
|
||||
return paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* The snapshot named `sha` in `dir`, or a one-line reason it cannot be used.
|
||||
* The name is checked before any path is built, so a `../` name is never read.
|
||||
* @returns {{base: {sha256: string, text: string}|null, reason: string|null}}
|
||||
*/
|
||||
function readBase(dir, sha) {
|
||||
if (typeof sha !== 'string' || !/^[0-9a-f]{64}$/.test(sha)) {
|
||||
return { base: null, reason: 'the previous verdict\'s plan_sha256 is not a sha256, so no snapshot is read' };
|
||||
}
|
||||
const short = sha.slice(0, 12);
|
||||
const file = join(dir, `${sha}.md`);
|
||||
let st;
|
||||
try { st = statSync(file); } catch (e) {
|
||||
return { base: null, reason: e.code === 'ENOENT' ? `no snapshot of ${short} in the git directory` : `the snapshot of ${short} is unreadable: ${oneLine(e.message)}` };
|
||||
}
|
||||
if (!st.isFile()) return { base: null, reason: `the snapshot of ${short} is not a file` };
|
||||
if (st.size > SNAPSHOT_MAX_BYTES) return { base: null, reason: `the snapshot of ${short} is over 4 MB` };
|
||||
let buf;
|
||||
try { buf = readFileSync(file); } catch (e) {
|
||||
return { base: null, reason: `the snapshot of ${short} is unreadable: ${oneLine(e.message)}` };
|
||||
}
|
||||
if (buf.length > SNAPSHOT_MAX_BYTES) return { base: null, reason: `the snapshot of ${short} is over 4 MB` };
|
||||
if (createHash('sha256').update(buf).digest('hex') !== sha) {
|
||||
return { base: null, reason: `the snapshot of ${short} does not hash to its name (tampered or damaged)` };
|
||||
}
|
||||
return { base: { sha256: sha, text: buf.toString('utf-8') }, reason: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* What the next round counts against: the snapshot of the text the current
|
||||
* verdict's round reviewed. verdict null = no round recorded yet.
|
||||
* @returns {{verdict: object|null, base: {sha256: string, text: string}|null, reason: string|null}}
|
||||
*/
|
||||
function loadBase(text, dir) {
|
||||
const ex = extractVerdict(text);
|
||||
if (!ex.verdict) {
|
||||
return { verdict: null, base: null, reason: ex.error && !ex.placeholder ? `the current verdict is unreadable: ${oneLine(ex.error)}` : 'no critic round has been recorded yet' };
|
||||
}
|
||||
if (dir === null) return { verdict: ex.verdict, base: null, reason: 'the plan is in no git repository, so no snapshot was kept' };
|
||||
const r = readBase(dir, ex.verdict.plan_sha256);
|
||||
return { verdict: ex.verdict, base: r.base, reason: r.reason };
|
||||
}
|
||||
|
||||
/** Keep this round's operative text as `<dir>/<sha>.md` (tmp + rename); returns the `snapshot` field. */
|
||||
function keepSnapshot(dir, text, sha) {
|
||||
if (dir === null) return 'not kept: no git repository';
|
||||
const file = join(dir, `${sha}.md`);
|
||||
const tmp = `${file}.${process.pid}.tmp`;
|
||||
try {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(tmp, operativeText(text));
|
||||
renameSync(tmp, file);
|
||||
return `kept: ${file}`;
|
||||
} catch (e) {
|
||||
try { rmSync(tmp, { force: true }); } catch { /* nothing to clean */ }
|
||||
return `not kept: ${oneLine(e.message)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** The `--diff` text: the base round and each hunk in the plan's full-file line numbers. */
|
||||
function diffReport(text, dir) {
|
||||
const none = why => `diff-base: none — ${why}; review the whole plan\n`;
|
||||
const lb = loadBase(text, dir);
|
||||
if (!lb.base) return none(lb.reason);
|
||||
const cur = operativeLines(text);
|
||||
if (lb.base.sha256 === planHash(text)) return none(`the plan has not changed since round ${lb.verdict.round}`);
|
||||
const oldL = lb.base.text.split('\n');
|
||||
const newL = cur.text.split('\n');
|
||||
const newCount = newL.length - 1; // the operative text ends with '\n'
|
||||
const d = lineDiff(lb.base.text, cur.text);
|
||||
const lines = [`diff-base: ${lb.base.sha256} (round ${lb.verdict.round})`];
|
||||
for (const h of d.hunks) {
|
||||
const changed = h.newEnd >= h.newStart;
|
||||
const lo = changed ? h.newStart : Math.max(1, h.newStart - 1);
|
||||
const hi = changed ? h.newEnd : Math.min(newCount, h.newStart);
|
||||
const mapped = [];
|
||||
for (let n = lo; n <= hi; n++) if (cur.opToFull[n] != null) mapped.push(cur.opToFull[n]);
|
||||
lines.push(mapped.length ? `@@ lines ${mapped[0]}-${mapped[mapped.length - 1]} @@` : '@@ lines (no file line) @@');
|
||||
for (let o = h.oldStart; o <= h.oldEnd; o++) lines.push(`-${oldL[o - 1]}`);
|
||||
for (let n = h.newStart; n <= h.newEnd; n++) lines.push(`+${cur.opToFull[n] ?? '?'} ${newL[n - 1]}`);
|
||||
}
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
if (isMain(import.meta.url)) {
|
||||
const args = process.argv.slice(2);
|
||||
const usage = 'Usage: plan-gate.mjs <plan.md> [--json] | --hash <plan.md> | --record <plan.md> --reviewed <sha256> [--extra-round] < dedup.json | --accept-open <plan.md>';
|
||||
const known = new Set(['--json', '--hash', '--record', '--reviewed', '--extra-round', '--accept-open']);
|
||||
const usage = 'Usage: plan-gate.mjs <plan.md> [--json] | --hash <plan.md> | --diff <plan.md> | --record <plan.md> --reviewed <sha256> [--extra-round] < dedup.json | --accept-open <plan.md>';
|
||||
const known = new Set(['--json', '--hash', '--diff', '--record', '--reviewed', '--extra-round', '--accept-open']);
|
||||
const rIdx = args.indexOf('--reviewed');
|
||||
const reviewed = rIdx >= 0 ? args[rIdx + 1] : undefined;
|
||||
const positional = args.filter((a, i) => !a.startsWith('--') && !(rIdx >= 0 && i === rIdx + 1));
|
||||
|
|
@ -443,6 +569,10 @@ if (isMain(import.meta.url)) {
|
|||
process.stdout.write(`${planHash(text)}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
if (args.includes('--diff')) {
|
||||
process.stdout.write(diffReport(text, snapshotDir(planPath)));
|
||||
process.exit(0);
|
||||
}
|
||||
if (args.includes('--accept-open')) {
|
||||
const r = acceptOpen(text);
|
||||
if (r.code === 0) {
|
||||
|
|
@ -465,8 +595,18 @@ if (isMain(import.meta.url)) {
|
|||
out({ code: 2, recorded: false, message: `stdin is not JSON: ${e.message}` });
|
||||
process.exit(2);
|
||||
}
|
||||
const r = recordVerdict(text, dedup, { extraRound: args.includes('--extra-round'), reviewed });
|
||||
const dir = snapshotDir(planPath);
|
||||
const opts = { extraRound: args.includes('--extra-round'), reviewed, planPaths: planPathsOf(planPath) };
|
||||
const lb = loadBase(text, dir);
|
||||
if (lb.verdict) {
|
||||
opts.base = lb.base;
|
||||
if (lb.reason) opts.baseNote = `${lb.reason}; full counting`;
|
||||
}
|
||||
const r = recordVerdict(text, dedup, opts);
|
||||
let snapshot = null;
|
||||
if (r.code === 0) {
|
||||
// The snapshot first, then the plan: a verdict never names a text that was not kept on purpose.
|
||||
snapshot = keepSnapshot(dir, text, r.verdict.plan_sha256);
|
||||
const tmp = `${planPath}.plan-gate.tmp`;
|
||||
try {
|
||||
writeFileSync(tmp, r.text);
|
||||
|
|
@ -481,6 +621,8 @@ if (isMain(import.meta.url)) {
|
|||
code: r.code, recorded: r.code === 0, message: r.message,
|
||||
round: r.verdict?.round ?? null, max_rounds: r.verdict?.max_rounds ?? null,
|
||||
verdict: r.verdict?.verdict ?? null, counts: r.verdict?.counts ?? null,
|
||||
diff_base: r.verdict?.diff_base ?? null, diff_note: r.verdict?.diff_note ?? null,
|
||||
lowered: r.code === 0 ? r.lowered : null, snapshot,
|
||||
});
|
||||
process.exit(r.code);
|
||||
}
|
||||
|
|
|
|||
148
tests/e2e-critic-diff-mode.sh
Normal file
148
tests/e2e-critic-diff-mode.sh
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
#!/usr/bin/env bash
|
||||
# tests/e2e-critic-diff-mode.sh
|
||||
# End to end for the critic loop's diff mode (decision 43): the plan gate CLI
|
||||
# run the way /trekplan Phase 9 runs it, in a temp git repo.
|
||||
# 1. git init a temp repo and build a 1.8 plan with
|
||||
# tests/helpers/plan-v18-builder.mjs.
|
||||
# 2. --hash, a round-1 reviewer payload (one major) through
|
||||
# lib/review/plan-review-dedup.mjs --stdin into a file, then
|
||||
# --record < file (exit 0, REVISE).
|
||||
# 3. Edit one step line with node -e.
|
||||
# 4. --diff names the edited line (its number found with grep -n).
|
||||
# 5. Save a copy of the plan.
|
||||
# 6. A: round 2 marks the round-1 major resolved and adds a new major on a
|
||||
# line outside the edit (exit 0, PASS); the bare gate exits 0.
|
||||
# 7. Restore the copy. B: the same, with the new major on the edited line
|
||||
# (exit 0, REVISE); the bare gate exits 1 with PLAN_GATE_OPEN_MAJOR.
|
||||
# 8. git status --porcelain --untracked-files=all lists no snapshot; the
|
||||
# round-1 snapshot file exists under .git/voyage/plan-snapshots/.
|
||||
# Prints "e2e: ok" and exits 0, or names the failed check and exits 1.
|
||||
# bash 3.2 compatible, ASCII only.
|
||||
|
||||
set -u
|
||||
|
||||
cd "$(dirname "$0")/.." || { echo "e2e: FAIL - cannot cd to the repo root" >&2; exit 1; }
|
||||
ROOT="$(pwd)"
|
||||
GATE="$ROOT/lib/validators/plan-gate.mjs"
|
||||
DEDUP="$ROOT/lib/review/plan-review-dedup.mjs"
|
||||
|
||||
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
|
||||
|
||||
TMP="$(mktemp -d "${TMPDIR:-/tmp}/e2e-critic-diff-mode.XXXXXX")" || { echo "e2e: FAIL - mktemp" >&2; exit 1; }
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
fail() {
|
||||
echo "e2e: FAIL - $1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
REPO="$TMP/repo"
|
||||
PLAN="$REPO/plan.md"
|
||||
|
||||
# gate <args...>: the plan gate run from inside the temp repo (findings cite plan.md).
|
||||
gate() {
|
||||
(cd "$REPO" && node "$GATE" "$@")
|
||||
}
|
||||
|
||||
# round <payload-file>: --hash, dedup --stdin, then --record; prints the record line.
|
||||
round() {
|
||||
h="$(gate --hash plan.md)" || fail "--hash exited non-zero"
|
||||
node "$DEDUP" --stdin < "$1" > "$TMP/dedup.json" || fail "plan-review-dedup --stdin on $1"
|
||||
gate --record plan.md --reviewed "$h" < "$TMP/dedup.json"
|
||||
}
|
||||
|
||||
# ---- 1. a temp git repo and a 1.8 plan ----
|
||||
|
||||
mkdir -p "$REPO" || fail "mkdir $REPO"
|
||||
git -C "$REPO" init -q || fail "git init $REPO"
|
||||
node --input-type=module -e '
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { buildPlan } from "./tests/helpers/plan-v18-builder.mjs";
|
||||
writeFileSync(process.argv[1], buildPlan());
|
||||
' "$PLAN" || fail "could not write the plan with tests/helpers/plan-v18-builder.mjs"
|
||||
|
||||
# ---- 2. round 1: one major ----
|
||||
|
||||
L1="$(grep -n 'Verifies: run1()' "$PLAN" | head -n 1 | cut -d: -f1)"
|
||||
[ -n "$L1" ] || fail "no Verifies line for step 1 in the plan"
|
||||
M1='Step 1 verification never states the expected return value'
|
||||
printf '{"plan_critic":{"findings":[{"file":"plan.md","line":%s,"rule_key":"step-quality","severity":"major","text":"%s"}]},"scope_guardian":{"findings":[]}}\n' \
|
||||
"$L1" "$M1" > "$TMP/r1.json"
|
||||
H1="$(gate --hash plan.md)" || fail "--hash before round 1"
|
||||
out="$(round "$TMP/r1.json")"
|
||||
rc=$?
|
||||
[ "$rc" -eq 0 ] || fail "round 1 --record: exit $rc, expected 0: $out"
|
||||
echo "$out" | grep -q '"verdict":"REVISE"' || fail "round 1: no REVISE in: $out"
|
||||
ID1="$(node -e '
|
||||
const t = require("node:fs").readFileSync(process.argv[1], "utf8");
|
||||
const v = JSON.parse(t.match(/```json\n([\s\S]*?)\n```/)[1]);
|
||||
process.stdout.write(v.findings[0].id);
|
||||
' "$PLAN")" || fail "could not read the round-1 finding id"
|
||||
echo "$ID1" | grep -Eq '^[0-9a-f]{40}$' || fail "round-1 finding id is not 40 hex: $ID1"
|
||||
|
||||
# ---- 3. edit one step line ----
|
||||
|
||||
node -e '
|
||||
const fs = require("node:fs");
|
||||
const p = process.argv[1];
|
||||
const t = fs.readFileSync(p, "utf8");
|
||||
const from = "Add `run2()` to `src/mod2.mjs`.";
|
||||
if (!t.includes(from)) process.exit(1);
|
||||
fs.writeFileSync(p, t.replace(from, "Add `run2()` and its error path to `src/mod2.mjs`."));
|
||||
' "$PLAN" || fail "could not edit the Step 2 Changes line"
|
||||
LE="$(grep -n 'its error path' "$PLAN" | head -n 1 | cut -d: -f1)"
|
||||
[ -n "$LE" ] || fail "the edited line is not in the plan"
|
||||
|
||||
# ---- 4. --diff names the edited line ----
|
||||
|
||||
out="$(gate --diff plan.md)"
|
||||
rc=$?
|
||||
[ "$rc" -eq 0 ] || fail "--diff: exit $rc, expected 0: $out"
|
||||
echo "$out" | head -n 1 | grep -q "^diff-base: $H1 (round 1)\$" || fail "--diff: first line is not the round-1 base: $out"
|
||||
echo "$out" | grep -q "^@@ lines $LE-$LE @@\$" || fail "--diff: no hunk header for line $LE in: $out"
|
||||
echo "$out" | grep -q "^+$LE .*its error path" || fail "--diff: line $LE is not named as a new line in: $out"
|
||||
|
||||
# ---- 5. save a copy ----
|
||||
|
||||
cp "$PLAN" "$TMP/plan-after-edit.md" || fail "could not copy the plan"
|
||||
|
||||
# ---- 6. A: the new major is outside the edit ----
|
||||
|
||||
LO="$(grep -n 'checkout -- src/mod1.mjs' "$PLAN" | head -n 1 | cut -d: -f1)"
|
||||
[ -n "$LO" ] || fail "no On failure line for step 1 in the plan"
|
||||
MA='The rollback named for this step gives no check that the revert actually worked'
|
||||
printf '{"plan_critic":{"previous":[{"id":"%s","resolved":true}],"findings":[{"file":"plan.md","line":%s,"rule_key":"step-quality","severity":"major","text":"%s"}]},"scope_guardian":{"previous":[{"id":"%s","resolved":true}],"findings":[]}}\n' \
|
||||
"$ID1" "$LO" "$MA" "$ID1" > "$TMP/r2a.json"
|
||||
out="$(round "$TMP/r2a.json")"
|
||||
rc=$?
|
||||
[ "$rc" -eq 0 ] || fail "A: round 2 --record: exit $rc, expected 0: $out"
|
||||
echo "$out" | grep -q '"verdict":"PASS"' || fail "A: round 2 outside the diff is not PASS: $out"
|
||||
echo "$out" | grep -q "\"diff_base\":\"$H1\"" || fail "A: round 2 did not diff against round 1: $out"
|
||||
out="$(gate plan.md)"
|
||||
rc=$?
|
||||
[ "$rc" -eq 0 ] || fail "A: the bare gate exit $rc, expected 0: $out"
|
||||
|
||||
# ---- 7. B: the new major is on the edited line ----
|
||||
|
||||
cp "$TMP/plan-after-edit.md" "$PLAN" || fail "could not restore the plan"
|
||||
MB='The error path named here gives callers no thrown type to match on'
|
||||
printf '{"plan_critic":{"previous":[{"id":"%s","resolved":true}],"findings":[{"file":"plan.md","line":%s,"rule_key":"step-quality","severity":"major","text":"%s"}]},"scope_guardian":{"previous":[{"id":"%s","resolved":true}],"findings":[]}}\n' \
|
||||
"$ID1" "$LE" "$MB" "$ID1" > "$TMP/r2b.json"
|
||||
out="$(round "$TMP/r2b.json")"
|
||||
rc=$?
|
||||
[ "$rc" -eq 0 ] || fail "B: round 2 --record: exit $rc, expected 0: $out"
|
||||
echo "$out" | grep -q '"verdict":"REVISE"' || fail "B: round 2 inside the diff is not REVISE: $out"
|
||||
out="$(gate plan.md)"
|
||||
rc=$?
|
||||
[ "$rc" -eq 1 ] || fail "B: the bare gate exit $rc, expected 1: $out"
|
||||
echo "$out" | grep -q 'PLAN_GATE_OPEN_MAJOR' || fail "B: no PLAN_GATE_OPEN_MAJOR in: $out"
|
||||
|
||||
# ---- 8. snapshots stay in the git directory ----
|
||||
|
||||
st="$(git -C "$REPO" status --porcelain --untracked-files=all)" || fail "git status in the temp repo"
|
||||
echo "$st" | grep -q 'plan-snapshots' && fail "git status lists a snapshot: $st"
|
||||
[ "$st" = '?? plan.md' ] || fail "git status lists more than the plan: $st"
|
||||
[ -f "$REPO/.git/voyage/plan-snapshots/$H1.md" ] || fail "no round-1 snapshot under .git/voyage/plan-snapshots/"
|
||||
|
||||
echo "e2e: ok"
|
||||
exit 0
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue