Quality only, no behaviour change (four cleanup reviewers, one implementer; npm test 2480/0 on the working tree): - threat-model.mjs splitBacked(): the one known_outside backing check of plan-gate (record and bare gate), verdict-gate and review-finalize, with one why-text; the engines' security keys derived from ENGINES; isSecurity. - coordinator-contract: "not a security engine" read from ENGINES (a string engine only), not the code-review literal. - review-rounds owns trailingBlock, findingsBlockSha, isMajor, isBlocker; review-answers imports them (review-rounds still imports nothing from it). prepare keeps the archive bytes it read instead of reading the previous archive again. - engine-compare exports rowIn and payloadEngine; finalize uses them. - plan-gate: one accepted_open builder; the stop-rule check hoisted; one top-level lookup for --record --brief. - security-engine: notFullShas(), runCli's usage flag, readScopeList shared with engine-findings; findReport and reviewRoundSummary module-private; two dead defaults gone. - tests: shared plan-gate harness and threat-model brief helpers; the [CNV] table cases call convertReport in-process, one CLI case per exit path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
91 lines
5 KiB
JavaScript
91 lines
5 KiB
JavaScript
// lib/review/engine-compare.mjs
|
|
// The standard-path put-back (fix round 4.5b2 part B, Step 9): on the prose
|
|
// path, an engine finding at MAJOR or BLOCKER that the coordinator dropped is
|
|
// put back at its own severity (operator decision 23).
|
|
//
|
|
// A finding is matched on its row: its (file, line, rule_key) triplet plus
|
|
// its title, trimmed and in lower case. An engine finding's row is returned
|
|
// when a finding of review.md carries its key, or the key of its title as the
|
|
// coordinator trims it, so a trimmed title puts back no copy. Each row not
|
|
// returned is put back once, its highest severity first (on a tie, the first
|
|
// seen), with `engine` taken from the payload it came from (the `reviewer`
|
|
// field mapped back through ENGINES, never the finding's own label) and no id
|
|
// (lib/review/review-finalize.mjs recomputes every id). The verdict is only
|
|
// raised: a BLOCKER gives BLOCK, a MAJOR at least WARN.
|
|
//
|
|
// Only a payload's `findings` are compared. An engine finding in
|
|
// `out_of_scope` stays declined by the existing rule
|
|
// (lib/review/coordinator-contract.mjs runContract, `engine:out-of-scope`),
|
|
// and so does a Claude Security finding in `below_threshold` (confidence
|
|
// below `high`, lib/review/security-engine.mjs; `engine:below-threshold`,
|
|
// decision 219): neither is ever put back. Claude Security's payload
|
|
// (`reviewer` `claude-security`) maps back through ENGINES like the others.
|
|
|
|
import { ENGINES } from './engine-findings.mjs';
|
|
import { JUDGE_TITLE_MAX, trimText } from './coordinator-contract.mjs';
|
|
|
|
// The (file,line,rule_key) triplet a finding is deduped and matched on.
|
|
const tripletOf = (f) => `${f.file}\u0000${f.line}\u0000${f.rule_key}`;
|
|
// A finding's title as the grouping compares it: trimmed, lower case.
|
|
const normTitle = (f) => String(f.title).trim().toLowerCase();
|
|
// The row a finding is matched to after the coordinator: its triplet plus its
|
|
// `normTitle`. An untitled finding gets a key no titled row has.
|
|
const rowOf = (f) => `${tripletOf(f)}\u0000${f.title === undefined ? '\u0001' : normTitle(f)}`;
|
|
// A title as lib/review/coordinator-contract.mjs judgeFilter trims an
|
|
// over-long MINOR/SUGGESTION title: over JUDGE_TITLE_MAX characters it is
|
|
// trimText's cut; any other title unchanged.
|
|
const trimmedTitle = (t) => (t.length > JUDGE_TITLE_MAX ? trimText(t, JUDGE_TITLE_MAX) : t);
|
|
|
|
export { tripletOf, normTitle, rowOf, trimmedTitle };
|
|
|
|
/** A finding's row is in `rows` (a Set of rowOf keys): as it is, or with its title as the coordinator trims it. */
|
|
export const rowIn = (rows, f) => rows.has(rowOf(f)) ||
|
|
(typeof f.title === 'string' && rows.has(rowOf({ ...f, title: trimmedTitle(f.title) })));
|
|
|
|
const PUT_BACK_SEVERITIES = ['BLOCKER', 'MAJOR'];
|
|
const VERDICT_RANK = { ALLOW: 0, WARN: 1, BLOCK: 2 };
|
|
export const ENGINE_OF_REVIEWER = Object.freeze(Object.fromEntries(Object.entries(ENGINES).map(([engine, s]) => [s.reviewer, engine])));
|
|
const isPlainObject = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
|
|
|
|
/** The engine a payload's top-level `reviewer` maps back to through ENGINES, or null. */
|
|
export const payloadEngine = (p) => (isPlainObject(p) && typeof p.reviewer === 'string' && Object.hasOwn(ENGINE_OF_REVIEWER, p.reviewer)
|
|
? ENGINE_OF_REVIEWER[p.reviewer] : null);
|
|
|
|
/**
|
|
* The engine findings at MAJOR or BLOCKER whose row no finding of `payload`
|
|
* carries, as copies to append, and the verdict raised by them.
|
|
* @param {{ verdict?: string, findings: object[] }} payload - review.md's trailing JSON block
|
|
* @param {Array<{ reviewer: string, findings: object[], out_of_scope?: object[] }>} enginePayloads
|
|
* @returns {{ added: object[], raisedVerdict: string|undefined }}
|
|
*/
|
|
export function putBack(payload, enginePayloads) {
|
|
const returned = (Array.isArray(payload?.findings) ? payload.findings : []).filter(isPlainObject);
|
|
const rows = new Set(returned.map(rowOf));
|
|
const candidates = [];
|
|
for (const p of Array.isArray(enginePayloads) ? enginePayloads : []) {
|
|
if (!isPlainObject(p) || !Array.isArray(p.findings)) continue;
|
|
const engine = payloadEngine(p);
|
|
if (!engine) continue;
|
|
for (const f of p.findings) {
|
|
if (!isPlainObject(f) || !PUT_BACK_SEVERITIES.includes(f.severity)) continue;
|
|
candidates.push({ f, engine, rank: PUT_BACK_SEVERITIES.indexOf(f.severity) });
|
|
}
|
|
}
|
|
// Highest severity first; Array.prototype.sort is stable, so a tie keeps the first seen.
|
|
candidates.sort((a, b) => a.rank - b.rank);
|
|
const added = [];
|
|
for (const { f, engine } of candidates) {
|
|
if (rowIn(rows, f)) continue;
|
|
const { id: _id, brief_lowered_from: _lowered, contradicts_brief: _contradicts, pre_existing: _pre, carried_from: _cf, ...rest } = f;
|
|
const copy = { id: null, ...rest, engine };
|
|
added.push(copy);
|
|
rows.add(rowOf(copy));
|
|
}
|
|
const current = payload?.verdict;
|
|
let raised = current;
|
|
if (Object.hasOwn(VERDICT_RANK, current)) {
|
|
const need = added.some((f) => f.severity === 'BLOCKER') ? 'BLOCK' : added.length > 0 ? 'WARN' : 'ALLOW';
|
|
if (VERDICT_RANK[need] > VERDICT_RANK[current]) raised = need;
|
|
}
|
|
return { added, raisedVerdict: raised };
|
|
}
|