fix(review): group a triplet's findings title first, so arrival order never changes the rows

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-23 18:30:52 +02:00
commit a5fcb5e8b4
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
6 changed files with 339 additions and 37 deletions

View file

@ -19,7 +19,7 @@
//
// What IS implemented, purely: Pass 1 (triplet dedup → survivor ranked by
// catalogue tier, then reviewer over engine, asserted severity, conformance,
// first seen; the survivor carries its group's highest asserted severity
// first in the group's order; the survivor carries its group's highest asserted severity
// (review bc7f4e23) + detail concat + raised_by provenance; when that raises
// it, `pass1_raised_from` keeps what the survivor itself asserted, read by
// Pass 3 and never returned: `original_severity` is what the kept finding
@ -254,9 +254,10 @@ export function ingest(reviewerPayloads) {
* (1) the catalogue tier (`getRule(rule_key)?.severity`, else the finding's own
* severity; lower `severityRank` wins), (2) a reviewer-agent finding over an
* engine finding, (3) among findings of one kind the higher asserted severity,
* (4) the conformance reviewer, (5) first seen. It is written with the highest
* severity any finding in its group asserted, so dropping a duplicate never
* lowers the group (review bc7f4e23; plan 2026-10-11-files-backticks-trial4,
* (4) the conformance reviewer, (5) the first in the group's order (the order
* the two passes below place the members in, never the arrival order). It is
* written with the highest severity any finding in its group asserted, so
* dropping a duplicate never lowers the group (review bc7f4e23; plan 2026-10-11-files-backticks-trial4,
* ADR "Pass 1 survivor order and group severity"). Carries raised_by
* provenance, concatenates other reviewers' attribution into detail, and
* recomputes the id over the triplet.
@ -267,23 +268,34 @@ export function dedupByTriplet(findings) {
// One reviewer never duplicates itself: its second finding on a triplet under
// another title is another finding and opens a group of its own, which gets
// its own row and the id over `rule_key#2` (voyage-lekerepo trial 5,
// finding 7). Another reviewer's finding joins the group whose title it
// shares, else the first one it can join.
// finding 7). Inside one triplet the findings are grouped in two passes, so
// the order the reviewers' findings arrive in never changes which row a
// finding joins (plan 2026-10-20-fix-round-serious-findings, ADR "ORD: two
// passes over the triplet, title first"): (1) every finding with a title
// joins the class of its normalized title, over all of the triplet's
// findings; (2) the title classes, in alphabetical order of the normalized
// title, each join the first earlier group whose every member fits every
// finding of the class (another reviewer, or the same title), else they form
// a group of their own; findings without a title come last, in canonical
// order (reviewer, detail, the whole finding), each joining the first group
// it fits. Members inside a group keep the order the passes place them in:
// the classes alphabetically, each class's findings in canonical order, the
// untitled findings last in canonical order. Rank (5) of the survivor reads
// that order, so a full tie never follows arrival order either: the survivor
// decides Pass 2's actionability and Pass 3's brief-conflict lowering, and so
// the verdict. A triplet's groups keep the order of their earliest input
// index, so the rows and `#k` ids stay in input order.
const tripletGroups = new Map();
for (const f of findings) {
findings.forEach((f, i) => {
const key = tripletKey(f);
if (!tripletGroups.has(key)) tripletGroups.set(key, []);
const subs = tripletGroups.get(key);
const fits = (g) => g.every((m) => m.reviewer !== f.reviewer || sameTitle(m, f));
let sub = subs.find((g) => fits(g) && g.some((m) => sameTitle(m, f) && m.title !== undefined && f.title !== undefined))
?? subs.find(fits);
if (!sub) {
sub = [];
subs.push(sub);
}
sub.push(f);
}
const groups = [...tripletGroups.values()].flat();
tripletGroups.get(key).push({ f, i });
});
const groups = [...tripletGroups.values()].flatMap((entries) => groupTriplet(
entries,
(a, b) => a.reviewer !== b.reviewer || sameTitle(a, b),
(f) => f.reviewer,
));
const out = [];
for (const group of groups) {
// Ranked as the JSDoc above says. A brief-conflict mark is not carried over
@ -346,6 +358,56 @@ function sameTitle(a, b) {
return String(a.title).trim().toLowerCase() === String(b.title).trim().toLowerCase();
}
/** Code-unit order of two strings (never the locale's). */
function cmpStr(a, b) {
return a < b ? -1 : a > b ? 1 : 0;
}
/**
* The groups of one triplet's findings, in the two passes `dedupByTriplet`
* describes. `entries` are `{ f, i }` (the finding and its input index);
* `fits(m, f)` says member `m` may share a group with `f`; `whoOf(f)` names the
* reviewer for the canonical order. Returns arrays of findings, each in the
* order the passes place them (classes alphabetically, each class in canonical
* order, untitled findings last in canonical order), the groups in the order
* of their earliest input index.
* @param {Array<{f: object, i: number}>} entries
* @param {(m: object, f: object) => boolean} fits
* @param {(f: object) => unknown} whoOf
* @returns {object[][]}
*/
function groupTriplet(entries, fits, whoOf) {
const canon = (x, y) => cmpStr(String(whoOf(x.f) ?? ''), String(whoOf(y.f) ?? ''))
|| cmpStr(String(x.f.detail ?? ''), String(y.f.detail ?? ''))
|| cmpStr(JSON.stringify(x.f), JSON.stringify(y.f));
const classes = new Map();
const untitled = [];
for (const e of entries) {
if (e.f.title === undefined) {
untitled.push(e);
continue;
}
const t = String(e.f.title).trim().toLowerCase();
if (!classes.has(t)) classes.set(t, []);
classes.get(t).push(e);
}
const groups = [];
const place = (members) => {
let g = groups.find((grp) => grp.every((m) => members.every((e) => fits(m.f, e.f))));
if (!g) {
g = [];
groups.push(g);
}
g.push(...members);
};
for (const t of [...classes.keys()].sort(cmpStr)) place(classes.get(t).sort(canon));
for (const e of untitled.sort(canon)) place([e]);
return groups
.map((g) => ({ first: Math.min(...g.map((e) => e.i)), members: g.map((e) => e.f) }))
.sort((a, b) => a.first - b.first)
.map((g) => g.members);
}
/**
* The tiers whose over-long text may be trimmed. Length is packaging, never a
* claim: a BLOCKER or MAJOR keeps every character, and nothing is ever removed