feat(plan): the operative line map and a deterministic line diff for the critic loop [skip-docs]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-15 16:49:21 +02:00
commit 3a0c2d0c54
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
3 changed files with 506 additions and 0 deletions

169
lib/review/critic-diff.mjs Normal file
View file

@ -0,0 +1,169 @@
// 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 };
}

View file

@ -100,6 +100,47 @@ export function operativeText(text) {
.trim() + '\n';
}
/**
* The operative text with its line map, built from the same transforms as
* `operativeText` (whose output it returns unchanged).
* @returns {{text: string, fullToOp: Array<number|null>, opToFull: Array<number|null>}}
* 1-based both ways (index 0 is null). fullToOp[i] is null for a review-log
* line, the quality line or a collapsed blank; opToFull[j] is null for an
* operative line no file line carries (a blank left where a log section stood).
*/
export function operativeLines(text) {
const src = norm(text);
const full = src.split('\n');
const excluded = new Set();
for (const s of splitSections(src, 2)) {
if (!isLogTitle(s.title)) continue;
const n = logPart(s.text).log.split('\n').length;
for (let i = s.start; i < s.start + n; i++) excluded.add(i);
}
full.forEach((l, i) => { if (QUALITY_LINE.test(l)) excluded.add(i); });
const op = operativeText(text);
const opL = op.split('\n');
const opCount = opL.length - 1; // the text ends with '\n'; the last split element is not a line
const fullToOp = new Array(full.length + 1).fill(null);
const opToFull = new Array(opCount + 1).fill(null);
let j = 0;
for (let i = 0; i < full.length && j < opCount; i++) {
if (excluded.has(i)) continue;
let l = full[i].replace(/[ \t]+$/, '');
if (j === 0) l = l.replace(/^\s+/, '');
// Only blanks lose their partner, so a non-blank kept line facing an operative
// blank means that blank has no file line: step past it.
while (l !== '' && opL[j] === '' && j < opCount - 1) j += 1;
const want = opL[j];
if (l === want || (j === opCount - 1 && l.trimEnd() === want)) {
fullToOp[i + 1] = j + 1;
opToFull[j + 1] = i + 1;
j += 1;
}
}
return { text: op, fullToOp, opToFull };
}
export function planHash(text) {
return createHash('sha256').update(operativeText(text)).digest('hex');
}