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:
parent
bb3884d0f4
commit
30ca7078e9
2 changed files with 187 additions and 14 deletions
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue