fix(review): the verdict gate refuses duplicate finding ids

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-11-04 09:40:25 +01:00
commit fc252f8d56
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
3 changed files with 226 additions and 0 deletions

View file

@ -339,6 +339,12 @@ export function decideAnswers(text, { records = [], reviewSubject, preExisting =
if (!payload) {
return { ok: false, unanswered: [], fixedPending: [], reason: 'review-not-measured: no readable trailing JSON block' };
}
// One id twice would let one bound answer clear two MAJORs (fail closed).
const ids = (Array.isArray(payload.findings) ? payload.findings : []).filter((f) => f && typeof f === 'object').map((f) => f.id);
const dup = ids.find((id, i) => ids.indexOf(id) !== i);
if (dup !== undefined) {
return { ok: false, unanswered: [], fixedPending: [], reason: `review-not-measured: duplicate finding id ${dup}` };
}
const majors = countingMajors(payload, { preExisting });
if (majors.length === 0) return { ok: true };
const { rows } = parseAnswers(bodyOf(text));
@ -498,6 +504,9 @@ export function recordAnswer({ reviewPath, finding, answer, repo, round, store,
try { text = readFileSync(reviewPath, 'utf8'); } catch (e) { return cannot(`cannot read ${reviewPath}: ${e.message}`); }
const payload = payloadOf(text);
if (!payload || !Array.isArray(payload.findings)) return cannot(`${reviewPath} has no readable trailing JSON block`);
const ids = payload.findings.filter((x) => x && typeof x === 'object').map((x) => x.id);
const dup = ids.find((x, i) => ids.indexOf(x) !== i);
if (dup !== undefined) return cannot(`${reviewPath} holds a duplicate finding id ${dup}; nothing appended`);
const f = payload.findings.find((x) => x && x.id === id);
if (f && isBlocker(f)) return refuse(`${id} is a BLOCKER: a BLOCKER is never answered, it is fixed`);
if (!f || !countingMajors(payload).some((x) => x.id === id)) return refuse(`${id} is not a counting MAJOR of this review`);

View file

@ -48,6 +48,9 @@
// engine finding not in review: <severity> <rule_key> at
// <file>:<line>[, …]`; `droppedEngineFindings`): finalize's put-back
// did not run, or `finalized: true` was written by hand.
// An ALLOW/WARN review that holds one finding id twice, in the
// frontmatter `findings:` list or in the trailing JSON block, is exit
// 2 (reason `duplicate finding id <id> in <where>`).
// Exit 2 is NEVER green.
//
// The pure `decideVerdict` stays text-only: it never reads the answers. The
@ -159,6 +162,13 @@ export function decideVerdict(text) {
if (illegal.length > 0) {
return undecided(`verdict ${verdict} over a brief-conflict lowering outside the one legal shape (finding ${illegal.join(', ')})`, verdict);
}
// One id twice would let one bound answer clear two MAJORs: not measured.
const fmList = Array.isArray(fm.findings) ? fm.findings : [];
const fmDup = fmList.find((id, i) => fmList.indexOf(id) !== i);
if (fmDup !== undefined) return undecided(`duplicate finding id ${fmDup} in the frontmatter findings: list`, verdict);
const jsonList = payload.findings.map((f) => f.id);
const jsonDup = jsonList.find((id, i) => jsonList.indexOf(id) !== i);
if (jsonDup !== undefined) return undecided(`duplicate finding id ${jsonDup} in the trailing JSON block`, verdict);
const fmIds = new Set(Array.isArray(fm.findings) ? fm.findings : []);
const jsonIds = new Set(payload.findings.map((f) => f.id));
if (fmIds.size !== jsonIds.size || [...fmIds].some((id) => !jsonIds.has(id))) {