918 lines
43 KiB
JavaScript
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);
|
|
}
|