// 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. // // countRound({round, previous, base, baseNote, current, planPaths, findings, marks}) // -> { findings, resolved, diffBase, diffNote, lowered } // Decides each finding's counted severity for one critic round (RULE_A, // RULE_B below). `current` = {text, sha256, fullToOp, opToFull}, built by the // caller from the plan gate's operativeLines and planHash; `base` = {sha256, // text} is the operative text the previous round reviewed, or null. // Rule A is on only from round 2, with a base whose sha256 is the previous // verdict's plan_sha256 and differs from the plan now. Otherwise the round is // counted as before (each finding at its reported severity, plus the rule-B // record) and `diffNote` says why in one line. With rule A on, a current // finding outside the diff is matched to a previous one only when their // texts overlap (`contentOverlap` ≥ `DEFAULT_THRESHOLD`) and the current one // sits at the previous one's mapped line, or (for a previous finding with no // plan line) has its id, or both texts hold `ANCHOR_MIN_CONTENT_WORDS` // content words in the same file class; a new finding never carries a // previous finding's id: a repeated id gets `#n`, as in dedup. // A previous finding stays counted until every reviewer that raised it marks // it resolved, no reviewer says it is not, and nobody re-reports it. // Carried findings come first (previous order), then new ones (dedup order). // // Trust limit: anything that can write the plan can rewrite the previous // verdict's plan_sha256 and plant a matching base snapshot. That was already // the stated limit of the verdict block; rule A raises what such a forgery // buys. An unchanged base lowers nothing. // // 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. import { resolve } from 'node:path'; import { normalizeSeverity, contentOverlap, contentTokens, DEFAULT_THRESHOLD, ANCHOR_MIN_CONTENT_WORDS, SEVERITIES, isSecurityKey, keysOf, isSecurity, ruleB, } from './plan-review-dedup.mjs'; import { computeFindingId } from '../parsers/finding-id.mjs'; export { isSecurityKey }; export const RULE_A = 'Rule A: from round 2 on, a new major finding that cites a plan line outside the diff since the last reviewed text counts as minor; a blocker, a security finding and a finding with no plan line count at full severity, and a previous finding stays counted until it is marked resolved.'; export const RULE_B = 'Rule B: a major that a named test, `Regression:` command or gate in the plan will fail on during execution is minor — never a blocker, never a security finding.'; // The optional voyage.plan-verdict/1 fields of decision 43; absent means round-1 behaviour. export const DIFF_FIELDS = Object.freeze([ // top level 'diff_base', 'diff_note', 'resolved', // per finding 'op_line', 'op_line_end', 'line_end', 'rule_keys', 'original_severity', 'lowered_by', 'caught_by', 'lowering_refused', 'carried_from', ]); 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 }; } // ---- counting (rule A, rule B) ---------------------------------------------------- // Lower index = more severe; an unrated severity counts as blocker. const rankOf = s => SEVERITIES.indexOf(s); const countedOf = s => normalizeSeverity(s) ?? 'blocker'; const higher = (a, b) => (rankOf(a) <= rankOf(b) ? a : b); const hasOwn = (o, k) => Object.prototype.hasOwnProperty.call(o, k); function union(...lists) { const out = []; for (const list of lists) { for (const x of Array.isArray(list) ? list : []) if (!out.includes(x)) out.push(x); } return out; } /** A current finding in today's verdict shape, plus the pass-through fields. */ function project(f) { const rated = normalizeSeverity(f.severity); const out = { id: f.id ?? null, severity: rated ?? 'blocker', rule_key: f.rule_key ?? null, file: f.file ?? null, line: f.line ?? null, text: typeof f.text === 'string' ? f.text : '', raised_by: Array.isArray(f.raised_by) ? [...f.raised_by] : [], }; if (rated === null) out.severity_defaulted = true; if (Number.isInteger(f.line_end)) out.line_end = f.line_end; if (Array.isArray(f.rule_keys)) out.rule_keys = [...f.rule_keys]; return out; } /** * Where a current finding sits in the operative text, or null when it has no * plan line (another file, no line, a line in the review log, past the end, * or a `line_end` that does not map). */ function placeOf(f, fullToOp, planSet) { if (typeof f.file !== 'string' || f.file === '' || !planSet.has(resolve(f.file))) return null; const at = l => (Number.isInteger(l) && l >= 1 && l < fullToOp.length ? fullToOp[l] ?? null : null); const start = at(f.line); if (start === null) return null; const hasEnd = f.line_end !== undefined && f.line_end !== null; let end = start; if (hasEnd) { const e = Number.isInteger(f.line_end) && f.line_end >= f.line ? at(f.line_end) : null; if (e === null) return null; end = Math.max(start, e); } return { start, end, hasEnd }; } const contentSize = t => new Set(contentTokens(typeof t === 'string' ? t : '')).size; /** Does current finding `f` (placed at `pl`) sit at previous finding `p`'s mapped line? */ function atSpot(pl, p, d) { if (pl === null) return false; if (!hasOwn(p, 'op_line') || !Number.isInteger(p.op_line)) return false; const n = d.oldToNew[p.op_line] ?? null; return n !== null && n >= pl.start && n <= pl.end; } /** Does current finding `f` (placed at `pl`, outside the diff) re-report previous finding `p`? */ function sameFinding(f, pl, p, d) { if (contentOverlap(f.text, p.text) < DEFAULT_THRESHOLD) return false; if (atSpot(pl, p, d)) return true; const pCites = hasOwn(p, 'op_line'); // The id is compared only for a previous finding with no plan line. if (!pCites && typeof f.id === 'string' && f.id === p.id) return true; const sameClass = (pl !== null && pCites) || (typeof f.file === 'string' && f.file !== '' && f.file === p.file); if (!sameClass) return false; return contentSize(f.text) >= ANCHOR_MIN_CONTENT_WORDS && contentSize(p.text) >= ANCHOR_MIN_CONTENT_WORDS; } /** A previous finding leaves the count only on its raisers' true, nobody's false. */ function isResolved(p, marks) { const ms = marks.filter(m => m && typeof m.id === 'string' && m.id === p.id); if (!ms.some(m => m.resolved === true)) return false; if (ms.some(m => m.resolved === false)) return false; const raisers = Array.isArray(p.raised_by) ? p.raised_by : []; return raisers.every(r => ms.some(m => m.agent === r && m.resolved === true)); } /** A previous finding carried into this round, with its matches' severities and keys. */ function carry(p, matched, d, current, prevRound) { const pSev = countedOf(p.severity); const orig = normalizeSeverity(p.original_severity); const wasLowered = orig !== null && rankOf(orig) < rankOf(pSev); const keys = union(keysOf(p), ...matched.map(m => keysOf(m.f))); let sev; if (wasLowered) { if (keys.some(isSecurityKey)) sev = matched.reduce((s, m) => higher(s, m.sev), orig); else if (matched.some(m => m.sev === 'blocker')) sev = 'blocker'; else sev = pSev; } else { sev = matched.reduce((s, m) => higher(s, m.sev), pSev); } const out = { id: p.id ?? null, severity: sev, rule_key: p.rule_key ?? null, file: p.file ?? null, line: p.line ?? null, text: typeof p.text === 'string' ? p.text : '', raised_by: union(p.raised_by, ...matched.map(m => m.f.raised_by)), }; if (p.severity_defaulted === true) out.severity_defaulted = true; if (Number.isInteger(p.line_end)) out.line_end = p.line_end; if (keys.length > 0) out.rule_keys = keys; if (hasOwn(p, 'op_line')) { // A plan line: follow the diff. Its own line edited (or no op_line already): // keep the old line, op_line null, never re-mapped from a stale position. const n = Number.isInteger(p.op_line) ? (d.oldToNew[p.op_line] ?? null) : null; out.op_line = n; if (n !== null) { out.line = current.opToFull[n] ?? out.line; const e = Number.isInteger(p.op_line_end) ? (d.oldToNew[p.op_line_end] ?? null) : null; const eFull = e !== null ? (current.opToFull[e] ?? null) : null; if (eFull !== null) { out.line_end = eFull; out.op_line_end = e; } else { delete out.line_end; } } else if (hasOwn(p, 'op_line_end')) { out.op_line_end = null; } } out.carried_from = Number.isInteger(p.carried_from) ? p.carried_from : prevRound; if (wasLowered) { if (rankOf(sev) > rankOf(orig)) { out.original_severity = orig; if (typeof p.lowered_by === 'string') out.lowered_by = p.lowered_by; if (typeof p.caught_by === 'string') out.caught_by = p.caught_by; } } else { if (orig !== null && orig !== sev) out.original_severity = orig; if (typeof p.lowering_refused === 'string') out.lowering_refused = p.lowering_refused; if (typeof p.caught_by === 'string') out.caught_by = p.caught_by; } return out; } /** One line: the reason a round is counted fully. */ export const oneLine = s => String(s).replace(/\s*[\r\n]+\s*/g, ' ').trim(); /** * Count one critic round by rule A (from round 2, against the diff since the * text the previous round reviewed) and record rule B (every round). * Pure and deterministic. * @param {{round: number, previous: object|null, base: {sha256: string, text: string}|null, * baseNote?: string|null, current: {text: string, sha256: string, fullToOp: Array, opToFull: Array}, * planPaths: string[], findings: object[], marks: Array<{agent: string, id: string, resolved: boolean}>}} input * @returns {{findings: object[], resolved: string[]|null, diffBase: string|null, diffNote: string|null, lowered: number}} */ export function countRound({ round, previous, base, baseNote, current, planPaths, findings, marks }) { const prevRound = previous && Number.isInteger(previous.round) ? previous.round : null; let diffNote = null; let d = null; if (Number.isInteger(round) && round >= 2 && prevRound !== null) { if (!base || typeof base !== 'object') { diffNote = typeof baseNote === 'string' && baseNote.trim() !== '' ? oneLine(baseNote) : `no base snapshot of round ${prevRound}'s reviewed text; full counting`; } else if (typeof base.sha256 !== 'string' || base.sha256 !== previous.plan_sha256) { diffNote = `the base snapshot is not the text round ${prevRound} reviewed; full counting`; } else if (base.sha256 === current.sha256) { diffNote = `the plan has not changed since round ${prevRound}; full counting`; } else if (typeof base.text !== 'string') { diffNote = `the base snapshot of round ${prevRound} has no text; full counting`; } else { d = lineDiff(base.text, current.text); } } const ruleA = d !== null; const planSet = new Set(Array.isArray(planPaths) ? planPaths : []); const fullToOp = Array.isArray(current?.fullToOp) ? current.fullToOp : []; const prevFindings = ruleA && Array.isArray(previous.findings) ? previous.findings.filter(p => p && typeof p === 'object') : []; const markList = Array.isArray(marks) ? marks : []; const matchedTo = new Map(prevFindings.map(p => [p, []])); // A new finding never carries a previous finding's id (dedup's `#n` scheme). const takenIds = new Set(prevFindings.map(p => p.id).filter(id => typeof id === 'string')); const fresh = []; for (const f of Array.isArray(findings) ? findings : []) { const o = project(f); const b = ruleB(f, o.severity); o.severity = b.severity; Object.assign(o, b.record); const pl = placeOf(f, fullToOp, planSet); let inside = false; if (ruleA && pl) { for (let n = pl.start; n <= pl.end && !inside; n++) inside = d.inside.has(n); } if (ruleA && !inside) { const p = prevFindings.find(q => sameFinding(f, pl, q, d)); if (p) { matchedTo.get(p).push({ f, sev: o.severity }); continue; } } if (pl) { o.op_line = pl.start; if (pl.hasEnd) o.op_line_end = pl.end; } if (ruleA && o.severity === 'major' && pl && !inside && !isSecurity(f)) { o.severity = 'minor'; o.original_severity = 'major'; o.lowered_by = `rule A: outside the diff since round ${prevRound}`; } if (typeof o.id === 'string' && takenIds.has(o.id)) { let n = 2; let id; while (takenIds.has(id = computeFindingId(String(o.file || 'unknown'), Number.isInteger(o.line) ? o.line : 0, `${o.rule_key ?? 'unknown'}#${n}`))) n += 1; o.id = id; } if (typeof o.id === 'string') takenIds.add(o.id); fresh.push(o); } const carried = []; const resolved = []; for (const p of prevFindings) { const matched = matchedTo.get(p); if (matched.length === 0 && isResolved(p, markList)) { resolved.push(p.id); continue; } carried.push(carry(p, matched, d, current, prevRound)); } const out = [...carried, ...fresh]; const lowered = out.filter((x) => { const o = normalizeSeverity(x.original_severity); return o !== null && rankOf(o) < rankOf(x.severity); }).length; return { findings: out, resolved: ruleA ? resolved : null, diffBase: ruleA ? base.sha256 : null, diffNote, lowered, }; }