refactor(review): simplify pass over E (reuse, simplification, efficiency)

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>
This commit is contained in:
Kjell Tore Guttormsen 2026-11-08 21:27:59 +01:00
commit f820081bdc
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
18 changed files with 332 additions and 373 deletions

View file

@ -148,8 +148,9 @@ import { splitSections, fenceTracker, extractPlanVersion, versionAtLeast } from
import { normalizeSeverity, isSecurity } from '../review/plan-review-dedup.mjs';
import { countRound, lineDiff, oneLine } from '../review/critic-diff.mjs';
import { roundSummary, decideStop, describeStop, STOP_REASONS } from '../review/stop-rule.mjs';
import { usableInRepo, repoRelativeBrief, backedLine } from '../review/threat-model.mjs';
import { usableInRepo, repoRelativeBrief, splitBacked } from '../review/threat-model.mjs';
import { resolveRepo } from '../verification/repo-tools.mjs';
import { resolveRepo as resolveTopLevel } from '../util/approval-log.mjs';
import { isMain } from '../util/is-main.mjs';
export const MAX_ROUNDS = 3;
@ -410,19 +411,15 @@ export function recordVerdict(text, dedup, opts = {}) {
// known_outside; a copy rule A carries from an earlier round stays counted
// until a reviewer marks it resolved (decision 221).
const use = opts.threatModel;
const tm = use && use.usable === true ? use.threatModel : null;
const backed = [];
const counted = [];
for (const f of dedup.findings) {
const line = tm ? backedLine(f, tm) : null;
if (line === null) { counted.push(f); continue; }
// rule_keys kept, so the bare gate re-checks the entry with the same keys (keysOf).
backed.push({
id: f.id ?? null, rule_key: f.rule_key ?? null,
...(Array.isArray(f.rule_keys) ? { rule_keys: f.rule_keys.filter(k => typeof k === 'string') } : {}),
text: typeof f.text === 'string' ? f.text : '', line,
});
}
const split = splitBacked(dedup.findings, use);
const counted = split.unbacked;
// rule_keys kept, so the bare gate re-checks the entry with the same keys
// (keysOf); line = the `Outside:` text, which a backed threat_model_ref equals.
const backed = split.backed.map(f => ({
id: f.id ?? null, rule_key: f.rule_key ?? null,
...(Array.isArray(f.rule_keys) ? { rule_keys: f.rule_keys.filter(k => typeof k === 'string') } : {}),
text: typeof f.text === 'string' ? f.text : '', line: f.threat_model_ref,
}));
const backedIds = new Set(backed.map(e => e.id).filter(id => id !== null));
const c = countRound({
round,
@ -506,32 +503,34 @@ export function acceptOpen(rawText, opts = {}) {
const ex = extractVerdict(text);
if (!ex.found || ex.error) return { code: 2, message: `no readable verdict: ${ex.error ?? 'no ## Critic Verdict'}` };
const v = ex.verdict;
const fired = v.stop_rule?.fired === true;
if (v.plan_sha256 !== planHash(text)) {
return {
code: 1,
message: v.stop_rule?.fired === true
message: fired
? 'the verdict is stale: the plan changed after the last round, and the stop rule fired, so there is no next round; restore the reviewed text to accept, or stop and take the direction to the operator'
: 'the verdict is stale: the plan changed after the last round; run the next round instead',
};
}
if (v.stop_rule?.fired === true) {
// The verdict with `accepted_open` naming `list`, bound to this round and plan text.
const accept = (list, under) => ({
...v,
accepted_open: {
round: v.round,
plan_sha256: v.plan_sha256,
accepted_at: opts.now ?? new Date().toISOString(),
...(under ? { under } : {}),
findings: list.map(m => ({ id: m.id ?? null, text: m.text ?? '' })),
},
});
if (fired) {
const open = v.findings.filter(f => sevOf(f) !== 'minor');
if (open.length === 0) return { code: 1, message: 'no BLOCKER or MAJOR is open; nothing to accept' };
const security = open.filter(isSecurity);
if (security.length > 0) {
return { code: 1, message: `PLAN_GATE_SECURITY_OPEN: ${security.length} open security finding(s) count and are never accepted: ${security.map(s => s.text).join(' | ')}` };
}
const accepted = {
...v,
accepted_open: {
round: v.round,
plan_sha256: v.plan_sha256,
accepted_at: opts.now ?? new Date().toISOString(),
under: 'stop-rule',
findings: open.map(m => ({ id: m.id ?? null, text: m.text ?? '' })),
},
};
return { code: 0, text: placeSection(text, renderSection(accepted)), message: `${open.length} open finding(s) of round ${v.round} accepted as known issues under the stop rule` };
return { code: 0, text: placeSection(text, renderSection(accept(open, 'stop-rule'))), message: `${open.length} open finding(s) of round ${v.round} accepted as known issues under the stop rule` };
}
if (v.findings.some(f => sevOf(f) !== 'major' && sevOf(f) !== 'minor')) return { code: 1, message: 'a BLOCKER is open; a blocker is never accepted' };
const majors = v.findings.filter(f => sevOf(f) === 'major');
@ -540,16 +539,7 @@ export function acceptOpen(rawText, opts = {}) {
// (found by the independent review: acceptance at round 1 passed the gate).
const left = v.max_rounds + (v.extra_rounds ?? 0) - v.round;
if (left > 0) return { code: 1, message: `${left} critic round(s) left: revise the plan and run the next round; acceptance is only for after the last round` };
const accepted = {
...v,
accepted_open: {
round: v.round,
plan_sha256: v.plan_sha256,
accepted_at: opts.now ?? new Date().toISOString(),
findings: majors.map(m => ({ id: m.id ?? null, text: m.text ?? '' })),
},
};
return { code: 0, text: placeSection(text, renderSection(accepted)), message: `${majors.length} open MAJOR(s) of round ${v.round} accepted` };
return { code: 0, text: placeSection(text, renderSection(accept(majors))), message: `${majors.length} open MAJOR(s) of round ${v.round} accepted` };
}
/**
@ -559,13 +549,8 @@ export function acceptOpen(rawText, opts = {}) {
* @param {{usable: boolean, why: string|null, threatModel: object}} use - threat-model.mjs `usable`
*/
function checkKnownOutside(entries, use, reasons, warnings) {
const tm = use?.usable === true ? use.threatModel : null;
const backed = [];
const unbacked = [];
for (const e of entries) {
const asFinding = { rule_key: e.rule_key, ...(Array.isArray(e.rule_keys) ? { rule_keys: e.rule_keys } : {}), threat_model_ref: e.line };
(tm && backedLine(asFinding, tm) !== null ? backed : unbacked).push(e);
}
const { backed, unbacked, why } = splitBacked(entries, use,
e => ({ rule_key: e.rule_key, ...(Array.isArray(e.rule_keys) ? { rule_keys: e.rule_keys } : {}), threat_model_ref: e.line }));
if (backed.length > 0) {
warnings.push({
code: 'PLAN_GATE_KNOWN_OUTSIDE',
@ -573,7 +558,6 @@ function checkKnownOutside(entries, use, reasons, warnings) {
});
}
if (unbacked.length > 0) {
const why = tm ? 'no `Outside:` line in the brief equals it, or it is not a Voyage security finding' : (use?.why ?? 'the threat model is not usable');
reasons.push({
code: 'PLAN_GATE_OUTSIDE_NOT_BACKED',
message: `${unbacked.length} finding(s) recorded as outside the threat model are not backed by the brief now (${why}), so they count in full: ${unbacked.map(e => e.text).join(' | ')}`,
@ -609,10 +593,11 @@ export function gatePlan(rawText, opts = {}) {
} else if (ex.error) {
reasons.push({ code: 'PLAN_GATE_VERDICT_MALFORMED', message: ex.error });
} else {
const stop = v.stop_rule?.fired === true ? v.stop_rule : null;
if (v.plan_sha256 !== planHash(text)) {
reasons.push({
code: 'PLAN_GATE_VERDICT_STALE',
message: v.stop_rule?.fired === true
message: stop
? `The plan changed after critic round ${v.round}, and the stop rule fired, so there is no next round; restore the reviewed text, or stop and take the direction to the operator.`
: `The plan changed after critic round ${v.round}; run the next round.`,
});
@ -620,7 +605,6 @@ export function gatePlan(rawText, opts = {}) {
const sev = f => normalizeSeverity(f.severity);
const blockers = v.findings.filter(f => sev(f) !== 'major' && sev(f) !== 'minor');
const majors = v.findings.filter(f => sev(f) === 'major');
const stop = v.stop_rule?.fired === true ? v.stop_rule : null;
if (stop) {
// The stop rule replaces the open-blocker and open-major reasons: no new
// round, the operator decides (decision 220). Covered = accepted under
@ -844,9 +828,11 @@ if (isMain(import.meta.url)) {
const dir = snapshotDir(planPath);
const opts = { extraRound: args.includes('--extra-round'), reviewed, planPaths: planPathsOf(planPath) };
if (briefArg !== undefined) {
// Stored relative to the repo's top level, never absolute; read from there.
opts.briefPath = repoRelativeBrief(briefArg);
opts.threatModel = usableInRepo(opts.briefPath);
// Stored relative to the repo's top level, never absolute; read from there
// (one top-level lookup for both).
const top = resolveTopLevel();
opts.briefPath = repoRelativeBrief(briefArg, top);
opts.threatModel = usableInRepo(opts.briefPath, top);
}
const lb = loadBase(text, dir);
if (lb.verdict) {