// lib/review/reviewed-commit.mjs // R6 — is the review of the commit being merged? (fix round 4-5a) // // A review verdict opens the merge into main only for the history it judged. // `/trekexecute` commits the review lessons after the review, so the commit // being merged is rarely the reviewed one itself; what may come after it is // exactly what /trekreview writes and nothing else: // // - 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 // `{prefix}.claude/rules/voyage-review-lessons.md` and `{prefix}CLAUDE.md`, // where {prefix} is the project's place in the repo (`git rev-parse // --show-prefix`); // - in each such commit the lessons file is unchanged outside its managed // block, every line its block gains is a lesson line (`validateLessonLine`) // or exactly `BLOCK_HEADER` (m4), and CLAUDE.md is unchanged or gains exactly the pointer block // (`withPointer` in lib/review/recurrence.mjs); // - a commit with no parent in that range fails closed, and so does a // symlinked CLAUDE.md (the pointer write goes through the link, so its // target shows as a changed path). // // No CLI of its own: the command is `lib/review/verdict-gate.mjs --repo `, // which exits 2 (never green) with the reason when `checkReviewedCommit` is // not ok. Two kinds of reason (M4): // // - `review-stale: …` — the gate looked, and the review is of another // commit (or something else changed after it); // - `review-not-measured: …` — the gate could not look: the `--repo` path // is missing, it is no git repository, or a git call failed. That is not // a finding about the review, so it is never reported as stale. // // Every git call is an argv array with no shell; a failed git call fails // closed (not measured), never ok. import { spawnSync } from 'node:child_process'; import { existsSync } from 'node:fs'; import { LESSONS_FILE_NAME, MARK_START, MARK_END, BLOCK_HEADER, withPointer, validateLessonLine } from './recurrence.mjs'; /** * A full commit id: 40 hex (SHA-1) or 64 hex (SHA-256), nothing shorter (M11). * A short id can also be a branch name, which resolves to wherever the branch * points now (DJ3). */ export const FULL_SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i; const LESSONS_REL = `.claude/rules/${LESSONS_FILE_NAME}`; // posix, as `git diff` prints it const CLAUDE_REL = 'CLAUDE.md'; const SYMLINK_MODE = '120000'; /** * The review's `scope_sha_end` as written. Read from the frontmatter text, not * through the YAML reader, which turns an all-digit value into a number and * drops a leading zero (`0123456` → 123456; release review of 6.3.0, round 3). */ export function reviewedSha(reviewText) { const fm = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/.exec(String(reviewText ?? '')); const m = fm && /^scope_sha_end:[ \t]*(["']?)([^"'\s#]*)\1[ \t]*(?:#.*)?$/m.exec(fm[1]); // A commit sha only: a ref name (HEAD, main, a tag) resolves to whatever it // points at now, and coverage would pass whatever was reviewed (round 4); // so can a short id that is also a branch name (M11). return m && FULL_SHA_RE.test(m[2]) ? m[2] : ''; } function git(repo, args) { return spawnSync('git', args, { cwd: repo, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); } function stale(reason) { return { ok: false, reason: `review-stale: ${reason}` }; } /** The gate could not look (M4): exit 2 like a stale review, but never called one. */ function notMeasured(reason) { return { ok: false, reason: `review-not-measured: ${reason}` }; } /** What a git call said: its spawn error, else the first line of its stderr. */ function gitNote(r) { return r.error?.message ?? String(r.stderr ?? '').trim().split('\n')[0]; } /** A failed git call: not measured, fails closed. */ function gitFailed(what, r) { return notMeasured(`git ${what} failed (${gitNote(r) || `exit ${r.status}`})`); } const short = (c) => c.slice(0, 12); /** The lessons text with its managed block (start marker to end marker, plus its newline) taken out. */ function outsideBlock(text) { const s = text.indexOf(MARK_START); if (s === -1) return text; const e = text.indexOf(MARK_END, s + MARK_START.length); if (e === -1) return text; let end = e + MARK_END.length; if (text.startsWith('\r\n', end)) end += 2; else if (text[end] === '\n') end += 1; return text.slice(0, s) + text.slice(end); } /** The non-blank lines inside the managed block (the same block `outsideBlock` takes out); none without one. */ function blockLines(text) { const s = text.indexOf(MARK_START); if (s === -1) return []; const e = text.indexOf(MARK_END, s + MARK_START.length); if (e === -1) return []; return text.slice(s + MARK_START.length, e).split(/\r?\n/).filter((l) => l.trim() !== ''); } /** * The lines in `after`'s block that `before`'s block does not hold, counted as * a multiset (a second copy of a line already there is added). */ function addedBlockLines(before, after) { const left = new Map(); for (const l of blockLines(before)) left.set(l, (left.get(l) ?? 0) + 1); const added = []; for (const l of blockLines(after)) { const n = left.get(l) ?? 0; if (n > 0) left.set(l, n - 1); else added.push(l); } return added; } /** * One file in one tree: `{ ok, mode, text }`; absent reads as mode null, text ''. * `--full-tree`: the path is from the repo root, as `git diff` prints it. */ function blobAt(repo, rev, path) { const ls = git(repo, ['ls-tree', '--full-tree', '-z', rev, '--', path]); if (ls.status !== 0) return { ok: false, fail: gitFailed(`ls-tree ${rev}`, ls) }; const entry = ls.stdout.split('\0').find(Boolean); if (!entry) return { ok: true, mode: null, text: '' }; const mode = entry.split(' ')[0]; const blob = git(repo, ['cat-file', 'blob', `${rev}:${path}`]); if (blob.status !== 0) return { ok: false, fail: gitFailed(`cat-file ${rev}:${path}`, blob) }; return { ok: true, mode, text: blob.stdout }; } /** * Is the review of `sha` a review of what HEAD holds in `repo`? * @param {{ repo: string, sha: string }} args * @returns {{ ok: true } | { ok: false, reason: string }} reason starts `review-stale:` * (the review is of another commit) or `review-not-measured:` (the gate could not look) */ export function checkReviewedCommit({ repo, sha } = {}) { if (typeof repo !== 'string' || repo === '') return stale('no repository given'); if (!existsSync(repo)) return notMeasured(`--repo ${repo} does not exist`); const given = String(sha ?? ''); const notCommit = `reviewed commit ${given} is not a commit in this repository`; // 1. Full hex only, so a value like `--output=/x` never reaches git as an option. if (!FULL_SHA_RE.test(given)) return stale(notCommit); const resolved = git(repo, ['rev-parse', '--verify', '--quiet', `${given}^{commit}`]); // `--quiet` makes an unknown commit exit 1 with nothing on stderr: that is // stale. A spawn error, any other exit, or stderr text (`not a git // repository`, a broken git) means the gate could not look. if (resolved.status === 1 && !resolved.error && String(resolved.stderr ?? '').trim() === '') { return stale(notCommit); } if (resolved.status !== 0) { const note = gitNote(resolved); if (/not a git repository/.test(note)) return notMeasured(`${repo} is not a git repository (${note})`); return gitFailed('rev-parse --verify', resolved); } const full = resolved.stdout.trim(); // A ref named like an id (64 hex in a SHA-1 repository) also resolves: the // id must be the commit itself, not a name that points at one (M11). if (full !== given.toLowerCase()) return stale(`${given} is not a full commit id in this repository`); // 2. The review is of HEAD itself. const head = git(repo, ['rev-parse', '--verify', 'HEAD']); if (head.status !== 0) return gitFailed('rev-parse HEAD', head); if (head.stdout.trim() === full) return { ok: true }; // 3. Ancestor of HEAD (side branches merged in count; an unmerged one does not). const anc = git(repo, ['merge-base', '--is-ancestor', full, 'HEAD']); if (anc.status === 1) return stale('the reviewed commit is not an ancestor of HEAD'); if (anc.status !== 0) return gitFailed('merge-base --is-ancestor', anc); // 4. Every commit after the review, paths and content. const pre = git(repo, ['rev-parse', '--show-prefix']); if (pre.status !== 0) return gitFailed('rev-parse --show-prefix', pre); const prefix = pre.stdout.replace(/\r?\n$/, ''); const lessons = `${prefix}${LESSONS_REL}`; const claude = `${prefix}${CLAUDE_REL}`; // One line per commit, ` ...`; a line with one field is a root. const list = git(repo, ['rev-list', '--parents', `${full}..HEAD`]); if (list.status !== 0) return gitFailed('rev-list --parents', list); const lines = list.stdout.split('\n').filter(Boolean).map((l) => l.split(' ')); 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); const other = paths.find((p) => p !== lessons && p !== claude); 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 === 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)})`); } } } return { ok: true }; }