169 lines
5.8 KiB
JavaScript
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 };
|
|
}
|