// 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 `### ` heading or `**ID:**` line), and each // `**Source finding:**` line. The file is written atomically (temp + rename). // // CLI: // node lib/review/review-finalize.mjs --review

--repo --project

// --round [--round-base ] [--previous

] // 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_@@ 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); }