fix(review): a dropped engine finding keeps the gate not measured, round 1 included

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-11-03 11:57:30 +01:00
commit 3cb3958238
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
6 changed files with 438 additions and 7 deletions

View file

@ -41,7 +41,13 @@
// coordinator-contract `NOTED_REASONS`) is exit 2 as well (reason
// `a policy or filter moved a BLOCKER, MAJOR or security finding`;
// `movedSerious`): a policy or filter only ever moves MINOR and
// SUGGESTION findings.
// SUGGESTION findings. An ALLOW/WARN review is exit 2 too when an
// engine finding at MAJOR or BLOCKER in a Phase 4.7 payload beside it
// (`.review-engine-payload-*.json`, read by review-finalize.mjs
// `roundEvidence`) is missing from it (reason `review-not-measured:
// 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.
// Exit 2 is NEVER green.
//
// The pure `decideVerdict` stays text-only: it never reads the answers. The
@ -73,6 +79,9 @@ import { resolveRepo } from '../util/approval-log.mjs';
import { answerRecords, archivedReader, decideAnswers, isSecurityFinding, reviewSubject } from './review-answers.mjs';
import { preExistingCheck, scopeShas } from './pre-existing.mjs';
import { frontmatterField, runId } from './review-rounds.mjs';
// No cycle: neither module, nor any module they import, imports this gate.
import { roundEvidence } from './review-finalize.mjs';
import { putBack, normTitle } from './engine-compare.mjs';
export const EXIT = Object.freeze({ PASS: 0, BLOCK: 1, UNDECIDED: 2 });
@ -200,6 +209,7 @@ function blockerSectionEntries(body) {
const NOT_FINALIZED = 'review-not-measured: round 2+ review not finalized';
const MOVED_SERIOUS = 'a policy or filter moved a BLOCKER, MAJOR or security finding';
const DROPPED_ENGINE = 'review-not-measured: engine finding not in review';
const NOTED_SEVERITIES = new Set(['MINOR', 'SUGGESTION']);
/** One field of a declined entry for the reason line: one line, at most 120 chars. */
@ -249,6 +259,44 @@ export function unfinalizedRound(text, reviewPath) {
return names.some((n) => archive.test(n)) ? NOT_FINALIZED : null;
}
/** The trailing JSON block as decideVerdict reads it (the last fenced block of the body), or null. */
function trailingPayload(text) {
const body = validateReviewContent(text, { strict: false }).parsed?.body ?? '';
const last = fencedBlocks(body).at(-1);
if (!last || !last.closed || !isJsonBlock(last)) return null;
try { return JSON.parse(last.body); } catch { return null; }
}
/** Same place and title: the fields a carry fold keeps from the current finding. */
const samePlace = (c, e) => typeof c.title === 'string' && typeof e.title === 'string'
&& c.file === e.file && String(c.line) === String(e.line) && normTitle(c) === normTitle(e);
/**
* The engine findings at MAJOR or BLOCKER in the Phase 4.7 payloads beside the
* review (review-finalize.mjs `roundEvidence`: each regular
* `.review-engine-payload-*.json` whose `reviewer` maps back through ENGINES)
* that review.md does not hold: engine-compare.mjs `putBack` against the
* trailing JSON block (an unreadable block holds nothing, so each such engine
* finding is missing: the stricter side), without each one a
* finding with `carried_from` matches on file, line and `normTitle` (a carry
* fold can swap in a previous security `rule_key`, review-rounds.mjs `fold`,
* so the row alone would miss it). A finalized review holds every put-back
* on its row, so it gives none. An engine finding returned on its own row at
* a lower severity is not caught: only a missing one is.
* @param {string} text - review.md
* @param {string|undefined} reviewPath
* @returns {object[]} the missing engine findings, as putBack copies them
*/
export function droppedEngineFindings(text, reviewPath) {
if (!reviewPath) return [];
const { payloads } = roundEvidence(dirname(reviewPath));
if (payloads.length === 0) return [];
const review = trailingPayload(String(text ?? ''));
const carried = (Array.isArray(review?.findings) ? review.findings : [])
.filter((f) => f !== null && typeof f === 'object' && typeof f.carried_from === 'string' && f.carried_from !== '');
return putBack(review, payloads).added.filter((e) => !carried.some((c) => samePlace(c, e)));
}
/**
* The open-findings check: every counting MAJOR of an ALLOW/WARN review must be
* answered (lib/review/review-answers.mjs `decideAnswers`). K2 records come
@ -262,8 +310,9 @@ export function unfinalizedRound(text, reviewPath) {
* against its archive beside the review file (`archivedReader`); with no
* review path no carried row answers. A finding with `carried_from` is never
* pre-existing: it keeps its line from the previous round's end. A round 2+
* review that was not finalized (`unfinalizedRound`) is exit 2 first, then a
* BLOCKER, MAJOR or security finding moved by a policy or filter (`movedSerious`).
* review that was not finalized (`unfinalizedRound`) is exit 2 first, then an
* engine MAJOR or BLOCKER missing from the review (`droppedEngineFindings`),
* then a BLOCKER, MAJOR or security finding moved by a policy or filter (`movedSerious`).
* @param {string} text - review.md
* @param {{ repo?: string, reviewPath: string }} opts
* @returns {{ exit: 0|2, reason: string, unanswered: string[], fixed_pending: string[] }}
@ -271,6 +320,15 @@ export function unfinalizedRound(text, reviewPath) {
export function decideOpenFindings(text, { repo, reviewPath } = {}) {
const notFinal = unfinalizedRound(text, reviewPath);
if (notFinal) return { exit: EXIT.UNDECIDED, reason: notFinal, unanswered: [], fixed_pending: [] };
const dropped = droppedEngineFindings(text, reviewPath);
if (dropped.length > 0) {
return {
exit: EXIT.UNDECIDED,
reason: `${DROPPED_ENGINE}: ${dropped.map((f) => `${field(f.severity) ?? '(no severity)'} ${field(f.rule_key) ?? '(no rule_key)'} at ${field(f.file) ?? '?'}:${field(f.line) ?? '?'}`).join(', ')}`,
unanswered: [],
fixed_pending: [],
};
}
const hidden = movedSerious(text);
if (hidden.length > 0) {
return {