// 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, the way the workflow path does // (scripts/trekreview-armB.workflow.mjs, 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`). // // The row rule below is a lib copy of the armB workflow's (scripts cannot be // imported); tests/lib/engine-findings-compare.test.mjs pins both equal. import { ENGINES } from './engine-findings.mjs'; // Copied from scripts/trekreview-armB.workflow.mjs:60-73 (rowOf, trimmedTitle). // 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)}`; // The coordinator trims an over-long MINOR/SUGGESTION title as // lib/review/coordinator-contract.mjs judgeFilter does; its marker and limit // are inlined (scripts cannot import) and pinned equal by the workflow test. const TRIM_MARKER = ' […trimmed]'; const JUDGE_TITLE_MAX = 100; // A title as that trim writes it: over JUDGE_TITLE_MAX characters, cut to // leave room for the marker, then the marker; any other title unchanged. const trimmedTitle = (t) => (t.length > JUDGE_TITLE_MAX ? t.slice(0, JUDGE_TITLE_MAX - TRIM_MARKER.length) + TRIM_MARKER : t); export { tripletOf, normTitle, rowOf, trimmedTitle }; 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 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 rowReturned = (m) => rows.has(rowOf(m)) || (typeof m.title === 'string' && rows.has(rowOf({ ...m, title: trimmedTitle(m.title) }))); const candidates = []; for (const p of Array.isArray(enginePayloads) ? enginePayloads : []) { if (!isPlainObject(p) || !Array.isArray(p.findings)) continue; const engine = typeof p.reviewer === 'string' && Object.hasOwn(ENGINE_OF_REVIEWER, p.reviewer) ? ENGINE_OF_REVIEWER[p.reviewer] : null; 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 (rowReturned(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 }; }