// lib/review/pre-existing.mjs // Pre-existing findings (review_version 1.2, fix round 4.5b2 part B): a // finding on a line the review range did not add or change. The mark is read // from git and never trusted as written: `lib/review/review-finalize.mjs` // writes it (`markFindings`), and the verdict gate re-derives it // (`preExistingCheck`) before a MAJOR leaves the answer set. A BLOCKER or a // security finding counts in full whatever the mark (lib/review/review-answers.mjs // `countingMajors`); every uncertain case counts as new. // // The line sets come from one `git diff -U0` of `scope_sha_start..scope_sha_end` // with flags that no repo config changes (`--no-color`, `--no-ext-diff`, // `--no-textconv`, `--no-relative`, `--find-renames`, explicit prefixes, // `--ignore-submodules=none`, `core.quotePath=false`): // - added or changed lines are `c..c+d-1` of each `@@ -a,b +c,d @@`; // - a pure deletion (`d = 0`) marks lines `c` and `c+1` (both sides of it); // - a new or binary file is `'all'`; // - a renamed or copied file goes into `renamed` under both names. // Any git failure, ENOBUFS, a range whose start is not a proper ancestor of its // end, or a line the reader cannot parse (a quoted path included) is // `{ok:false}`: then nothing is pre-existing. // // A finding's path and line must also be in the tree at `scope_sha_end`: the // path, byte for byte, names a regular file there (mode 100644 or 100755, read // once with `git ls-tree -r -z --full-tree`), and the line lies inside that // file's line count. A path the diff does not name is otherwise taken as // untouched; this check keeps a case variant (`A.mjs` on a case-insensitive // disk), a subdirectory-relative path, a missing file or a line past the end of // the file from passing as pre-existing. Any git failure here also means // nothing is pre-existing. // // The binding (fix round 4.5b2 part B, Step 14): `scope_sha_start` is read from // review.md, so it counts only when it is bound to the fork point from main // (`forkPoint`: `refs/heads/main`, else `refs/heads/master`, the rule of // `/trekexecute` Phase 8). Exactly two starts are bound (`startBound`): // - the fork point itself; // - a later start reached through process files only: the tree diff from the // fork point to the start names only the approval log // (`.claude/voyage/approvals.jsonl`, LOG_RELPATH) and paths under the // top-level `.claude/projects/`, the commits `/trekbrief` and `/trekplan` // make on the branch (lib/util/artifact-commit.mjs). // Every other start, and a range that ends on the main line itself, is // `{ok:false}`, so nothing is pre-existing. A finding on the approval log or on // a `.claude/projects/` file is never pre-existing, whatever the start. Limits: // - a range that ends on the main line (a run committed straight to main, or // a fast-forward merge), a start older than the fork point (the line sets // are a tree diff, so a line main changed between that start and the fork // point and the branch put back would not show), and a repo with neither // branch have nothing pre-existing; a subdirectory project's own // `{prefix}.claude/projects/` is not a process path (the stricter side); // - trust limit: the fork point is read from `refs/heads/main` (or // `master`), a ref the session that writes review.md can also move (`git // update-ref`, `git branch -f`, or a plain commit on main before the branch // is made), and the Bash guard does not ask about that. The binding narrows // the forged start to a forged ref move as well. It is a trace, not a // signature, like the K2 log; // - BLOCKER and security findings are unaffected, as before: they count in // full whatever the mark. // // No CLI of its own. import { spawnSync } from 'node:child_process'; import { FULL_SHA_RE, reviewedSha } from './reviewed-commit.mjs'; import { LOG_RELPATH } from '../util/approval-log.mjs'; const MAX_BUFFER = 64 * 1024 * 1024; const HUNK_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*)?$/; const REGULAR_MODES = new Set(['100644', '100755']); const TREE_ENTRY_RE = /^(\d{6}) ([a-z]+) ([0-9a-f]{40}|[0-9a-f]{64})$/; const HEADER_LINE_RE = /^(?:old mode \d+|new mode \d+|deleted file mode \d+|new file mode \d+|index [0-9a-f]+\.\.[0-9a-f]+(?: \d+)?|similarity index \d+%|dissimilarity index \d+%)$/; // The argv git helper of lib/review/reviewed-commit.mjs:64-66 (copied, not exported there). function git(repo, args) { return spawnSync('git', args, { cwd: repo, encoding: 'utf8', maxBuffer: MAX_BUFFER }); } // The `notMeasured` shape of lib/review/reviewed-commit.mjs:72-75 (copied). function notMeasured(reason) { return { ok: false, reason: `pre-existing not measured: ${reason}` }; } function gitFailed(what, r) { const note = r.error?.code === 'ENOBUFS' ? 'output past the 64 MiB buffer' : (r.error?.message ?? String(r.stderr ?? '').trim().split('\n')[0]); return notMeasured(`git ${what} failed (${note || `exit ${r.status}`})`); } /** A reason without the `notMeasured` prefix, to be wrapped once more. */ function bare(reason) { return String(reason ?? '').replace(/^pre-existing not measured: /, ''); } /** True for the process files `/trekbrief` and `/trekplan` commit: the approval log and the top-level project folder. */ function processPath(p) { return p === LOG_RELPATH || p.startsWith('.claude/projects/'); } /** * The fork point of `end` from the main branch (`refs/heads/main`, else * `refs/heads/master`): the main line's commit when it holds nothing `end` * lacks, else the first parent of the oldest first-parent commit of the main * line that `end` lacks. * @param {string} repo * @param {string} end - a full commit id * @returns {{ok: true, sha: string, ref: string} | {ok: false, reason: string}} */ export function forkPoint(repo, end) { if (!repo) return notMeasured('no repo'); if (!FULL_SHA_RE.test(String(end ?? ''))) return notMeasured('scope_sha_end must be a full commit id'); let ref = null; let tip = null; for (const b of ['main', 'master']) { const r = git(repo, ['rev-parse', '-q', '--verify', `refs/heads/${b}`]); if (r.error || (r.status !== 0 && r.status !== 1)) return gitFailed('rev-parse --verify', r); if (r.status === 0) { ref = `refs/heads/${b}`; tip = r.stdout.trim(); break; } } if (!ref) return { ok: false, reason: 'no main or master branch' }; const list = git(repo, ['rev-list', '--first-parent', ref, `^${end}`]); if (list.error || list.status !== 0) return gitFailed('rev-list --first-parent', list); const shas = list.stdout.split('\n').filter((s) => s !== ''); if (shas.length === 0) return { ok: true, sha: tip, ref }; const last = shas.at(-1); const parent = git(repo, ['rev-parse', '-q', '--verify', `${last}^1`]); if (parent.error || (parent.status !== 0 && parent.status !== 1)) return gitFailed('rev-parse --verify', parent); if (parent.status === 1) return { ok: false, reason: `${last.slice(0, 12)} on ${ref} has no parent: no fork point` }; return { ok: true, sha: parent.stdout.trim(), ref }; } /** * Is `start` bound to the fork point `fork`? Only the fork point itself, or a * later start whose tree diff from the fork point names only process files. * @param {string} repo * @param {string} start - scope_sha_start * @param {string} fork - forkPoint(...).sha * @returns {{ok: true} | {ok: false, reason: string}} */ export function startBound(repo, start, fork) { if (!repo) return notMeasured('no repo'); if (!FULL_SHA_RE.test(String(start ?? '')) || !FULL_SHA_RE.test(String(fork ?? ''))) { return { ok: false, reason: 'the start and the fork point must be full commit ids' }; } if (start.toLowerCase() === fork.toLowerCase()) return { ok: true }; const anc = git(repo, ['merge-base', '--is-ancestor', fork, start]); if (anc.error || (anc.status !== 0 && anc.status !== 1)) return gitFailed('merge-base --is-ancestor', anc); if (anc.status === 1) return { ok: false, reason: 'the start is not after the fork point (an older or unrelated start)' }; const r = git(repo, ['diff', '--no-color', '--no-ext-diff', '--no-relative', '--ignore-submodules=none', '--name-only', '-z', '--no-renames', fork, start]); if (r.error || r.status !== 0) return gitFailed('diff --name-only', r); const other = r.stdout.split('\0').find((p) => p !== '' && !processPath(p)); if (other !== undefined) { return { ok: false, reason: `the fork point..start changes ${JSON.stringify(other.slice(0, 120))}, not a process file` }; } return { ok: true }; } /** A frontmatter sha field as written (the `reviewedSha` reading, for any key): a full sha, else ''. */ function frontmatterSha(text, key) { const fm = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/.exec(String(text ?? '')); const re = new RegExp(`^${key}:[ \\t]*(["']?)([^"'\\s#]*)\\1[ \\t]*(?:#.*)?$`, 'm'); const m = fm && re.exec(fm[1]); return m && FULL_SHA_RE.test(m[2]) ? m[2] : ''; } /** * The review's range, read raw from the frontmatter text as `reviewedSha` reads * `scope_sha_end`: a YAML number, `null` or a ref name is '' (no range, no mark). * @param {string} text - review.md * @returns {{ start: string, end: string }} */ export function scopeShas(text) { return { start: frontmatterSha(text, 'scope_sha_start'), end: reviewedSha(text) }; } /** A path as the diff prints it after its prefix; null when it is quoted (not read). */ function unprefixed(path, prefix) { const p = path.replace(/\t$/, ''); if (p.startsWith('"') || !p.startsWith(prefix) || p.length === prefix.length) return null; return p.slice(prefix.length); } /** The one path of a `diff --git a/P b/P` header (both halves equal), else null. */ function headerPath(rest) { if ((rest.length - 5) % 2 !== 0) return null; const n = (rest.length - 5) / 2; const p = rest.slice(2, 2 + n); return rest === `a/${p} b/${p}` && !p.startsWith('"') ? p : null; } /** * Read a `git diff -U0` text into line sets. * @param {string} diff * @returns {{ok: true, files: Map|'all'>, renamed: Set} | {ok: false, reason: string}} */ export function parseDiff(diff) { const files = new Map(); const renamed = new Set(); let cur = null; let minus = 0; let plus = 0; const bad = (line) => notMeasured(`cannot read diff line ${JSON.stringify(line.slice(0, 120))}`); const finish = () => { if (!cur) return null; const path = cur.to ?? cur.plusPath ?? cur.header; if (cur.from) renamed.add(cur.from); if (cur.to) renamed.add(cur.to); if (cur.deleted) return null; if (!path) return 'no path'; if (cur.isNew || cur.binary) { files.set(path, 'all'); return null; } const prev = files.get(path); if (prev === 'all') return null; const set = prev ?? new Set(); for (const n of cur.lines) set.add(n); files.set(path, set); return null; }; const lines = String(diff).split('\n'); if (lines.at(-1) === '') lines.pop(); for (const line of lines) { if (minus > 0 || plus > 0) { if (line.startsWith('-') && minus > 0) { minus--; continue; } if (line.startsWith('+') && plus > 0) { plus--; continue; } if (line.startsWith('\\')) continue; return bad(line); } if (line.startsWith('\\')) continue; if (line.startsWith('diff --git ')) { if (finish()) return bad(line); cur = { header: headerPath(line.slice('diff --git '.length)), lines: [] }; continue; } if (!cur) return bad(line); let m; if (HEADER_LINE_RE.test(line)) { if (line.startsWith('new file mode')) cur.isNew = true; if (line.startsWith('deleted file mode')) cur.deleted = true; } else if ((m = /^(rename|copy) (from|to) (.+)$/.exec(line))) { if (m[3].startsWith('"')) return bad(line); cur[m[2]] = m[3]; } else if (line.startsWith('--- ')) { const p = line.slice(4); if (p !== '/dev/null' && unprefixed(p, 'a/') === null) return bad(line); } else if (line.startsWith('+++ ')) { const p = line.slice(4); if (p === '/dev/null') cur.deleted = true; else { const q = unprefixed(p, 'b/'); if (q === null) return bad(line); cur.plusPath = q; } } else if (/^Binary files .* differ$/.test(line)) { cur.binary = true; } else if ((m = HUNK_RE.exec(line))) { const b = m[2] === undefined ? 1 : Number(m[2]); const c = Number(m[3]); const d = m[4] === undefined ? 1 : Number(m[4]); if (d === 0) cur.lines.push(c, c + 1); else for (let n = c; n < c + d; n++) cur.lines.push(n); minus = b; plus = d; } else { return bad(line); } } if (minus > 0 || plus > 0) return notMeasured('the diff ends inside a hunk'); if (finish()) return notMeasured('a file in the diff has no path the reader can name'); return { ok: true, files, renamed }; } /** * The regular files of the tree at `end`, by exact repo-root path: path -> blob id. * @returns {{ok: true, blobs: Map} | {ok: false, reason: string}} */ function regularFilesAt(repo, end) { const r = git(repo, ['ls-tree', '-r', '-z', '--full-tree', end]); if (r.error || r.status !== 0) return gitFailed('ls-tree', r); const blobs = new Map(); for (const entry of r.stdout.split('\0')) { if (entry === '') continue; const tab = entry.indexOf('\t'); const m = tab === -1 ? null : TREE_ENTRY_RE.exec(entry.slice(0, tab)); if (!m) return notMeasured(`cannot read ls-tree entry ${JSON.stringify(entry.slice(0, 120))}`); if (m[2] === 'blob' && REGULAR_MODES.has(m[1])) blobs.set(entry.slice(tab + 1), m[3]); } return { ok: true, blobs }; } /** The line count of one blob (a last line without a newline counts); null when git fails. */ function blobLineCount(repo, blob) { const r = spawnSync('git', ['cat-file', 'blob', blob], { cwd: repo, maxBuffer: MAX_BUFFER }); if (r.error || r.status !== 0 || !Buffer.isBuffer(r.stdout)) return null; const b = r.stdout; let n = 0; for (let i = b.indexOf(10); i !== -1; i = b.indexOf(10, i + 1)) n++; if (b.length > 0 && b[b.length - 1] !== 10) n++; return n; } /** * The lines `start..end` added or changed, per file (repo-root paths), plus * `lineCount(path)`: the line count of a regular file at `end` under exactly * that path, or null when there is none (or git fails). Full commit ids only; * `start` must be a proper ancestor of `end`. * @param {string} repo * @param {string} start * @param {string} end * @returns {{ok: true, files: Map|'all'>, renamed: Set, lineCount: (path: string) => number|null} | {ok: false, reason: string}} */ export function changedLineSets(repo, start, end) { if (!repo) return notMeasured('no repo'); if (!FULL_SHA_RE.test(String(start ?? '')) || !FULL_SHA_RE.test(String(end ?? ''))) { return notMeasured('scope_sha_start and scope_sha_end must be full commit ids'); } if (start.toLowerCase() === end.toLowerCase()) return notMeasured('an empty range (start = end) proves nothing'); const anc = git(repo, ['merge-base', '--is-ancestor', start, end]); if (anc.error || (anc.status !== 0 && anc.status !== 1)) return gitFailed('merge-base --is-ancestor', anc); if (anc.status === 1) return notMeasured(`${start.slice(0, 12)} is not an ancestor of ${end.slice(0, 12)}`); const fp = forkPoint(repo, end); if (!fp.ok) return notMeasured(bare(fp.reason)); const onMain = git(repo, ['merge-base', '--is-ancestor', end, fp.sha]); if (onMain.error || (onMain.status !== 0 && onMain.status !== 1)) return gitFailed('merge-base --is-ancestor', onMain); if (onMain.status === 0) return notMeasured(`the range ends on ${fp.ref}; scope_sha_start cannot be bound`); const bound = startBound(repo, start, fp.sha); if (!bound.ok) { return notMeasured(`scope_sha_start ${start.slice(0, 12)} is not bound to the fork point ${fp.sha.slice(0, 12)} from ${fp.ref}: ${bare(bound.reason)}`); } const r = git(repo, ['-c', 'core.quotePath=false', 'diff', '-U0', '--no-color', '--no-ext-diff', '--no-textconv', '--no-relative', '--find-renames', '--src-prefix=a/', '--dst-prefix=b/', '--ignore-submodules=none', start, end]); if (r.error || r.status !== 0) return gitFailed('diff', r); const sets = parseDiff(r.stdout); if (!sets.ok) return sets; const tree = regularFilesAt(repo, end); if (!tree.ok) return tree; const counts = new Map(); sets.lineCount = (path) => { if (typeof path !== 'string' || !tree.blobs.has(path)) return null; if (!counts.has(path)) counts.set(path, blobLineCount(repo, tree.blobs.get(path))); return counts.get(path); }; return sets; } /** A repo-relative posix path as the diff names it: no `./`, `..`, empty segment, leading `/` or backslash. */ function plainPath(p) { return typeof p === 'string' && p !== '' && !p.includes('\\') && p.split('/').every((s) => s !== '' && s !== '.' && s !== '..'); } /** * True only when the sets are ok, `f.line >= 1`, the file is not renamed, its * entry is not `'all'`, `f.line` is not in its set, and `f.file` is a regular * file at `end` under exactly that path with at least `f.line` lines * (`sets.lineCount`; sets without it are never pre-existing). * @param {{file?: string, line?: number}} f * @param {ReturnType} sets * @returns {boolean} */ export function isPreExisting(f, sets) { if (!sets || sets.ok !== true) return false; if (!f || !Number.isInteger(f.line) || f.line < 1 || !plainPath(f.file)) return false; // A process file can be changed by a bound start's own commits: never pre-existing. if (processPath(f.file)) return false; if (sets.renamed.has(f.file)) return false; const entry = sets.files.get(f.file); if (entry === 'all') return false; if (entry && entry.has(f.line)) return false; if (typeof sets.lineCount !== 'function') return false; const n = sets.lineCount(f.file); return Number.isInteger(n) && f.line <= n; } /** True when `file` has uncommitted changes, or git cannot say (the stricter side). */ function uncommitted(repo, file) { const r = git(repo, ['status', '--porcelain=v1', '-z', '--', `:(top,literal)${file}`]); return Boolean(r.error) || r.status !== 0 || r.stdout !== ''; } /** * Set `pre_existing` (a boolean) on every JSON finding of `payload`, in place. * With no repo or no full `start`/`end`, every mark is false; a file with * uncommitted changes is false. * @param {{findings?: object[]}} payload * @param {{ repo?: string, start?: string, end?: string }} range * @returns {object} payload */ export function markFindings(payload, { repo, start, end } = {}) { const findings = Array.isArray(payload?.findings) ? payload.findings : []; const sets = repo && start && end ? changedLineSets(repo, start, end) : notMeasured('no range'); const dirty = new Map(); for (const f of findings) { if (!f || typeof f !== 'object') continue; let pre = isPreExisting(f, sets); if (pre) { if (!dirty.has(f.file)) dirty.set(f.file, uncommitted(repo, f.file)); pre = !dirty.get(f.file); } f.pre_existing = pre; } return payload; } /** * The gate's predicate for a review: true only when the finding says * `pre_existing: true` and `changedLineSets(repo, scope_sha_start, * scope_sha_end)` agrees through `isPreExisting`. With no repo, or no full * `scope_sha_start`, it is always false. The sets are read once, when a marked * finding is first asked about. * @param {string} text - review.md * @param {string|undefined} repo * @returns {(f: object) => boolean} */ export function preExistingCheck(text, repo) { const { start, end } = scopeShas(text); if (!repo || !start || !end) return () => false; let sets = null; return (f) => { if (f?.pre_existing !== true) return false; if (sets === null) sets = changedLineSets(repo, start, end); return isPreExisting(f, sets); }; }