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:
parent
03c9f527a3
commit
57e7eba0a0
2 changed files with 278 additions and 41 deletions
229
tests/lib/reviewed-commit-parents.test.mjs
Normal file
229
tests/lib/reviewed-commit-parents.test.mjs
Normal 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(); }
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue