226 lines
10 KiB
JavaScript
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);
|
|
}
|