fix(review): one reviewer's two findings on one triplet keep a row each
Pass 1 merged two different /code-review findings on (bin/slug.mjs, 46, CODE_REVIEW_DEFECT) and the second lost its row and its action. A reviewer never duplicates itself: its second finding on a triplet under another title stays its own row, with the id over rule_key#2 (the form critic-diff already uses); fill-finding-ids counts the same way (voyage-lekerepo trial 5, finding 7). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
16d1bb7dd5
commit
b47cabdf30
6 changed files with 112 additions and 23 deletions
|
|
@ -1,7 +1,8 @@
|
|||
// lib/parsers/finding-id.mjs
|
||||
// Stable finding-ID for /trekreview v1.0.
|
||||
//
|
||||
// id = sha1(file:line:rule_key) → 40-char hex.
|
||||
// id = sha1(file:line:rule_key) → 40-char hex; a second finding on the same
|
||||
// triplet gets it over `rule_key#2` (computeFindingIds).
|
||||
// Same input always produces same output (determinism floor SC4).
|
||||
// node:crypto is built-in (zero-deps invariant).
|
||||
|
||||
|
|
@ -43,6 +44,36 @@ export function computeFindingId(filePath, line, ruleKey) {
|
|||
return createHash('sha1').update(composite).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* The ids of a list of findings, in order. The first finding on a
|
||||
* (file, line, rule_key) gets computeFindingId of it, as always; the k-th
|
||||
* (k >= 2) gets it over `${rule_key}#k`, the form critic-diff.mjs already uses,
|
||||
* so two different findings one reviewer put on one triplet keep a row each
|
||||
* (voyage-lekerepo trial 5, finding 7).
|
||||
* @param {Array<{file: string, line: number|string, rule_key: string}>} findings
|
||||
* @param {(index: number, error: Error) => void} [onError] - when given, a
|
||||
* finding whose id cannot be computed gets null and is reported here;
|
||||
* without it the TypeError is thrown
|
||||
* @returns {Array<string|null>} one id per finding
|
||||
*/
|
||||
export function computeFindingIds(findings, onError) {
|
||||
const seen = new Map();
|
||||
return findings.map((f, i) => {
|
||||
const key = `${f?.file}:${f?.line}:${f?.rule_key}`;
|
||||
const k = (seen.get(key) ?? 0) + 1;
|
||||
seen.set(key, k);
|
||||
try {
|
||||
// A missing rule_key must still throw, never become "undefined#2".
|
||||
const ruleKey = k === 1 || typeof f?.rule_key !== 'string' || f.rule_key === '' ? f?.rule_key : `${f.rule_key}#${k}`;
|
||||
return computeFindingId(f?.file, f?.line, ruleKey);
|
||||
} catch (e) {
|
||||
if (!onError) throw e;
|
||||
onError(i, e);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a finding-ID's shape (40-char lowercase hex).
|
||||
* @param {string} id
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@
|
|||
// contract being mirrored, so only the deterministic "recommended_action is
|
||||
// present-and-non-empty when supplied" half is kept.
|
||||
// - Nothing about ids beyond the triplet: `computeFindingId(file, line,
|
||||
// rule_key)` is the one id. The LLM coordinator writes `@@ID_<n>@@`
|
||||
// rule_key)` is the one id, over `rule_key#k` for the k-th row on one
|
||||
// triplet (computeFindingIds). The LLM coordinator writes `@@ID_<n>@@`
|
||||
// placeholders and lib/review/fill-finding-ids.mjs computes them (the
|
||||
// old doc named a 4-tuple with `title` the code never computed).
|
||||
//
|
||||
|
|
@ -32,13 +33,13 @@
|
|||
// classifySuppression). No LLM, no network, no time, no randomness.
|
||||
//
|
||||
// Reuses: SEVERITY_VALUES / RULE_KEYS / getRule (rule-catalogue.mjs),
|
||||
// computeFindingId (finding-id.mjs, triplet), validateFindings
|
||||
// computeFindingIds (finding-id.mjs, triplet), validateFindings
|
||||
// (findings-schema.mjs). Triplet key format mirrors
|
||||
// scripts/bakeoff-armA-merge.mjs `dedupByTriplet`; raised_by provenance mirrors
|
||||
// lib/review/plan-review-dedup.mjs.
|
||||
|
||||
import { SEVERITY_VALUES, RULE_KEYS, getRule } from './rule-catalogue.mjs';
|
||||
import { computeFindingId } from '../parsers/finding-id.mjs';
|
||||
import { computeFindingIds } from '../parsers/finding-id.mjs';
|
||||
import { validateFindings } from './findings-schema.mjs';
|
||||
import { ENGINES } from './engine-findings.mjs';
|
||||
import { isMain } from '../util/is-main.mjs';
|
||||
|
|
@ -259,14 +260,28 @@ export function ingest(reviewerPayloads) {
|
|||
* @returns {object[]}
|
||||
*/
|
||||
export function dedupByTriplet(findings) {
|
||||
const groups = new Map();
|
||||
// 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.
|
||||
const tripletGroups = new Map();
|
||||
for (const f of findings) {
|
||||
const key = tripletKey(f);
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(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();
|
||||
const out = [];
|
||||
for (const group of groups.values()) {
|
||||
for (const group of groups) {
|
||||
// Ranked as the JSDoc above says. A brief-conflict mark is not carried over
|
||||
// from a group member to the survivor: when the survivor is unmarked, the
|
||||
// group stays unmarked and is judged at its full severity. For a catalogue
|
||||
|
|
@ -307,17 +322,24 @@ export function dedupByTriplet(findings) {
|
|||
detail += `\nAlso flagged by ${o.reviewer ?? 'unknown'}: ${o.title ?? o.rule_key}.`;
|
||||
}
|
||||
}
|
||||
const id = computeFindingId(survivor.file, survivor.line, survivor.rule_key);
|
||||
// When the group raised the survivor, Pass 3 still needs what the survivor
|
||||
// itself asserted for `original_severity` (review 08deae68).
|
||||
const raised = severityRank(severity) < severityRank(survivor.severity);
|
||||
out.push({
|
||||
...survivor, ...(severity !== undefined ? { severity } : {}), id,
|
||||
...survivor, ...(severity !== undefined ? { severity } : {}),
|
||||
...(detail !== undefined ? { detail } : {}), raised_by,
|
||||
...(raised ? { pass1_raised_from: survivor.severity } : {}),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
// The groups of one triplet sit next to each other, so the k-th row on a
|
||||
// triplet here is the k-th in review.md, where fill-finding-ids counts the same way.
|
||||
const ids = computeFindingIds(out);
|
||||
return out.map((f, i) => ({ ...f, id: ids[i] }));
|
||||
}
|
||||
|
||||
function sameTitle(a, b) {
|
||||
if (a.title === undefined || b.title === undefined) return true;
|
||||
return String(a.title).trim().toLowerCase() === String(b.title).trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@
|
|||
// step no document named. Now the contract says it: the coordinator writes
|
||||
// `@@ID_<n>@@` wherever finding n's id belongs (n = 1-based position in the
|
||||
// trailing JSON block's `findings` array), and this tool replaces every
|
||||
// placeholder with computeFindingId(file, line, rule_key) of that finding.
|
||||
// placeholder with computeFindingId(file, line, rule_key) of that finding
|
||||
// (`rule_key#k` for the k-th finding on one triplet: computeFindingIds).
|
||||
//
|
||||
// Fail-closed: a placeholder with no JSON finding behind it, or a 40-hex id
|
||||
// in the JSON block that is not the computed one, is an error -- never a guess.
|
||||
|
|
@ -19,7 +20,7 @@
|
|||
// 2 = could not run (unreadable file, no JSON block).
|
||||
|
||||
import { readFileSync, writeFileSync, renameSync } from 'node:fs';
|
||||
import { computeFindingId } from '../parsers/finding-id.mjs';
|
||||
import { computeFindingIds } from '../parsers/finding-id.mjs';
|
||||
import { extractFindingsBlock } from './findings-schema.mjs';
|
||||
import { isMain } from '../util/is-main.mjs';
|
||||
|
||||
|
|
@ -41,13 +42,8 @@ export function fillFindingIds(text) {
|
|||
}
|
||||
const findings = Array.isArray(parsed?.findings) ? parsed.findings : [];
|
||||
const errors = [];
|
||||
const ids = findings.map((f, i) => {
|
||||
try {
|
||||
return computeFindingId(f.file, f.line, f.rule_key);
|
||||
} catch (e) {
|
||||
errors.push(`findings[${i}]: cannot compute an id (${e.message})`);
|
||||
return null;
|
||||
}
|
||||
const ids = computeFindingIds(findings, (i, e) => {
|
||||
errors.push(`findings[${i}]: cannot compute an id (${e.message})`);
|
||||
});
|
||||
findings.forEach((f, i) => {
|
||||
if (typeof f.id === 'string' && HEX40.test(f.id) && ids[i] && f.id !== ids[i]) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue