// 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 // --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(); } });