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:
parent
c9248bbb52
commit
3a0c2d0c54
3 changed files with 506 additions and 0 deletions
169
lib/review/critic-diff.mjs
Normal file
169
lib/review/critic-diff.mjs
Normal 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 };
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue