325 lines
16 KiB
JavaScript
325 lines
16 KiB
JavaScript
// 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 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
|
|
// 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);
|
|
// - in each such commit the approval log only gains appended review answers
|
|
// (R6, fix round 4.5b2 part B): neither side is a symlink, the log after
|
|
// is mode 100644 (also when the commit creates it), the log before
|
|
// (empty when absent) is a byte prefix of the log after, and every added
|
|
// line is a K2 record with exactly the record fields and kind `review`,
|
|
// whose `hash` is its `recordHash`, whose `seq` follows the last record
|
|
// before it and whose `prev` links to it (to GENESIS on an empty log);
|
|
// any other change to the log is stale;
|
|
// - 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 <dir>`,
|
|
// 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';
|
|
import { GENESIS, LOG_RELPATH, recordHash } from '../util/approval-log.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';
|
|
const LOG_MODE = '100644'; // the approval log after a commit: a regular, non-executable file
|
|
// The K2 record's keys (`FIELDS` plus `hash` in lib/util/approval-log.mjs, not exported there).
|
|
const RECORD_KEYS = ['expires', 'hash', 'kind', 'prev', 'seq', 'sha256', 'subject', 'ts', 'v'].join(',');
|
|
const HASH_RE = /^[0-9a-f]{64}$/;
|
|
|
|
/**
|
|
* 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);
|
|
|
|
/** Where the managed block's start marker (`s`) and end marker (`e`) are; null when either is missing. */
|
|
function blockBounds(text) {
|
|
const s = text.indexOf(MARK_START);
|
|
if (s === -1) return null;
|
|
const e = text.indexOf(MARK_END, s + MARK_START.length);
|
|
if (e === -1) return null;
|
|
return { s, e };
|
|
}
|
|
|
|
/** The lessons text with its managed block (start marker to end marker, plus its newline) taken out. */
|
|
function outsideBlock(text) {
|
|
const b = blockBounds(text);
|
|
if (!b) return text;
|
|
const { s, e } = b;
|
|
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 b = blockBounds(text);
|
|
if (!b) return [];
|
|
return text.slice(b.s + MARK_START.length, b.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 };
|
|
}
|
|
|
|
/**
|
|
* One file's bytes in one tree (`blob` is its blobAt result): empty when absent.
|
|
* Bytes, not text, so the prefix check below is a byte prefix.
|
|
*/
|
|
function bytesAt(repo, rev, path, blob) {
|
|
if (blob.mode === null) return { ok: true, bytes: Buffer.alloc(0) };
|
|
const r = spawnSync('git', ['cat-file', 'blob', `${rev}:${path}`], { cwd: repo, maxBuffer: 64 * 1024 * 1024 });
|
|
if (r.status !== 0) return { ok: false, fail: gitFailed(`cat-file ${rev}:${path}`, r) };
|
|
return { ok: true, bytes: r.stdout };
|
|
}
|
|
|
|
/**
|
|
* Does `after` hold `before` plus appended `review` records, each a valid link
|
|
* of the K2 chain? `before`/`after` are Buffers. Returns true or false.
|
|
*/
|
|
function onlyAppendedReviewRecords(before, after) {
|
|
if (after.length <= before.length || !after.subarray(0, before.length).equals(before)) return false;
|
|
let added;
|
|
try {
|
|
added = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(after.subarray(before.length));
|
|
} catch {
|
|
return false;
|
|
}
|
|
const beforeText = before.toString('utf8');
|
|
// appendApproval adds a newline first when the log does not end with one.
|
|
if (beforeText !== '' && !beforeText.endsWith('\n')) {
|
|
if (!added.startsWith('\n')) return false;
|
|
added = added.slice(1);
|
|
}
|
|
if (added === '' || !added.endsWith('\n')) return false;
|
|
|
|
let seq = 0;
|
|
let prev = GENESIS;
|
|
const beforeLines = beforeText.split('\n');
|
|
if (beforeLines[beforeLines.length - 1] === '') beforeLines.pop();
|
|
if (beforeLines.length > 0) {
|
|
let last;
|
|
try { last = JSON.parse(beforeLines[beforeLines.length - 1]); } catch { return false; }
|
|
if (!last || typeof last !== 'object' || !Number.isInteger(last.seq) || !HASH_RE.test(String(last.hash))) return false;
|
|
seq = last.seq;
|
|
prev = last.hash;
|
|
}
|
|
for (const line of added.slice(0, -1).split('\n')) {
|
|
let rec;
|
|
try { rec = JSON.parse(line); } catch { return false; }
|
|
if (!rec || typeof rec !== 'object' || Array.isArray(rec)) return false;
|
|
if (Object.keys(rec).sort().join(',') !== RECORD_KEYS) return false;
|
|
if (rec.kind !== 'review' || rec.seq !== seq + 1 || rec.prev !== prev) return false;
|
|
if (recordHash(rec) !== rec.hash) return false;
|
|
seq = rec.seq;
|
|
prev = rec.hash;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 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, `<commit> <parent>...`; 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, ...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, 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`);
|
|
}
|
|
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)})`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return { ok: true };
|
|
}
|