fix(review): R6 checks every parent of a commit after the review

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-11-03 11:07:08 +01:00
commit 57e7eba0a0
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
2 changed files with 278 additions and 41 deletions

View file

@ -0,0 +1,229 @@
// tests/lib/reviewed-commit-parents.test.mjs
// [RMP] R6 (lib/review/reviewed-commit.mjs) checks every parent of each commit
// after the review, not only its first parent (fix round 4.5b2 part B, Step 13).
// A merge whose first parent comes from before the review and whose tree is
// the pre-review tree changes nothing against that parent, but against the
// reviewed side it takes the reviewed change back out: HEAD no longer holds
// what was reviewed, so the gate must say review-stale.
// Every case runs the CLI `lib/review/verdict-gate.mjs --json <review.md>
// --repo <repo>` on an ALLOW review of the reviewed commit R, in throwaway git
// repos with no hooks. R sits on a pre-review commit P (`R^` is P).
// The `keep:` cases hold before this step too and must stay so.
import { after, test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, appendFileSync, cpSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { MARK_START, MARK_END, POINTER_START, POINTER_LINE, POINTER_END } from '../../lib/review/recurrence.mjs';
import { childEnv } from '../helpers/child-env.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const GATE = join(ROOT, 'lib', 'review', 'verdict-gate.mjs');
// Copied from tests/lib/verdict-gate.test.mjs:30-72 (review), never imported.
function review({ verdict, jsonVerdict, severities = [], allowBlockedBy = [], scopeShaEnd = 'abc' }) {
const ids = severities.map((_, i) => String(i + 1).padStart(40, 'a'));
const fmVerdict = verdict === undefined ? '' : `verdict: ${verdict}\n`;
const findings = severities.map((s, i) => ({
id: ids[i], severity: s, rule_key: 'MISSING_TEST', file: 'a.mjs', line: i + 1, title: 't', detail: 'd', recommended_action: 'Add a test.',
}));
const counts = { BLOCKER: 0, MAJOR: 0, MINOR: 0, SUGGESTION: 0 };
for (const s of severities) counts[s] += 1;
const json = JSON.stringify({ verdict: jsonVerdict ?? verdict, counts, allow_blocked_by: allowBlockedBy, findings }, null, 2);
return `---
type: trekreview
review_version: "1.1"
task: "t"
slug: s
project_dir: .claude/projects/2026-10-06-s/
brief_path: .claude/projects/2026-10-06-s/brief.md
scope_sha_end: ${scopeShaEnd}
reviewed_files_count: 1
${fmVerdict}findings:
${ids.map((id) => ` - ${id}`).join('\n')}
---
# Review: t
## Executive Summary
x
## Coverage
| File | Treatment | Reason |
|------|-----------|--------|
| a.mjs | summary-only | default |
## Remediation Summary
x
\`\`\`json
${json}
\`\`\`
`;
}
// Copied from tests/lib/verdict-gate.test.mjs:255-261 (the R6 fixture texts), never imported.
const LESSONS = '.claude/rules/voyage-review-lessons.md';
const POINTER = `${POINTER_START}\n${POINTER_LINE}\n${POINTER_END}\n`;
const LESSONS_BEFORE = `# Review lessons\n\n${MARK_START}\nEach line: a finding class.\n${MARK_END}\n\nKept by hand.\n`;
const LESSON = '- [MISSING_TEST] (check) Mistake: m. · Fix: f. · How to check: c.';
const LESSONS_AFTER = `# Review lessons\n\n${MARK_START}\nEach line: a finding class.\n${LESSON}\n${MARK_END}\n\nKept by hand.\n`;
// Copied from tests/lib/verdict-gate.test.mjs:263-267 (g), never imported.
function g(cwd, ...args) {
const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: childEnv() });
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
return r.stdout.trim();
}
// Copied from tests/lib/verdict-gate.test.mjs:269-273 (putIn), never imported.
function putIn(dir, rel, text) {
mkdirSync(dirname(join(dir, rel)), { recursive: true });
writeFileSync(join(dir, rel), text);
}
const seeds = new Map();
after(() => {
for (const seed of seeds.values()) rmSync(seed.base, { recursive: true, force: true });
});
// Copied from tests/lib/verdict-gate.test.mjs:281-301 (seedRepo), never imported. Changed:
// a pre-review commit P (a.mjs at `export const a = 0;`) comes first, so the
// reviewed commit R changes a.mjs on top of it and is not the root commit.
function seedRepo() {
const base = mkdtempSync(join(tmpdir(), 'reviewed-commit-parents-seed-'));
const repo = join(base, 'repo');
mkdirSync(repo, { recursive: true });
mkdirSync(join(base, 'hooks-none'));
g(repo, 'init', '-q', '-b', 'main');
appendFileSync(join(repo, '.git', 'config'), '[user]\n\tname = Test\n\temail = test@example.invalid\n'
+ `[commit]\n\tgpgsign = false\n[core]\n\thooksPath = "${join(base, 'hooks-none')}"\n`);
putIn(repo, 'a.mjs', 'export const a = 0;\n');
g(repo, 'add', '-A');
g(repo, 'commit', '-q', '-m', 'feat: before the review');
const pre = g(repo, 'rev-parse', 'HEAD');
putIn(repo, 'a.mjs', 'export const a = 1;\n');
putIn(repo, LESSONS, LESSONS_BEFORE);
putIn(repo, 'CLAUDE.md', '# Project\n');
g(repo, 'add', '-A');
g(repo, 'commit', '-q', '-m', 'feat: the work');
return { base, pre, reviewed: g(repo, 'rev-parse', 'HEAD') };
}
// Copied from tests/lib/verdict-gate.test.mjs:310-339 (gateRepo), never imported. Changed:
// one variant only (no sub, claudeLink, objectFormat), and it also returns P as `pre`.
function gateRepo() {
if (!seeds.has('')) seeds.set('', seedRepo());
const seed = seeds.get('');
const base = mkdtempSync(join(tmpdir(), 'reviewed-commit-parents-'));
cpSync(seed.base, base, { recursive: true, verbatimSymlinks: true });
const repo = join(base, 'repo');
const config = join(repo, '.git', 'config');
writeFileSync(config, readFileSync(config, 'utf8').split(seed.base).join(base));
const { reviewed, pre } = seed;
const commit = (msg, files) => {
for (const [rel, text] of Object.entries(files)) putIn(repo, rel, text);
g(repo, 'add', '-A');
g(repo, 'commit', '-q', '-m', msg);
};
const gate = ({ verdict = 'ALLOW', sha = reviewed } = {}) => {
const p = join(base, 'review.md');
writeFileSync(p, review({ verdict, severities: ['MINOR'], scopeShaEnd: sha }));
return spawnSync(process.execPath, [GATE, '--json', p, '--repo', repo], { encoding: 'utf-8', env: childEnv() });
};
return {
base, repo, reviewed, pre, commit, gate,
git: (...args) => g(repo, ...args),
cleanup: () => rmSync(base, { recursive: true, force: true }),
};
}
// Copied from tests/lib/verdict-gate.test.mjs:341-344 (lessonsCommit), never imported.
function lessonsCommit(fx, claudeAfter = `# Project\n\n${POINTER}`) {
fx.commit('docs(voyage): review lessons (round 1)', { [LESSONS]: LESSONS_AFTER, 'CLAUDE.md': claudeAfter });
}
// Copied from tests/lib/verdict-gate.test.mjs:346-350 (assertStale), never imported.
function assertStale(r, re = /review-stale/) {
assert.equal(r.status, 2, r.stdout + r.stderr);
assert.match(r.stdout, /review-stale/);
assert.match(r.stdout, re);
}
test('[RMP] a merge whose first parent is the reviewed commit\'s parent and whose tree is that parent\'s tree, the reviewed commit as second parent → exit 2 review-stale naming a.mjs', () => {
const fx = gateRepo();
try {
const tree = fx.git('rev-parse', `${fx.pre}^{tree}`);
const merge = fx.git('commit-tree', tree, '-p', fx.pre, '-p', fx.reviewed, '-m', 'merge: back to before the review');
fx.git('reset', '-q', '--hard', merge);
// Control: HEAD is the merge, its first parent P, its second parent R, its tree P's tree.
assert.equal(fx.git('rev-parse', 'HEAD^1'), fx.pre);
assert.equal(fx.git('rev-parse', 'HEAD^2'), fx.reviewed);
assert.equal(fx.git('rev-parse', 'HEAD^{tree}'), tree);
assertStale(fx.gate(), /a\.mjs/);
} finally { fx.cleanup(); }
});
test('[RMP] a side branch from before the review, with one empty commit, merged over the reviewed history with -s ours → exit 2 review-stale', () => {
const fx = gateRepo();
try {
fx.git('checkout', '-q', '-b', 'side', fx.pre);
fx.git('commit', '-q', '--allow-empty', '-m', 'chore: nothing');
const empty = fx.git('rev-parse', 'HEAD');
fx.git('merge', '-q', '-s', 'ours', '--no-edit', 'main');
// Control: HEAD is the merge on the side branch, first parent the empty commit, tree the pre-review tree.
assert.equal(fx.git('rev-parse', 'HEAD^1'), empty);
assert.equal(fx.git('rev-parse', 'HEAD^2'), fx.reviewed);
assert.equal(fx.git('rev-parse', 'HEAD^{tree}'), fx.git('rev-parse', `${fx.pre}^{tree}`));
assertStale(fx.gate(), /a\.mjs/);
} finally { fx.cleanup(); }
});
test('[RMP] an octopus merge whose first parent is the reviewed commit\'s parent and whose tree is that parent\'s tree → exit 2 review-stale', () => {
const fx = gateRepo();
try {
fx.git('checkout', '-q', '-b', 'side', fx.pre);
fx.git('commit', '-q', '--allow-empty', '-m', 'chore: nothing');
const x = fx.git('rev-parse', 'HEAD');
fx.git('checkout', '-q', 'main');
const tree = fx.git('rev-parse', `${fx.pre}^{tree}`);
const merge = fx.git('commit-tree', tree, '-p', fx.pre, '-p', x, '-p', fx.reviewed, '-m', 'merge: octopus back to before the review');
fx.git('reset', '-q', '--hard', merge);
// Control: three parents P, X, R.
assert.deepEqual(fx.git('rev-list', '--parents', '-n', '1', 'HEAD').split(' ').slice(1), [fx.pre, x, fx.reviewed]);
assertStale(fx.gate(), /a\.mjs/);
} finally { fx.cleanup(); }
});
test('keep: a merge after the review whose main side adds a lesson line and whose side branch from R adds only the CLAUDE.md pointer → exit 0', () => {
const fx = gateRepo();
try {
fx.git('checkout', '-q', '-b', 'side');
fx.commit('docs(voyage): pointer', { 'CLAUDE.md': `# Project\n\n${POINTER}` });
fx.git('checkout', '-q', 'main');
fx.commit('docs(voyage): review lessons (round 1)', { [LESSONS]: LESSONS_AFTER });
fx.git('merge', '-q', '--no-ff', '--no-edit', 'side');
// Control: HEAD is a merge of the two.
assert.equal(fx.git('rev-list', '--parents', '-n', '1', 'HEAD').split(' ').length, 3);
const r = fx.gate();
assert.equal(r.status, 0, r.stdout + r.stderr);
} finally { fx.cleanup(); }
});
test('keep: a merge after the review that brings in a source change from a side branch started at R → exit 2 review-stale', () => {
const fx = gateRepo();
try {
fx.git('checkout', '-q', '-b', 'side');
fx.commit('fix: side work', { 'a.mjs': 'export const a = 3;\n' });
fx.git('checkout', '-q', 'main');
lessonsCommit(fx);
fx.git('merge', '-q', '--no-ff', '--no-edit', 'side');
assertStale(fx.gate(), /a\.mjs/);
} finally { fx.cleanup(); }
});