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
|
|
@ -8,7 +8,11 @@
|
|||
//
|
||||
// - the reviewed commit is HEAD, or an ancestor of HEAD;
|
||||
// - every commit between it and HEAD (side branches included, each diffed
|
||||
// against its first parent, `--no-relative --ignore-submodules=none`) changes only
|
||||
// against every parent it has, `--no-relative --ignore-submodules=none`;
|
||||
// some path of parent edges leads from the reviewed commit to HEAD, so
|
||||
// when every edge changes only the allowed paths, HEAD's tree outside
|
||||
// them is the reviewed tree — a merge whose first parent comes from
|
||||
// before the review cannot restore the tree from before it) changes only
|
||||
// `{prefix}.claude/rules/voyage-review-lessons.md`, `{prefix}CLAUDE.md`
|
||||
// and the approval log `.claude/voyage/approvals.jsonl` (LOG_RELPATH, at
|
||||
// the git top level, never under {prefix}), where {prefix} is the
|
||||
|
|
@ -267,49 +271,53 @@ export function checkReviewedCommit({ repo, sha } = {}) {
|
|||
const root = lines.find((f) => f.length === 1);
|
||||
if (root) return stale(`commit ${short(root[0])} has no parent`);
|
||||
|
||||
for (const [c] of lines) {
|
||||
// `--no-relative` and `--ignore-submodules=none`: neither `diff.relative`
|
||||
// nor an `ignore =` setting (config or .gitmodules) may hide a changed path.
|
||||
const names = git(repo, ['diff', '--no-relative', '--ignore-submodules=none', '--name-only', '-z', '--no-renames', `${c}^1`, c]);
|
||||
if (names.status !== 0) return gitFailed(`diff ${short(c)}`, names);
|
||||
const paths = names.stdout.split('\0').filter(Boolean);
|
||||
// The approval log is the top-level one: `--no-relative` paths are from the top level.
|
||||
const other = paths.find((p) => p !== lessons && p !== claude && p !== LOG_RELPATH);
|
||||
if (other !== undefined) return stale(`${other} changed after the review (commit ${short(c)})`);
|
||||
for (const [c, ...parents] of lines) {
|
||||
// Every parent edge, not only the first: a merge whose first parent is
|
||||
// from before the review could otherwise bring the pre-review tree back.
|
||||
for (const p of parents) {
|
||||
// `--no-relative` and `--ignore-submodules=none`: neither `diff.relative`
|
||||
// nor an `ignore =` setting (config or .gitmodules) may hide a changed path.
|
||||
const names = git(repo, ['diff', '--no-relative', '--ignore-submodules=none', '--name-only', '-z', '--no-renames', p, c]);
|
||||
if (names.status !== 0) return gitFailed(`diff ${short(c)}`, names);
|
||||
const paths = names.stdout.split('\0').filter(Boolean);
|
||||
// The approval log is the top-level one: `--no-relative` paths are from the top level.
|
||||
const other = paths.find((q) => q !== lessons && q !== claude && q !== LOG_RELPATH);
|
||||
if (other !== undefined) return stale(`${other} changed after the review (commit ${short(c)})`);
|
||||
|
||||
for (const path of paths) {
|
||||
const before = blobAt(repo, `${c}^1`, path);
|
||||
if (!before.ok) return before.fail;
|
||||
const after = blobAt(repo, c, path);
|
||||
if (!after.ok) return after.fail;
|
||||
if (before.mode === SYMLINK_MODE || after.mode === SYMLINK_MODE) {
|
||||
return stale(`${path} is a symlink (commit ${short(c)}) — re-run /trekreview on this commit`);
|
||||
}
|
||||
if (path === LOG_RELPATH) {
|
||||
// 7. The approval log: only appended review answers (R6).
|
||||
const was = bytesAt(repo, `${c}^1`, path, before);
|
||||
if (!was.ok) return was.fail;
|
||||
const now = bytesAt(repo, c, path, after);
|
||||
if (!now.ok) return now.fail;
|
||||
// A regular, non-executable file after the commit, on creation too.
|
||||
if (after.mode !== LOG_MODE || !onlyAppendedReviewRecords(was.bytes, now.bytes)) {
|
||||
return stale(`${path} changed other than by appended review answers (commit ${c.slice(0, 8)})`);
|
||||
for (const path of paths) {
|
||||
const before = blobAt(repo, p, path);
|
||||
if (!before.ok) return before.fail;
|
||||
const after = blobAt(repo, c, path);
|
||||
if (!after.ok) return after.fail;
|
||||
if (before.mode === SYMLINK_MODE || after.mode === SYMLINK_MODE) {
|
||||
return stale(`${path} is a symlink (commit ${short(c)}) — re-run /trekreview on this commit`);
|
||||
}
|
||||
} else if (path === lessons) {
|
||||
// 5. Only the managed block may change.
|
||||
if (outsideBlock(before.text).trimEnd() !== outsideBlock(after.text).trimEnd()) {
|
||||
return stale(`${path} changed outside the managed block (commit ${short(c)})`);
|
||||
if (path === LOG_RELPATH) {
|
||||
// 7. The approval log: only appended review answers (R6).
|
||||
const was = bytesAt(repo, p, path, before);
|
||||
if (!was.ok) return was.fail;
|
||||
const now = bytesAt(repo, c, path, after);
|
||||
if (!now.ok) return now.fail;
|
||||
// A regular, non-executable file after the commit, on creation too.
|
||||
if (after.mode !== LOG_MODE || !onlyAppendedReviewRecords(was.bytes, now.bytes)) {
|
||||
return stale(`${path} changed other than by appended review answers (commit ${c.slice(0, 8)})`);
|
||||
}
|
||||
} else if (path === lessons) {
|
||||
// 5. Only the managed block may change.
|
||||
if (outsideBlock(before.text).trimEnd() !== outsideBlock(after.text).trimEnd()) {
|
||||
return stale(`${path} changed outside the managed block (commit ${short(c)})`);
|
||||
}
|
||||
// 5b. What the block gains is lesson lines, or the header applyLesson
|
||||
// writes with the first lesson; free text there is an instruction (m4).
|
||||
const free = addedBlockLines(before.text, after.text)
|
||||
.find((l) => l !== BLOCK_HEADER && !validateLessonLine(l).valid);
|
||||
if (free !== undefined) {
|
||||
return stale(`free text added inside the lessons block (commit ${short(c)})`);
|
||||
}
|
||||
} else if (after.text !== before.text && after.text !== withPointer(before.text)) {
|
||||
// 6. CLAUDE.md: unchanged, or exactly the pointer block added.
|
||||
return stale(`${path} changed other than the review-lessons pointer (commit ${short(c)})`);
|
||||
}
|
||||
// 5b. What the block gains is lesson lines, or the header applyLesson
|
||||
// writes with the first lesson; free text there is an instruction (m4).
|
||||
const free = addedBlockLines(before.text, after.text)
|
||||
.find((l) => l !== BLOCK_HEADER && !validateLessonLine(l).valid);
|
||||
if (free !== undefined) {
|
||||
return stale(`free text added inside the lessons block (commit ${short(c)})`);
|
||||
}
|
||||
} else if (after.text !== before.text && after.text !== withPointer(before.text)) {
|
||||
// 6. CLAUDE.md: unchanged, or exactly the pointer block added.
|
||||
return stale(`${path} changed other than the review-lessons pointer (commit ${short(c)})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue