// lib/review/review-finalize.mjs // One deterministic pass over review.md after the coordinator and after // lib/review/fill-finding-ids.mjs, on both paths (review_version 1.2, fix // round 4.5b2 part B). Its passes run in the Architecture order (engine, // instruction, carry, pre-existing, policy), each at its place in PASSES: // // - engine, always the first pass: `putBack` (lib/review/engine-compare.mjs) // compares the engine payloads Phase 4.7 saved in the project folder // (each regular `.review-engine-payload-*.json` whose top-level `reviewer` // maps back through `ENGINES`, read with readdirSync) with the review's // findings. Each engine finding at MAJOR or BLOCKER whose row no finding // carries (its own row, or the row of its trimmed title) is appended at // its own severity with `engine` kept and no id (the id recomputation // gives it its computed id). It gets a `### ` entry under its // `## Findings ()` section with the line // `**Source:** engine {name}, put back by the standard-path comparison`, // the frontmatter list, counts, a verdict that is only raised, and, as a // BLOCKER, its `### Step R`. An engine finding in `out_of_scope` is // not compared (it stays declined, `engine:out-of-scope`). // - instruction, right after engine: a finding under `CLAUDE_MD_DEVIATION` // or `CLAUDE_MD_STALE` whose `instruction_ref` is missing or names a file // outside the repo's tracked instruction set (lib/review/instruction-scope.mjs // `instructionSet` at `scope_sha_end`, for the paths changed over // `scope_sha_start..scope_sha_end`) leaves the JSON findings, the // frontmatter list, `counts` and its `### ` entry under // `## Findings (MINOR|SUGGESTION)`, and goes to `declined_to_judge` with // `instruction-out-of-set` and its `instruction_ref` (the cited file, or // null); `## Declined to Judge` gets one line for it, by title, naming // the cited file. A git failure or no range is an empty set, so every // such finding is declined. Only a MINOR or SUGGESTION that is not a // security finding moves; one whose entry is not found exactly once // there, that an R-step names or that finalize added stays reported // (stricter). The same breach reported by an engine and a reviewer // (same triplet) was already merged by the coordinator's dedup, which // keeps the higher severity. // - carry (round 2 and later, `--previous `): `carry` // (lib/review/review-rounds.mjs) keeps every previous finding that this // round gives no positive evidence of being resolved, with `carried_from`; // a current finding that matches one folds into it. What ran this round is // read from the project folder, never inferred from what is missing: // an engine ran when a regular `.review-engine-payload-*.json` there holds // a top-level `reviewer` that maps back through `ENGINES` // (lib/review/engine-findings.mjs), and a reviewer ran when its regular // `.review-out-.md` passes the findings schema; that output's // `previous: [{id, resolved}]` are its marks. The previous review's `ran` // counts only when it is `finalized: true`. Writes `resolved` and a // `## Resolved since round ` section just before `## Declined to Judge`. // - pre-existing: `markFindings` (lib/review/pre-existing.mjs) sets // `pre_existing` on every JSON finding from git over // `scope_sha_start..scope_sha_end` (read raw, as `reviewedSha` reads; a // YAML number or `null` means no mark), writes `pre_existing_counts`, and // writes a `## Pre-existing` list (the count, then one line per title) // just before `## Resolved since round ` or `## Declined to Judge`. It // never moves or lowers a finding. A finding with `carried_from` keeps its // line from the previous round's end, so it is never pre-existing // (`pre_existing: false`, not judged against this range). // - policy, always the last pass: `readPolicy` (lib/review/review-policy.mjs) // reads `REVIEW.md` at `scope_sha_start` from git and `applyPolicy` caps // and skips MINOR and SUGGESTION findings only (never a BLOCKER, a MAJOR // or a security finding). A moved finding leaves the JSON findings, the // frontmatter list, `counts` and its `### ` entry under // `## Findings (MINOR|SUGGESTION)`, and goes to `declined_to_judge` with // `policy:skip` or `policy:cap`; one whose entry is not found exactly // once there, or that an R-step names, stays reported (stricter). The // `## Declined to Judge` section gets one line per moved finding, by // title, and `Over the policy cap: N` when a cap is set; the frontmatter // gets `review_policy`, `review_policy_note` and `review_policy_sha256` // (`none` without the file); `pre_existing_counts` and `## Pre-existing` // keep only the findings still reported. A malformed, symlinked or changed file, or // a git failure, applies no policy (`review_policy: not-applied`, the // note says why). Coverage is not touched. // // Then, always last, the id recomputation: every id is computed again with // `computeFindingIds` (lib/parsers/finding-id.mjs) over the final array, and // any id that changed is rewritten with one simultaneous old -> new map, // applied in one pass so no rewrite chains: each JSON finding's `id`, the id // line of each body entry (its `### ` heading or `**ID:**` line), and each // `Source finding:` line; the frontmatter `findings:` list is written from the // final ids. A folded finding keeps the current finding's id until then, so // every reference to it, its R-step included, names the carried finding. // `carried_from`, `resolved` and `## Answers` values keep the previous ids. // After it: a body entry for each finding finalize added (under its // severity's `## Findings` section; an entry under another severity's section // moves), counts recomputed when the carry ran, a verdict that is only ever // raised (frontmatter and JSON block), one `### Step R` for each BLOCKER // that no R-step names (the template's form, numbered after the last), and // `ran` in the JSON block with `round`, `round_base` and `finalized: true` in // the frontmatter. An R-step whose `Source finding:` names no finding of the // review is refused (exit 1). The file is written atomically (temp + rename). // // CLI: // node lib/review/review-finalize.mjs --review

--repo --project

// --round [--round-base ] [--previous

] // (from round 2 both --round-base and --previous are required; --previous // names this run's archive of round R-1, `review-round--.md`) // prints one JSON line; exit 0 = written, 1 = refused (a placeholder left, // an id that cannot be computed, two findings under one id, an orphan // R-step), 2 = could not run (bad arguments, unreadable file, no trailing // JSON block, an unreadable previous review). On 1 or 2 the review is left // as fill-finding-ids wrote it. import { lstatSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from 'node:fs'; import { basename, dirname, join } from 'node:path'; import { computeFindingIds } from '../parsers/finding-id.mjs'; import { fencedBlocks, isJsonBlock, validateReviewerOutput } from './findings-schema.mjs'; import { FULL_SHA_RE } from './reviewed-commit.mjs'; import { markFindings, scopeShas } from './pre-existing.mjs'; import { changedPaths, instructionSet } from './instruction-scope.mjs'; import { isSecurityFinding } from './review-answers.mjs'; import { ENGINES } from './engine-findings.mjs'; import { carry, frontmatterField, MAX_REVIEW_ROUNDS, runId } from './review-rounds.mjs'; import { applyPolicy, readPolicy } from './review-policy.mjs'; import { putBack } from './engine-compare.mjs'; import { isMain } from '../util/is-main.mjs'; const SEVERITIES = ['BLOCKER', 'MAJOR', 'MINOR', 'SUGGESTION']; const VERDICT_RANK = { ALLOW: 0, WARN: 1, BLOCK: 2 }; const HEX40 = /^[0-9a-f]{40}$/; const HEX40_G = /[0-9a-f]{40}/g; const SOURCE_RE = /Source finding:\**\s*`?([0-9a-f]{40})`?/; const ARCHIVE_RE = /^review-round-([0-9a-f]{12})-(\d+)\.md$/; const OUT_RE = /^\.review-out-(.+)\.md$/; const PAYLOAD_RE = /^\.review-engine-payload-.+\.json$/; 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); const sevOf = (f) => String(f?.severity ?? '').toUpperCase(); /** The review split at the trailing closed `json` block: {head, fence, body, tail}, or null. */ function splitTrailing(text) { const last = fencedBlocks(text).at(-1); if (!last || !last.closed || !isJsonBlock(last) || last.body === '') return null; const bodyAt = text.lastIndexOf(last.body); if (bodyAt <= 0) return null; const fenceAt = text.lastIndexOf('\n', bodyAt - 2) + 1; return { head: text.slice(0, fenceAt), fence: text.slice(fenceAt, bodyAt), body: last.body, tail: text.slice(bodyAt + last.body.length), }; } /** The parsed trailing JSON block when it is an object with a findings array, else null. */ function payloadOf(text) { const parts = splitTrailing(text); if (!parts) return null; try { const p = JSON.parse(parts.body); return isPlainObject(p) && Array.isArray(p.findings) ? p : null; } catch { return null; } } /** One line of prose: whitespace runs made one space, backticks removed. */ const oneLine = (s) => String(s ?? '').replace(/`/g, '').replace(/\s+/g, ' ').trim(); // ---- sections of the head ---------------------------------------------------------- /** The index of the first line matching any of `res`, else -1. */ const firstIndex = (lines, res) => lines.findIndex((l) => res.some((re) => re.test(l))); /** The end (exclusive) of the `## ` section that starts at `at`. */ function sectionEnd(lines, at) { let i = at + 1; while (i < lines.length && !/^##\s/.test(lines[i])) i++; return i; } /** * `head` with every section whose heading matches `headingRe` taken out and * `section` put just before the first line matching any of `before` (else at * the end). */ function replaceSection(head, headingRe, section, before) { const lines = head.split('\n'); const kept = []; for (let i = 0; i < lines.length; i++) { if (headingRe.test(lines[i])) { i = sectionEnd(lines, i) - 1; continue; } kept.push(lines[i]); } const at = firstIndex(kept, before); if (at === -1) return `${kept.join('\n').replace(/\n*$/, '\n\n')}${section}`; kept.splice(at, 0, ...section.replace(/\n$/, '').split('\n')); return kept.join('\n'); } /** * Append `block` lines at the end of the section whose heading matches * `headingRe`; when there is none, create it (`heading`) just before the first * line matching any of `before` (else at the end). Returns the new lines. */ function appendToSection(lines, headingRe, heading, block, before) { let at = lines.findIndex((l) => headingRe.test(l)); if (at === -1) { const where = firstIndex(lines, before); const add = [heading, '']; if (where === -1) { while (lines.length && lines.at(-1) === '') lines.pop(); lines.push('', ...add); at = lines.length - 2; } else { lines.splice(where, 0, ...add); at = where; } } let end = sectionEnd(lines, at); while (end - 1 > at && lines[end - 1] === '') end--; const out = ['', ...block, '']; lines.splice(end, 0, ...out); // One blank line before what follows (the blank lines trimmed above). const next = end + out.length; while (next < lines.length && lines[next] === '' && lines[next - 1] === '') lines.splice(next, 1); return lines; } // ---- pass: engine ------------------------------------------------------------------ /** * Pass `engine`, always the first pass: the engine findings at MAJOR or * BLOCKER the coordinator dropped are put back (lib/review/engine-compare.mjs). */ function passEngine(payload, ctx) { const r = putBack(payload, ctx.evidence.payloads ?? []); for (const f of r.added) { payload.findings.push(f); ctx.added.add(f); ctx.putBack.add(f); } ctx.engine = { put_back: r.added.length }; if (r.added.length === 0) return {}; ctx.recount = true; if (r.raisedVerdict === payload.verdict) return {}; payload.verdict = r.raisedVerdict; return { headEdit: (head) => withVerdict(head, r.raisedVerdict) }; } // ---- pass: instruction ------------------------------------------------------------- const INSTRUCTION_KEYS = new Set(['CLAUDE_MD_DEVIATION', 'CLAUDE_MD_STALE']); // One of coordinator-contract `NOTED_REASONS`: the gate accepts it on a MINOR or SUGGESTION only. const OUT_OF_SET = 'instruction-out-of-set'; const INSTRUCTION_MOVABLE = new Set(['MINOR', 'SUGGESTION']); /** * The repo's tracked instruction files at `scope_sha_end` for the paths * changed over the review's range (`changedPaths`, the list of Phase 5's * `git diff --name-only`, deleted paths included); an empty set when either * cannot be read. */ function instructionFilesOf(repo, text) { const { start, end } = scopeShas(text); const changed = changedPaths(repo, start, end); if (!changed.ok) return { measured: false, files: new Set() }; const r = instructionSet(repo, end, changed.paths); return r.ok ? { measured: true, files: new Set(r.files) } : { measured: false, files: new Set() }; } /** One `## Declined to Judge` line for a finding the instruction filter moved. */ function instructionLine(d) { const cited = typeof d.instruction_ref === 'string' && oneLine(d.instruction_ref) !== '' ? oneLine(d.instruction_ref) : 'no instruction file'; return `- ${oneLine(d.file) || '(no file)'}:${d.line ?? '?'} ${oneLine(d.rule_key) || '(no rule_key)'} — ${OUT_OF_SET}, cites ${cited} (${oneLine(d.title) || '(no title)'})`; } /** * Pass `instruction`, right after engine: a CLAUDE.md-key finding that cites * no file of the instruction set goes to Declined to Judge. */ function passInstruction(payload, ctx) { const keyed = payload.findings.filter((f) => INSTRUCTION_KEYS.has(f.rule_key)); if (keyed.length === 0) return {}; if (payload.declined_to_judge !== undefined && !Array.isArray(payload.declined_to_judge)) { ctx.instruction = { measured: false, moved: 0 }; return {}; } const set = instructionFilesOf(ctx.repo, ctx.text); const lines = ctx.head.split('\n'); const fmEnd = frontmatterEnd(lines); const named = new Set(remediationSteps(lines).steps.map((st) => st.source).filter(Boolean)); const take = keyed.filter((f) => (typeof f.instruction_ref !== 'string' || !set.files.has(f.instruction_ref)) && INSTRUCTION_MOVABLE.has(f.severity) && !isSecurityFinding(f) && typeof f.id === 'string' && HEX40.test(f.id) && !named.has(f.id) && !ctx.added.has(f) && movableEntry(lines, fmEnd, f.id) !== null); ctx.instruction = { measured: set.measured, moved: take.length }; if (take.length === 0) return {}; const declined = take.map((f) => ({ file: f.file ?? null, line: f.line ?? null, rule_key: f.rule_key ?? null, severity: f.severity ?? null, title: f.title ?? null, ...(f.engine !== undefined ? { engine: f.engine } : {}), instruction_ref: typeof f.instruction_ref === 'string' ? f.instruction_ref : null, reason: OUT_OF_SET, })); const takeSet = new Set(take); payload.findings = payload.findings.filter((f) => !takeSet.has(f)); payload.declined_to_judge = [...(payload.declined_to_judge ?? []), ...declined]; ctx.recount = true; const ids = take.map((f) => f.id); return { headEdit: (head) => { let out = head.split('\n'); const fe = frontmatterEnd(out); for (const id of ids) { const e = movableEntry(out, fe, id); if (e) out.splice(e.at, e.end - e.at); } out = emptySectionsSayNone(out); const at = out.findIndex((l) => DECLINED_RE.test(l)); if (at !== -1) { for (let i = sectionEnd(out, at) - 1; i > at; i--) { if (out[i].trim() !== 'None.') continue; out.splice(i, 1); if (out[i] === '' && out[i - 1] === '') out.splice(i, 1); } } out = appendToSection(out, DECLINED_RE, '## Declined to Judge', declined.map(instructionLine), [/^##\s+Remediation Summary\b/, /^##\s+Remediation Steps\b/, /^##\s+Answers\s*$/]); return out.join('\n'); }, }; } // ---- pass: carry ------------------------------------------------------------------- const LATER_SECTIONS = [/^##\s+Suppressed Findings\b/, /^##\s+Pre-existing\s*$/, /^##\s+Resolved since round\b/, /^##\s+Declined to Judge\b/, /^##\s+Remediation Steps\b/, /^##\s+Remediation Summary\b/, /^##\s+Answers\s*$/]; /** The `## Resolved since round ` section. */ function resolvedSection(n, list) { if (list.length === 0) return `## Resolved since round ${n}\n\nNone.\n\n`; const items = list.map((f) => `- ${oneLine(f.title) || '(no title)'} (\`${oneLine(f.file)}:${f.line}\`, \`${f.id}\`)`); return `## Resolved since round ${n}\n\nPrevious findings this round no longer raises: ${list.length}.\n\n${items.join('\n')}\n\n`; } /** Pass `carry`: only with a previous review. */ function passCarry(payload, ctx) { if (!ctx.previous) return {}; const before = payload.findings.length; const current = payload.findings; const r = carry(ctx.previous.findings, current, { marks: ctx.evidence.marks, ran: ctx.evidence.ran, previousRan: ctx.previous.ran, }); // carry returns a copy of each current finding at its index: a finding an // earlier pass added (an engine put-back) stays added under its copy. for (let i = 0; i < before; i++) { for (const set of [ctx.added, ctx.putBack]) { if (set.has(current[i])) { set.delete(current[i]); set.add(r.findings[i]); } } } payload.findings = r.findings; for (const f of r.findings.slice(before)) ctx.added.add(f); payload.resolved = r.resolved; ctx.carried = true; const list = ctx.previous.findings.filter((p) => r.resolved.includes(p.id)); const section = resolvedSection(ctx.round - 1, list); return { headEdit: (head) => replaceSection(head, /^##\s+Resolved since round\b/, section, [/^##\s+Declined to Judge\b/]) }; } // ---- pass: pre-existing ---------------------------------------------------------- /** The `## Pre-existing` section for the marked findings. */ function preExistingSection(findings, counts) { const marked = findings.filter((f) => f && f.pre_existing === true); if (marked.length === 0) return '## Pre-existing\n\nNone.\n\n'; const rows = SEVERITIES.filter((s) => counts[s] > 0).map((s) => `| ${s} | ${counts[s]} |`); const items = marked.map((f) => `- ${oneLine(f.title) || '(no title)'} (\`${oneLine(f.file)}:${f.line}\`)`); return `## Pre-existing\n\nFindings on lines this range did not add or change: ${marked.length}.\n\n` + `| Severity | Count |\n|----------|-------|\n${rows.join('\n')}\n\n${items.join('\n')}\n\n`; } /** Pass `pre-existing`. */ function passPreExisting(payload, { repo, text }) { const { start, end } = scopeShas(text); // A carried finding keeps its line from the previous round's end, so this // range's lines say nothing about it: it is never pre-existing. const carried = (f) => Boolean(f && typeof f === 'object' && f.carried_from); markFindings({ findings: payload.findings.filter((f) => !carried(f)) }, { repo, start, end }); for (const f of payload.findings) if (carried(f)) f.pre_existing = false; const counts = Object.fromEntries(SEVERITIES.map((s) => [s, 0])); for (const f of payload.findings) { const sev = sevOf(f); if (f && f.pre_existing === true && sev in counts) counts[sev] += 1; } payload.pre_existing_counts = counts; const section = preExistingSection(payload.findings, counts); return { headEdit: (head) => replaceSection(head, /^##\s+Pre-existing\s*$/, section, [/^##\s+Resolved since round\b/, /^##\s+Declined to Judge\b/]), counts, }; } // ---- pass: policy ---------------------------------------------------------------- const MOVABLE_SECTION_RE = /^##\s+Findings\s*\((?:MINOR|SUGGESTION)\)\s*$/; const DECLINED_RE = /^##\s+Declined to Judge\b/; const OVER_CAP_RE = /^Over the policy cap:\s*\d+\s*$/; /** A frontmatter string value: one line, no quote or backslash, at most 200 chars, double-quoted. */ const quoted = (s) => `"${oneLine(s).replace(/["\\]/g, "'").slice(0, 200)}"`; /** * Where the `### ` entry of `id` sits in `lines` (after the frontmatter): * {at, end} when there is exactly one and it stands under `## Findings (MINOR)` * or `## Findings (SUGGESTION)`, else null. */ function movableEntry(lines, fmEnd, id) { const heading = new RegExp(`^###\\s+${id}\\s*$`); const hits = []; for (let i = fmEnd + 1; i < lines.length; i++) if (heading.test(lines[i])) hits.push(i); if (hits.length !== 1) return null; const at = hits[0]; let h = at - 1; while (h > fmEnd && !/^##\s/.test(lines[h])) h--; if (!MOVABLE_SECTION_RE.test(lines[h] ?? '')) return null; let end = at + 1; while (end < lines.length && !/^#{2,3}\s/.test(lines[end])) end++; return { at, end, section: h }; } /** `lines` with every `## Findings (MINOR|SUGGESTION)` section left without an entry saying `None.`. */ function emptySectionsSayNone(lines) { for (let i = 0; i < lines.length; i++) { if (!MOVABLE_SECTION_RE.test(lines[i])) continue; const end = sectionEnd(lines, i); const body = lines.slice(i + 1, end); if (body.some((l) => /^###\s/.test(l))) continue; if (body.some((l) => l.trim() !== '')) continue; lines.splice(i + 1, end - i - 1, '', 'None.', ''); } return lines; } /** One `## Declined to Judge` line for a finding the policy moved. */ function declinedLine(d) { return `- ${oneLine(d.file) || '(no file)'}:${d.line ?? '?'} ${oneLine(d.rule_key) || '(no rule_key)'} — ${d.reason} (${oneLine(d.title) || '(no title)'})`; } /** * Pass `policy`, always the last pass: the `REVIEW.md` policy read at * `scope_sha_start` (lib/review/review-policy.mjs). A finding it moves leaves * the JSON findings (so the frontmatter list and counts), and its `### ` * entry; a finding whose entry is not found exactly once under * `## Findings (MINOR|SUGGESTION)`, or that an R-step names, stays reported. * The moved ones are listed under `## Declined to Judge`, with * `Over the policy cap: N` when a cap is set. Coverage is not touched. */ function passPolicy(payload, ctx) { const { start, end } = scopeShas(ctx.text); let policy = readPolicy(ctx.repo, start, end); if (policy.state === 'applied' && payload.declined_to_judge !== undefined && !Array.isArray(payload.declined_to_judge)) { policy = { ...policy, state: 'not-applied', reason: 'declined_to_judge in the JSON block is not an array', cap: null, skip: [] }; } ctx.policyFields = { review_policy: policy.state, review_policy_note: quoted(policy.reason), review_policy_sha256: `"${policy.sha256}"`, }; // The reason of each one is the one the whole set gives it: the cap counts over every finding. const all = applyPolicy(payload, policy); const reasonOf = new Map(all.moved.map((f, i) => [f, all.declined[i]])); const lines = ctx.head.split('\n'); const fmEnd = frontmatterEnd(lines); const named = new Set(remediationSteps(lines).steps.map((st) => st.source).filter(Boolean)); const take = []; for (const f of all.moved) { if (typeof f.id !== 'string' || !HEX40.test(f.id) || named.has(f.id) || ctx.added.has(f)) continue; if (movableEntry(lines, fmEnd, f.id)) take.push(f); } const declined = take.map((f) => reasonOf.get(f)); const takeSet = new Set(take); payload.findings = payload.findings.filter((f) => !takeSet.has(f)); let preSection = null; if (take.length > 0) { payload.declined_to_judge = [...(payload.declined_to_judge ?? []), ...declined]; ctx.recount = true; // The pre-existing pass ran first: its counts and list keep only the findings still reported. if (isPlainObject(payload.pre_existing_counts)) { const counts = Object.fromEntries(SEVERITIES.map((sv) => [sv, 0])); for (const f of payload.findings) if (f.pre_existing === true && sevOf(f) in counts) counts[sevOf(f)] += 1; payload.pre_existing_counts = counts; preSection = preExistingSection(payload.findings, counts); } } // Every policy:cap entry the review holds, also those an earlier finalize moved. const overCap = (Array.isArray(payload.declined_to_judge) ? payload.declined_to_judge : []) .filter((d) => d !== null && typeof d === 'object' && d.reason === 'policy:cap').length; ctx.policy = { state: policy.state, moved: take.length, over_cap: overCap }; const ids = take.map((f) => f.id); return { headEdit: (head) => { const pre = preSection === null ? head : replaceSection(head, /^##\s+Pre-existing\s*$/, preSection, [/^##\s+Resolved since round\b/, DECLINED_RE]); let out = pre.split('\n'); const fe = frontmatterEnd(out); for (const id of ids) { const e = movableEntry(out, fe, id); if (!e) continue; out.splice(e.at, e.end - e.at); } out = emptySectionsSayNone(out); const block = declined.map(declinedLine); if (policy.state === 'applied' && policy.cap !== null) block.push(...(block.length > 0 ? [''] : []), `Over the policy cap: ${overCap}`); if (block.length === 0) return out.join('\n'); const at = out.findIndex((l) => DECLINED_RE.test(l)); if (at !== -1) { const stop = sectionEnd(out, at); for (let i = stop - 1; i > at; i--) { if (OVER_CAP_RE.test(out[i]) || (declined.length > 0 && out[i].trim() === 'None.')) { out.splice(i, 1); if (out[i] === '' && out[i - 1] === '') out.splice(i, 1); } } } out = appendToSection(out, DECLINED_RE, '## Declined to Judge', block, [/^##\s+Remediation Summary\b/, /^##\s+Remediation Steps\b/, /^##\s+Answers\s*$/]); return out.join('\n'); }, }; } /** The passes, in the Architecture order; the id recomputation runs after them, always last. */ export const PASSES = Object.freeze([ { name: 'engine', run: passEngine }, { name: 'instruction', run: passInstruction }, { name: 'carry', run: passCarry }, { name: 'pre-existing', run: passPreExisting }, { name: 'policy', run: passPolicy }, ]); // ---- after the ids --------------------------------------------------------------- /** Rewrite the 40-hex tokens of one line through `map` (one pass: no chaining). */ const remap = (line, map) => line.replace(HEX40_G, (m) => map.get(m) ?? m); /** The frontmatter's closing line index (the opening is line 0), or -1. */ function frontmatterEnd(lines) { return lines[0] === '---' ? lines.indexOf('---', 1) : -1; } /** `head` with ids rewritten in body id lines and Source finding lines. */ function rewriteIds(head, map) { if (map.size === 0) return head; const lines = head.split('\n'); const fmEnd = frontmatterEnd(lines); return lines.map((l, i) => { if (i <= fmEnd) return l; if (/^###\s+[0-9a-f]{40}\b/.test(l) || /\*\*ID:\*\*/.test(l) || /Source finding:/.test(l)) return remap(l, map); return l; }).join('\n'); } /** Frontmatter edits: the `findings:` list written from `ids`, `fields` set (replaced, or added before `findings:`). */ function editFrontmatter(head, ids, fields) { const lines = head.split('\n'); const fmEnd = frontmatterEnd(lines); if (fmEnd === -1) return head; const fm = lines.slice(1, fmEnd); const out = []; for (let i = 0; i < fm.length; i++) { const l = fm[i]; const key = /^([A-Za-z_][\w-]*)\s*:/.exec(l)?.[1]; if (key && Object.hasOwn(fields, key)) { while (i + 1 < fm.length && /^\s/.test(fm[i + 1])) i++; continue; } if (key === 'findings') { while (i + 1 < fm.length && /^\s/.test(fm[i + 1])) i++; for (const [k, v] of Object.entries(fields)) out.push(`${k}: ${v}`); out.push(ids.length === 0 ? 'findings: []' : 'findings:', ...ids.map((id) => ` - ${id}`)); continue; } out.push(l); } if (!fm.some((l) => /^findings\s*:/.test(l))) { for (const [k, v] of Object.entries(fields)) out.push(`${k}: ${v}`); } return [lines[0], ...out, ...lines.slice(fmEnd)].join('\n'); } /** * A body entry for a finding finalize added, in the coordinator's form; an * engine put-back names its source first. */ function entryLines(f, putBackSet) { const lines = [`### ${f.id}`, '']; if (putBackSet.has(f)) lines.push(`**Source:** engine ${oneLine(f.engine)}, put back by the standard-path comparison`, ''); lines.push(`- file: ${oneLine(f.file)}`, `- line: ${f.line}`, `- rule_key: ${oneLine(f.rule_key)}`); for (const k of ['brief_ref', 'title', 'detail', 'recommended_action', 'engine', 'carried_from']) { if (f[k] !== undefined && f[k] !== null && oneLine(f[k]) !== '') lines.push(`- ${k}: ${oneLine(f[k])}`); } return lines; } const findingsHeading = (sev) => new RegExp(`^##\\s+Findings\\s*\\(${sev}\\)\\s*$`); /** Where a new `## Findings ()` section goes: before a later severity's section or any later section. */ function findingsBefore(sev) { return [...SEVERITIES.slice(SEVERITIES.indexOf(sev) + 1).map(findingsHeading), ...LATER_SECTIONS]; } /** * The body entries: one added for each finding in `added`, and a `### ` * entry that sits under another severity's `## Findings` section moved to its own. */ function placeEntries(head, findings, added, putBackSet) { let lines = head.split('\n'); const fmEnd = frontmatterEnd(lines); for (const f of findings) { const sev = sevOf(f); if (!SEVERITIES.includes(sev) || typeof f.id !== 'string') continue; if (added.has(f)) { lines = appendToSection(lines, findingsHeading(sev), `## Findings (${sev})`, entryLines(f, putBackSet), findingsBefore(sev)); continue; } const at = lines.findIndex((l, i) => i > fmEnd && new RegExp(`^###\\s+${f.id}\\s*$`).test(l)); if (at === -1) continue; let h = at - 1; while (h > fmEnd && !/^##\s/.test(lines[h])) h--; const m = /^##\s+Findings\s*\((BLOCKER|MAJOR|MINOR|SUGGESTION)\)\s*$/.exec(lines[h] ?? ''); if (!m || m[1] === sev) continue; let end = at + 1; while (end < lines.length && !/^#{2,3}\s/.test(lines[end])) end++; const block = lines.splice(at, end - at); while (block.length && block.at(-1) === '') block.pop(); lines = appendToSection(lines, findingsHeading(sev), `## Findings (${sev})`, block, findingsBefore(sev)); } return lines.join('\n'); } /** The `### Step R` blocks of `## Remediation Steps`: [{n, source}], and the section's bounds. */ function remediationSteps(lines) { const at = lines.findIndex((l) => /^##\s+Remediation Steps\b/.test(l)); if (at === -1) return { at: -1, end: -1, steps: [] }; const end = sectionEnd(lines, at); const steps = []; for (let i = at + 1; i < end; i++) { const m = /^###\s+Step\s+R(\d+)\b/.exec(lines[i]); if (m) steps.push({ n: Number(m[1]), source: null, line: i }); else if (steps.length && steps.at(-1).source === null) { const s = SOURCE_RE.exec(lines[i]); if (s) steps.at(-1).source = s[1]; } } return { at, end, steps }; } /** One R-step per BLOCKER that has none, in the template's form; returns {head, added}. */ function withBlockerSteps(head, findings) { let lines = head.split('\n'); const { steps } = remediationSteps(lines); const named = new Set(steps.map((s) => s.source).filter(Boolean)); let n = steps.reduce((m, s) => Math.max(m, s.n), 0); const added = []; for (const f of findings) { if (sevOf(f) !== 'BLOCKER' || named.has(f.id)) continue; n += 1; named.add(f.id); added.push(`R${n}`); const action = oneLine(f.recommended_action) || `Fix: ${oneLine(f.title) || f.id}`; lines = appendToSection(lines, /^##\s+Remediation Steps\b/, '## Remediation Steps', [ `### Step R${n}: ${action}`, '', `- **Files:** \`${oneLine(f.file)}\``, `- **Source finding:** \`${f.id}\``, '- **Test first:** a test that reproduces the finding and fails today', '- **Verify:** re-run `/trekreview`; the finding is absent', '- **On failure:** escalate', ], [/^##\s+Remediation Summary\b/, /^##\s+Answers\s*$/]); } if (added.length > 0) { // A "None." left in the section is no longer true. const { at, end } = remediationSteps(lines); for (let i = end - 1; i > at; i--) { if (lines[i].trim() === 'None.' || (lines[i] === '' && lines[i + 1] === '')) lines.splice(i, 1); } } return { head: lines.join('\n'), added }; } /** The verdict the findings need, never lower than `current`. */ function raisedVerdict(current, findings) { if (!Object.hasOwn(VERDICT_RANK, current)) return current; const need = findings.some((f) => sevOf(f) === 'BLOCKER') ? 'BLOCK' : findings.some((f) => sevOf(f) === 'MAJOR') ? 'WARN' : 'ALLOW'; return VERDICT_RANK[need] > VERDICT_RANK[current] ? need : current; } /** `head` with every frontmatter `verdict:` line set to `verdict`. */ function withVerdict(head, verdict) { const lines = head.split('\n'); const fmEnd = frontmatterEnd(lines); for (let i = 1; i < fmEnd; i++) if (/^verdict\s*:/.test(lines[i])) lines[i] = `verdict: ${verdict}`; return lines.join('\n'); } /** * Finalize review.md text. Pure apart from the git reads of the passes. * @param {string} text * @param {{ repo: string, round?: number, roundBase?: string|null, * previous?: {findings: object[], ran: object|null}|null, * evidence?: {ran: {engines: string[], reviewers: string[]}, marks: object[]} }} ctx * @returns {{exit: 0, text: string, passes: string[], ...} | {exit: 1|2, reason: string}} */ export function finalizeReview(text, { repo, round, roundBase = null, previous = null, evidence } = {}) { if (/@@ID_\d+@@/.test(text)) return { exit: 1, reason: 'an @@ID_@@ placeholder is left: run fill-finding-ids.mjs first' }; const parts = splitTrailing(text); if (!parts) return { exit: 2, reason: 'no trailing closed json block' }; let payload; try { payload = JSON.parse(parts.body); } catch (e) { return { exit: 2, reason: `the trailing JSON block does not parse: ${e.message}` }; } if (!isPlainObject(payload) || !Array.isArray(payload.findings)) { return { exit: 2, reason: 'the trailing JSON block has no findings array' }; } if (payload.findings.some((f) => !isPlainObject(f))) return { exit: 1, reason: 'a finding is not an object' }; const before = payload.findings.map((f) => f.id); const dup = before.find((id, i) => before.indexOf(id) !== i); if (dup !== undefined) return { exit: 1, reason: `two findings carry the id ${JSON.stringify(dup)}` }; const ev = evidence ?? { ran: { engines: [], reviewers: [] }, marks: [], payloads: [] }; const ctx = { repo, text, round, previous, evidence: ev, added: new Set(), putBack: new Set(), carried: false }; let head = parts.head; const out = { passes: [] }; for (const pass of PASSES) { ctx.head = head; const r = pass.run(payload, ctx); if (r.headEdit) head = r.headEdit(head); if (r.counts) out.pre_existing_counts = r.counts; out.passes.push(pass.name); } // The id recomputation, always last. const errors = []; const ids = computeFindingIds(payload.findings, (i, e) => errors.push(`findings[${i}]: ${e.message}`)); if (errors.length > 0) return { exit: 1, reason: `cannot compute an id: ${errors.join('; ')}` }; const map = new Map(); payload.findings.forEach((f, i) => { if (f.id !== ids[i] && typeof f.id === 'string' && HEX40.test(f.id)) map.set(f.id, ids[i]); f.id = ids[i]; }); head = rewriteIds(head, map); out.passes.push('ids'); head = placeEntries(head, payload.findings, ctx.added, ctx.putBack); if (ctx.carried || ctx.recount) { payload.counts = Object.fromEntries(SEVERITIES.map((s) => [s, payload.findings.filter((f) => sevOf(f) === s).length])); } const verdict = raisedVerdict(payload.verdict, payload.findings); if (verdict !== payload.verdict) { payload.verdict = verdict; head = withVerdict(head, verdict); } const steps = withBlockerSteps(head, payload.findings); head = steps.head; const known = new Set(payload.findings.map((f) => f.id)); const orphans = remediationSteps(head.split('\n')).steps.filter((s) => s.source !== null && !known.has(s.source)); if (orphans.length > 0) { return { exit: 1, reason: `R-step ${orphans.map((s) => `R${s.n} (Source finding ${s.source})`).join(', ')} names no finding of this review` }; } payload.ran = { engines: [...ev.ran.engines], reviewers: [...ev.ran.reviewers] }; const fields = { ...(Number.isInteger(round) ? { round: String(round), round_base: roundBase ?? 'null' } : {}), finalized: 'true', ...(ctx.policyFields ?? {}), }; head = editFrontmatter(head, payload.findings.map((f) => f.id), fields); const body = `${JSON.stringify(payload, null, 2)}\n`; return { exit: 0, text: `${head}${parts.fence}${body}${parts.tail}`, ...out, ids_rewritten: map.size, carried: ctx.added.size, resolved: payload.resolved ?? [], ran: payload.ran, r_steps_added: steps.added, policy: ctx.policy ?? null, put_back: ctx.engine?.put_back ?? 0, instruction: ctx.instruction ?? null, }; } // ---- evidence and the previous review ------------------------------------------------ /** * What ran this round, from the project folder (positive evidence only), the * reviewers' marks of previous findings, and the engine payloads that prove * an engine ran (for the engine pass). * @param {string} dir * @returns {{ ran: {engines: string[], reviewers: string[]}, marks: Array<{id, resolved, reviewer}>, payloads: object[] }} */ export function roundEvidence(dir) { const engines = new Set(); const reviewers = new Set(); const marks = []; const payloads = []; let names = []; try { names = readdirSync(dir).sort(); } catch { names = []; } for (const name of names) { const p = join(dir, name); try { if (!lstatSync(p).isFile()) continue; } catch { continue; } if (PAYLOAD_RE.test(name)) { let payload; try { payload = JSON.parse(readFileSync(p, 'utf8')); } catch { continue; } const engine = isPlainObject(payload) && typeof payload.reviewer === 'string' && Object.hasOwn(ENGINE_OF_REVIEWER, payload.reviewer) ? ENGINE_OF_REVIEWER[payload.reviewer] : null; if (engine && Array.isArray(payload.findings)) { engines.add(engine); payloads.push(payload); } continue; } const m = OUT_RE.exec(name); if (!m) continue; let raw; try { raw = readFileSync(p, 'utf8'); } catch { continue; } const v = validateReviewerOutput(raw); if (!v.valid) continue; reviewers.add(m[1]); for (const mark of Array.isArray(v.parsed?.previous) ? v.parsed.previous : []) { if (isPlainObject(mark) && typeof mark.id === 'string' && HEX40.test(mark.id) && typeof mark.resolved === 'boolean') { marks.push({ id: mark.id, resolved: mark.resolved, reviewer: m[1] }); } } } return { ran: { engines: [...engines].sort(), reviewers: [...reviewers].sort() }, marks, payloads }; } /** A `ran` object of the 1.2 shape, else null. */ function ranOf(r) { const ok = isPlainObject(r) && Array.isArray(r.engines) && r.engines.every((e) => Object.hasOwn(ENGINES, e)) && Array.isArray(r.reviewers) && r.reviewers.every((s) => typeof s === 'string'); return ok ? { engines: r.engines, reviewers: r.reviewers } : null; } /** * The previous round's review: this run's archive of round `round - 1`. * `ran` is null unless it is `finalized: true` with a well-formed `ran`. */ export function readPrevious(path, text, round) { const m = ARCHIVE_RE.exec(basename(String(path))); if (!m) return { exit: 2, reason: `--previous ${path} is not an archive review-round--.md` }; const { start } = scopeShas(text); if (!start) return { exit: 2, reason: 'the review has no full scope_sha_start: no run to carry from' }; if (m[1] !== runId(start)) return { exit: 2, reason: `--previous ${basename(path)} is an archive of another run (this run is ${runId(start)})` }; if (Number(m[2]) !== round - 1) return { exit: 2, reason: `--previous ${basename(path)} is not round ${round - 1}` }; let prevText; try { if (!lstatSync(path).isFile()) return { exit: 2, reason: `--previous ${path} is not a regular file` }; prevText = readFileSync(path, 'utf8'); } catch (e) { return { exit: 2, reason: `cannot read --previous ${path}: ${e.message}` }; } const payload = payloadOf(prevText); if (!payload) return { exit: 2, reason: `--previous ${path} has no readable trailing JSON block with findings` }; const finalized = frontmatterField(prevText, 'finalized') === 'true'; return { exit: 0, previous: { findings: payload.findings.filter(isPlainObject), ran: finalized ? ranOf(payload.ran) : null }, }; } function writeAtomic(p, text) { const tmp = join(dirname(p), `.${basename(p)}.${process.pid}.tmp`); writeFileSync(tmp, text); renameSync(tmp, p); } /** The CLI's work: check the arguments, read, finalize, write. */ export function finalizeFile({ review, repo, project, round, roundBase, previous }) { if (!review) return { exit: 2, reason: '--review is required' }; if (!repo) return { exit: 2, reason: '--repo is required' }; if (!project) return { exit: 2, reason: '--project is required' }; if (!/^[1-9]\d*$/.test(String(round ?? '')) || Number(round) > MAX_REVIEW_ROUNDS) { return { exit: 2, reason: `--round must be an integer 1-${MAX_REVIEW_ROUNDS}` }; } const R = Number(round); if (roundBase !== undefined && !FULL_SHA_RE.test(roundBase)) return { exit: 2, reason: '--round-base must be a full commit id' }; if (R >= 2 && (previous === undefined || roundBase === undefined)) { return { exit: 2, reason: 'round 2 and later need --previous and --round-base (the values prepare printed)' }; } if (R === 1 && previous !== undefined) return { exit: 2, reason: '--previous is for round 2 and later' }; try { if (!statSync(project).isDirectory()) return { exit: 2, reason: `--project ${project} is not a directory` }; } catch (e) { return { exit: 2, reason: `--project ${project}: ${e.message}` }; } let text; try { text = readFileSync(review, 'utf8'); } catch (e) { return { exit: 2, reason: `cannot read ${review}: ${e.message}` }; } let prev = null; if (previous !== undefined) { const p = readPrevious(previous, text, R); if (p.exit !== 0) return p; prev = p.previous; } const r = finalizeReview(text, { repo, round: R, roundBase: roundBase ?? null, previous: prev, evidence: roundEvidence(project) }); if (r.exit !== 0) return r; try { writeAtomic(review, r.text); } catch (e) { return { exit: 2, reason: `cannot write ${review}: ${e.message}` }; } const { text: _t, ...rest } = r; return { ...rest, written: true, path: review }; } function opt(args, name) { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; } if (isMain(import.meta.url)) { const args = process.argv.slice(2); let result; try { result = finalizeFile({ review: opt(args, '--review'), repo: opt(args, '--repo'), project: opt(args, '--project'), round: opt(args, '--round'), roundBase: opt(args, '--round-base'), previous: opt(args, '--previous'), }); } catch (e) { result = { exit: 2, reason: `review-finalize crashed: ${e.message}` }; } const { exit, ...rest } = result; process.stdout.write(`${JSON.stringify(exit === 0 ? rest : { written: false, ...rest })}\n`); process.exit(exit); }