voyage/lib/review/review-finalize.mjs

226 lines
10 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:
//
// - 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 `## Declined to Judge`. It never moves or lowers a finding.
//
// 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, in four places only: each JSON
// finding's `id`, the frontmatter `findings:` list, the id line of each body
// entry (its `### <id>` heading or `**ID:**` line), and each
// `**Source finding:**` line. 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>]
// prints one JSON line; exit 0 = written, 1 = refused (a placeholder left,
// an id that cannot be computed, two findings under one id), 2 = could not
// run (bad arguments, unreadable file, no trailing JSON block). On 1 or 2
// the review is left as fill-finding-ids wrote it.
import { readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
import { basename, dirname, join } from 'node:path';
import { computeFindingIds } from '../parsers/finding-id.mjs';
import { fencedBlocks, isJsonBlock } from './findings-schema.mjs';
import { FULL_SHA_RE } from './reviewed-commit.mjs';
import { markFindings, scopeShas } from './pre-existing.mjs';
import { isMain } from '../util/is-main.mjs';
export const MAX_ROUND = 3;
const SEVERITIES = ['BLOCKER', 'MAJOR', 'MINOR', 'SUGGESTION'];
const HEX40 = /^[0-9a-f]{40}$/;
const HEX40_G = /[0-9a-f]{40}/g;
/** 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),
};
}
/** One line of prose: whitespace runs made one space, backticks removed. */
const oneLine = (s) => String(s ?? '').replace(/`/g, '').replace(/\s+/g, ' ').trim();
/** 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`;
}
/** `head` with every `## Pre-existing` section taken out and `section` put just before `## Declined to Judge` (else at the end). */
function withPreExistingSection(head, section) {
const lines = head.split('\n');
const kept = [];
for (let i = 0; i < lines.length; i++) {
if (/^##\s+Pre-existing\s*$/.test(lines[i])) {
i++;
while (i < lines.length && !/^##\s/.test(lines[i])) i++;
i--;
continue;
}
kept.push(lines[i]);
}
const at = kept.findIndex((l) => /^##\s+Declined to Judge\b/.test(l));
const sectionLines = section.replace(/\n$/, '').split('\n');
if (at === -1) {
const out = kept.join('\n').replace(/\n*$/, '\n\n');
return `${out}${section}`;
}
kept.splice(at, 0, ...sectionLines);
return kept.join('\n');
}
/** Pass `pre-existing`. */
function passPreExisting(payload, { repo, text }) {
const { start, end } = scopeShas(text);
markFindings(payload, { repo, start, end });
const counts = Object.fromEntries(SEVERITIES.map((s) => [s, 0]));
for (const f of payload.findings) {
const sev = String(f?.severity ?? '').toUpperCase();
if (f && f.pre_existing === true && sev in counts) counts[sev] += 1;
}
payload.pre_existing_counts = counts;
return { headEdit: (head) => withPreExistingSection(head, preExistingSection(payload.findings, counts)), counts };
}
/** The passes, in the Architecture order; the id recomputation runs after them, always last. */
export const PASSES = Object.freeze([
{ name: 'pre-existing', run: passPreExisting },
]);
/** 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);
/** `head` with ids rewritten in the frontmatter list, body id lines and Source finding lines. */
function rewriteIds(head, map) {
if (map.size === 0) return head;
const lines = head.split('\n');
let fmEnd = -1;
if (lines[0] === '---') fmEnd = lines.indexOf('---', 1);
let inList = false;
return lines.map((l, i) => {
if (i > 0 && i < fmEnd) {
if (/^findings\s*:/.test(l)) { inList = true; return l; }
if (inList && /^\s+-\s/.test(l)) return remap(l, map);
if (!/^\s/.test(l)) inList = false;
return l;
}
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');
}
/**
* Finalize review.md text. Pure apart from the git reads of the passes.
* @param {string} text
* @param {{ repo: string }} ctx
* @returns {{exit: 0, text: string, passes: string[], pre_existing_counts: object, ids_rewritten: number} | {exit: 1|2, reason: string}}
*/
export function finalizeReview(text, { repo } = {}) {
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 (!payload || typeof payload !== 'object' || Array.isArray(payload) || !Array.isArray(payload.findings)) {
return { exit: 2, reason: 'the trailing JSON block has no findings array' };
}
if (payload.findings.some((f) => !f || typeof f !== 'object')) 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)}` };
let head = parts.head;
const out = { passes: [] };
for (const pass of PASSES) {
const r = pass.run(payload, { repo, text });
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');
const body = `${JSON.stringify(payload, null, 2)}\n`;
return { exit: 0, text: `${head}${parts.fence}${body}${parts.tail}`, ...out, ids_rewritten: map.size };
}
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 }) {
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_ROUND) {
return { exit: 2, reason: `--round must be an integer 1-${MAX_ROUND}` };
}
if (roundBase !== undefined && !FULL_SHA_RE.test(roundBase)) return { exit: 2, reason: '--round-base must be a full commit id' };
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}` }; }
const r = finalizeReview(text, { repo });
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'),
});
} 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);
}