// lib/review/critic-diff.mjs // Rule A and the rule-B record for the plan gate (decision 43). // // Why: from round 2 on, the critic loop counts a new MAJOR that cites plan text // outside the diff since the last reviewed round as minor (rule A), so a loop of // critic rounds converges on what changed instead of re-litigating text that was // already reviewed. That needs a line diff between two operative plan texts // that is deterministic: the same two texts always give the same hunks, so the // same findings always give the same verdict. // // lineDiff(oldText, newText) -> { inside, oldToNew, hunks } // Lines are 1-based operative lines; CRLF is normalized first. // inside Set of every inserted or changed new line; for a pure-deletion // hunk also the new lines on both sides of the deletion point. // oldToNew oldToNew[o] = the new line an unchanged old line moved to, else // null (index 0 is null). // hunks [{oldStart, oldEnd, newStart, newEnd}], inclusive ranges in order; // an empty side has end = start - 1. // How: trim the common prefix and suffix; anchor on lines that occur exactly // once in both texts, keep the longest increasing subsequence of anchors and // recurse between them; a gap with no unique line uses an LCS table, ties // resolved toward matching the earliest old line. // // Pure: no I/O, and it never reads the plan gate (one dependency direction). // // Credit: patience-style anchoring (unique-line anchors kept by their longest // increasing subsequence), the idea behind Bram Cohen's patience diff; the code // here is written fresh. const toLines = (text) => { const t = String(text ?? '').replace(/\r\n?/g, '\n'); if (t === '') return []; const out = t.split('\n'); if (out[out.length - 1] === '') out.pop(); return out; }; /** Indexes (into `items`) of a longest strictly increasing subsequence of items[k].b; earliest on ties. */ function lisByB(items) { const tails = []; // tails[len-1] = index into items of the smallest tail const prev = new Array(items.length).fill(-1); for (let k = 0; k < items.length; k++) { const v = items[k].b; let lo = 0; let hi = tails.length; while (lo < hi) { const mid = (lo + hi) >> 1; if (items[tails[mid]].b < v) lo = mid + 1; else hi = mid; } if (lo > 0) prev[k] = tails[lo - 1]; tails[lo] = k; } const out = []; for (let k = tails.length ? tails[tails.length - 1] : -1; k !== -1; k = prev[k]) out.push(k); return out.reverse(); } /** LCS over a[aLo..aHi) x b[bLo..bHi); pushes matched [i, j] pairs in order. */ function lcsGap(a, aLo, aHi, b, bLo, bHi, pairs) { const n = aHi - aLo; const m = bHi - bLo; const w = m + 1; // L[i*w + j] = LCS length of a[aLo+i..aHi) and b[bLo+j..bHi) const L = new Uint32Array((n + 1) * w); for (let i = n - 1; i >= 0; i--) { for (let j = m - 1; j >= 0; j--) { L[i * w + j] = a[aLo + i] === b[bLo + j] ? L[(i + 1) * w + j + 1] + 1 : Math.max(L[(i + 1) * w + j], L[i * w + j + 1]); } } let i = 0; let j = 0; while (i < n && j < m) { if (a[aLo + i] === b[bLo + j]) { pairs.push([aLo + i, bLo + j]); i += 1; j += 1; } else if (L[i * w + j + 1] >= L[(i + 1) * w + j]) { j += 1; // keep old line i for the earliest match } else { i += 1; } } } /** Match a[aLo..aHi) against b[bLo..bHi); pushes matched [i, j] pairs (0-based) in order. */ function matchRange(a, aLo, aHi, b, bLo, bHi, pairs) { while (aLo < aHi && bLo < bHi && a[aLo] === b[bLo]) { pairs.push([aLo, bLo]); aLo += 1; bLo += 1; } const tail = []; while (aLo < aHi && bLo < bHi && a[aHi - 1] === b[bHi - 1]) { aHi -= 1; bHi -= 1; tail.push([aHi, bHi]); } if (aLo < aHi && bLo < bHi) { const count = new Map(); // line -> {na, nb, a, b} for (let i = aLo; i < aHi; i++) { const c = count.get(a[i]) ?? { na: 0, nb: 0, a: -1, b: -1 }; c.na += 1; c.a = i; count.set(a[i], c); } for (let j = bLo; j < bHi; j++) { const c = count.get(b[j]); if (!c) continue; c.nb += 1; c.b = j; } const anchors = []; for (let i = aLo; i < aHi; i++) { const c = count.get(a[i]); if (c.na === 1 && c.nb === 1) anchors.push({ a: c.a, b: c.b }); } if (anchors.length > 0) { let pa = aLo; let pb = bLo; for (const k of lisByB(anchors)) { const { a: ia, b: ib } = anchors[k]; matchRange(a, pa, ia, b, pb, ib, pairs); pairs.push([ia, ib]); pa = ia + 1; pb = ib + 1; } matchRange(a, pa, aHi, b, pb, bHi, pairs); } else { lcsGap(a, aLo, aHi, b, bLo, bHi, pairs); } } for (let k = tail.length - 1; k >= 0; k--) pairs.push(tail[k]); } /** * Deterministic line diff of two operative texts. * @param {string} oldText * @param {string} newText * @returns {{inside: Set, oldToNew: Array, hunks: Array<{oldStart: number, oldEnd: number, newStart: number, newEnd: number}>}} */ export function lineDiff(oldText, newText) { const a = toLines(oldText); const b = toLines(newText); const pairs = []; matchRange(a, 0, a.length, b, 0, b.length, pairs); const oldToNew = new Array(a.length + 1).fill(null); for (const [i, j] of pairs) oldToNew[i + 1] = j + 1; const hunks = []; const inside = new Set(); let pi = -1; let pj = -1; for (const [qi, qj] of [...pairs, [a.length, b.length]]) { if (qi > pi + 1 || qj > pj + 1) { const h = { oldStart: pi + 2, oldEnd: qi, newStart: pj + 2, newEnd: qj }; hunks.push(h); if (h.newEnd >= h.newStart) { for (let n = h.newStart; n <= h.newEnd; n++) inside.add(n); } else { if (h.newStart - 1 >= 1) inside.add(h.newStart - 1); if (h.newStart <= b.length) inside.add(h.newStart); } } pi = qi; pj = qj; } return { inside, oldToNew, hunks }; }