voyage/lib/review/critic-diff.mjs
2026-10-15 16:49:21 +02:00

169 lines
5.8 KiB
JavaScript

// 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<number>, oldToNew: Array<number|null>, 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 };
}