voyage/lib/review/review-finalize.mjs

918 lines
43 KiB
JavaScript

// 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 (<SEV>)` 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<n>`. 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 <archive>`): `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-<reviewer>.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 <n>` 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 <n>` 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 `### <id>` 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<n>` 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 <p> --repo <d> --project <dir>
// --round <R> [--round-base <sha>] [--previous <p>]
// (from round 2 both --round-base and --previous are required; --previous
// names this run's archive of round R-1, `review-round-<run>-<R-1>.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 <n>` 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 `### <id>` 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 (<sev>)` 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 `### <id>`
* 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<n>` 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_<n>@@ 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-<run>-<n>.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);
}