fix(review): the critic-loop matcher needs overlapping texts, and a new finding never takes an old id

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-16 16:29:58 +02:00
commit 30ca7078e9
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
2 changed files with 187 additions and 14 deletions

View file

@ -31,10 +31,14 @@
// 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 (same id, its mapped
// line, or the same wording in the same file class); 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.
// 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 `<rule_key>#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
@ -53,6 +57,7 @@ import {
normalizeSeverity, contentOverlap, contentTokens, DEFAULT_THRESHOLD, ANCHOR_MIN_CONTENT_WORDS, SEVERITIES,
} from './plan-review-dedup.mjs';
import { getRule } from './rule-catalogue.mjs';
import { computeFindingId } from '../parsers/finding-id.mjs';
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.';
@ -303,19 +308,25 @@ function placeOf(f, fullToOp, planSet) {
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 (typeof f.id === 'string' && f.id === p.id) return true;
if (contentOverlap(f.text, p.text) < DEFAULT_THRESHOLD) return false;
if (atSpot(pl, p, d)) return true;
const pCites = hasOwn(p, 'op_line');
if (pl && pCites && Number.isInteger(p.op_line)) {
const n = d.oldToNew[p.op_line] ?? null;
if (n !== null && n >= pl.start && n <= pl.end) return true;
}
// 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;
if (contentSize(f.text) < ANCHOR_MIN_CONTENT_WORDS || contentSize(p.text) < ANCHOR_MIN_CONTENT_WORDS) return false;
return contentOverlap(f.text, p.text) >= DEFAULT_THRESHOLD;
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. */
@ -427,6 +438,8 @@ export function countRound({ round, previous, base, baseNote, current, planPaths
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 `<rule_key>#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);
@ -454,6 +467,13 @@ export function countRound({ round, previous, base, baseNote, current, planPaths
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);
}