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:
parent
7175783ce5
commit
a5fcb5e8b4
6 changed files with 339 additions and 37 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue