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:
parent
a09e6c96a1
commit
3cb3958238
6 changed files with 438 additions and 7 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue