606 lines
28 KiB
JavaScript
606 lines
28 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); later steps insert theirs at
|
|
// their places in PASSES. Today it runs:
|
|
//
|
|
// - 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).
|
|
//
|
|
// 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 { ENGINES } from './engine-findings.mjs';
|
|
import { carry, frontmatterField, MAX_REVIEW_ROUNDS, runId } from './review-rounds.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: 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 r = carry(ctx.previous.findings, payload.findings, {
|
|
marks: ctx.evidence.marks, ran: ctx.evidence.ran, previousRan: ctx.previous.ran,
|
|
});
|
|
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,
|
|
};
|
|
}
|
|
|
|
/** The passes, in the Architecture order; the id recomputation runs after them, always last. */
|
|
export const PASSES = Object.freeze([
|
|
{ name: 'carry', run: passCarry },
|
|
{ name: 'pre-existing', run: passPreExisting },
|
|
]);
|
|
|
|
// ---- 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. */
|
|
function entryLines(f) {
|
|
const lines = [`### ${f.id}`, '', `- 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) {
|
|
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), 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: [] };
|
|
const ctx = { repo, text, round, previous, evidence: ev, added: new Set(), carried: false };
|
|
let head = parts.head;
|
|
const out = { passes: [] };
|
|
for (const pass of PASSES) {
|
|
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);
|
|
if (ctx.carried) {
|
|
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',
|
|
};
|
|
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,
|
|
};
|
|
}
|
|
|
|
// ---- evidence and the previous review ------------------------------------------------
|
|
|
|
/**
|
|
* What ran this round, from the project folder (positive evidence only), and
|
|
* the reviewers' marks of previous findings.
|
|
* @param {string} dir
|
|
* @returns {{ ran: {engines: string[], reviewers: string[]}, marks: Array<{id, resolved, reviewer}> }}
|
|
*/
|
|
export function roundEvidence(dir) {
|
|
const engines = new Set();
|
|
const reviewers = new Set();
|
|
const marks = [];
|
|
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);
|
|
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 };
|
|
}
|
|
|
|
/** 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);
|
|
}
|