feat(review): review rounds carry findings until resolved, one cap of three

This commit is contained in:
Kjell Tore Guttormsen 2026-11-02 17:44:23 +01:00
commit 635003e8aa
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
13 changed files with 2021 additions and 100 deletions

View file

@ -16,6 +16,30 @@
// `scope_sha_end`, so an answer goes stale with the review it answers;
// - the row's Record is that record's hash.
// A `fixed` row never answers: the finding stays open as fixed-pending.
// A carried row (Carried from `review-round-<run>-<n>.md#<old id>`, written by
// `carry` from round 2) answers a carried MAJOR only when all of these hold:
// - the finding is carried (`carried_from`) and, like the old finding in
// the archive, no security finding; the reason is not hollow;
// - `<run>` is the first 12 hex of this review's `scope_sha_start`, and `<n>`
// is lower than this review's `round` (no `round`: no carried link), so an
// answer from another run, or from a later round, is never carried;
// - the archive is a regular file beside review.md (read through
// `ctx.archived`), of the same run, and holds the old finding, which
// matches this one by the carry rule (lib/review/review-rounds.mjs
// `sameFinding`);
// - the archive's own `## Answers` row for the old id is that answer as
// given there (`accepted`, Carried from `-`, the same reason and Record);
// - the link is this finding's own carry chain: with `<n>` = round - 1 the
// old id is the finding's `carried_from`; with an older `<n>` the run's
// archive of round - 1 (its `round` field that number) holds the finding
// `carried_from` names, whose own row has the same link, Record and
// reason and is itself bound by these rules (one answer never answers
// another occurrence of the same class);
// - the K2 record with that hash is the latest of kind `review` for
// `<review path>#<old id>`, and its sha256 is `answerSha` over the
// archive's trailing JSON block, its `scope_sha_end`, the old id and the
// reason.
// Otherwise the MAJOR is unanswered.
// BLOCKER findings are never answered (the gate stops on them before this).
//
// The answer log is the top-level one: `logPath(answerLogRepo(dir))`, whatever
@ -38,18 +62,27 @@
// with its reason), 1 = refused (bad trailer, empty or hollow reason, an id
// that is not a counting MAJOR, a BLOCKER, `accepted` on a security
// finding), 2 = could not run
// node lib/review/review-answers.mjs carry --review <p> --previous <archive>
// --repo <d>
// after finalize, from round 2: for each carried MAJOR whose previous
// review holds a valid `accepted` row, writes its row with the link to the
// archive where it was given and that answer's record hash (no K2 record
// is appended, nothing is committed); exit 0 = done (`written`, `skipped`),
// 1 = refused (the previous review is not this run's earlier archive beside
// review.md), 2 = could not run
//
// The accept shape (an answer bound to the reviewed text, never to a BLOCKER)
// follows lib/validators/plan-gate.mjs `acceptOpen`.
import { createHash } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import { accessSync, constants as fsConstants, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
import { accessSync, constants as fsConstants, lstatSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
import { basename, dirname, join, resolve } from 'node:path';
import { fencedBlocks, isJsonBlock } from './findings-schema.mjs';
import { getRule } from './rule-catalogue.mjs';
import { reviewedSha } from './reviewed-commit.mjs';
import { preExistingCheck } from './pre-existing.mjs';
import { preExistingCheck, scopeShas } from './pre-existing.mjs';
import { archiveName, frontmatterField, runId, sameFinding } from './review-rounds.mjs';
import {
appendApproval, isHollowAnswer, logPath, normalizeSubject, readLog, verifyLog,
} from '../util/approval-log.mjs';
@ -66,6 +99,8 @@ const HEX40_RE = /^[0-9a-f]{40}$/;
const ANSWER_FINDING_RE = /^([0-9a-f]{40})(?:#\d+)?$/;
const SHA256_RE = /^[0-9a-f]{64}$/;
const CARRIED_FROM_RE = /^review-round-[0-9a-f]{12}-\d+\.md#[0-9a-f]{40}$/;
const LINK_RE = /^(review-round-([0-9a-f]{12})-(\d+)\.md)#([0-9a-f]{40})$/;
const ARCHIVE_NAME_RE = /^review-round-([0-9a-f]{12})-(\d+)\.md$/;
const sha256 = (s) => createHash('sha256').update(s).digest('hex');
const isMajor = (f) => String(f?.severity ?? '').toUpperCase() === 'MAJOR';
@ -213,19 +248,96 @@ function latestReviewRecord(records, subject) {
return null;
}
/**
* The archived-review reader for `dir` (the review's folder): an archive name
* `review-round-<run>-<n>.md` gives its text when it is a regular file there,
* anything else gives null.
* @param {string} dir
* @returns {(name: string) => string|null}
*/
export function archivedReader(dir) {
return (name) => {
if (!dir || !ARCHIVE_NAME_RE.test(String(name))) return null;
const p = join(dir, name);
try {
if (!lstatSync(p).isFile()) return null;
return readFileSync(p, 'utf8');
} catch { return null; }
};
}
/** Is `row` the answer `answer` to finding `id`, bound by the latest K2 record to `text` as it is? */
function directBound(text, id, row, answer, { records, reviewSubject }) {
const blockSha = findingsBlockSha(text);
const scopeShaEnd = reviewedSha(text) || null;
if (!row || !blockSha || !scopeShaEnd || !reviewSubject) return false;
const reason = normalizeReason(row.reason);
const rec = latestReviewRecord(Array.isArray(records) ? records : [], `${reviewSubject}#${id}`);
return Boolean(rec) && rec.hash === row.record
&& rec.sha256 === answerSha({ blockSha, scopeShaEnd, findingId: id, answer, reason });
}
/** An accepted reason that is a reason: not empty, not hollow. */
const realReason = (row) => {
const reason = normalizeReason(row?.reason);
return reason !== '' && !isHollowAnswer(reason);
};
/**
* Is the carried row `row` of finding `f` in review `text` bound to an
* accepted answer in this run's earlier archive (the rule in the header)?
*/
function carriedBound(text, f, row, { records, reviewSubject, archived }) {
if (!row || row.answer !== 'accepted' || typeof archived !== 'function' || !reviewSubject) return false;
if (typeof f?.carried_from !== 'string' || isSecurityFinding(f) || !realReason(row)) return false;
const m = LINK_RE.exec(String(row.carriedFrom));
if (!m) return false;
const [, name, run, n, oldId] = m;
const { start } = scopeShas(text);
if (!start || run !== runId(start)) return false;
const round = frontmatterField(text, 'round');
if (!/^\d+$/.test(round ?? '') || Number(n) >= Number(round)) return false;
const old = archived(name);
if (typeof old !== 'string') return false;
const oldStart = scopeShas(old).start;
if (!oldStart || runId(oldStart) !== run) return false;
const oldF = (payloadOf(old)?.findings ?? []).find((x) => x && typeof x === 'object' && x.id === oldId);
if (!oldF || isSecurityFinding(oldF) || !sameFinding(oldF, f)) return false;
const oldRow = parseAnswers(bodyOf(old)).rows.find((r) => r.id === oldId);
if (!oldRow || oldRow.answer !== 'accepted' || oldRow.carriedFrom !== '-' || oldRow.record !== row.record
|| normalizeReason(oldRow.reason) !== normalizeReason(row.reason)) return false;
if (!directBound(old, oldId, oldRow, 'accepted', { records, reviewSubject })) return false;
// The link is bound to this finding's own carry chain, so an answer to one
// occurrence never answers another occurrence of the same class.
const before = Number(round) - 1;
if (Number(n) === before) return oldId === f.carried_from;
// An older answer reached this round through the previous one: the run's
// round-(round-1) archive holds the finding this one was carried from, with
// the same link, record and reason, itself bound the same way.
const prev = archived(archiveName(run, before));
if (typeof prev !== 'string' || frontmatterField(prev, 'round') !== String(before)) return false;
const prevF = (payloadOf(prev)?.findings ?? []).find((x) => x && typeof x === 'object' && x.id === f.carried_from);
const prevRow = parseAnswers(bodyOf(prev)).rows.find((r) => r.id === f.carried_from);
if (!prevF || !prevRow || prevRow.carriedFrom !== row.carriedFrom || prevRow.record !== row.record
|| normalizeReason(prevRow.reason) !== normalizeReason(row.reason)) return false;
return carriedBound(prev, prevF, prevRow, { records, reviewSubject, archived });
}
/**
* Decide whether every counting MAJOR of a review is answered. Pure: the K2
* records, the review's subject (its path relative to the git top level) and
* the `preExisting` predicate (the gate's is lib/review/pre-existing.mjs
* records, the review's subject (its path relative to the git top level), the
* `preExisting` predicate (the gate's is lib/review/pre-existing.mjs
* `preExistingCheck`, re-derived from git; absent = nothing is pre-existing)
* come in `ctx`; with no subject or no records nothing is answered.
* and the `archived` reader for carried rows (`archivedReader`; absent = no
* carried row answers) come in `ctx`; with no subject or no records nothing
* is answered.
* @param {string} text - review.md
* @param {{ records?: object[], reviewSubject?: string, preExisting?: (f) => boolean, archived?: unknown }} [ctx]
* @param {{ records?: object[], reviewSubject?: string, preExisting?: (f) => boolean, archived?: (name: string) => string|null }} [ctx]
* A review with no readable trailing JSON block is `review-not-measured:`
* (not ok), never ok.
* @returns {{ok: true} | {ok: false, unanswered: string[], fixedPending: string[], reason: string}}
*/
export function decideAnswers(text, { records = [], reviewSubject, preExisting = () => false } = {}) {
export function decideAnswers(text, { records = [], reviewSubject, preExisting = () => false, archived } = {}) {
const payload = payloadOf(text);
// A review whose findings cannot be read is never answered (fail closed).
if (!payload) {
@ -233,25 +345,21 @@ export function decideAnswers(text, { records = [], reviewSubject, preExisting =
}
const majors = countingMajors(payload, { preExisting });
if (majors.length === 0) return { ok: true };
const blockSha = findingsBlockSha(text);
const scopeShaEnd = reviewedSha(text) || null;
const { rows } = parseAnswers(bodyOf(text));
const rowOf = new Map(rows.map((r) => [r.id, r]));
const unanswered = [];
const fixedPending = [];
const ctx = { records, reviewSubject, archived };
for (const f of majors) {
const row = rowOf.get(f.id);
const bound = (answer) => {
if (!row || !blockSha || !scopeShaEnd || !reviewSubject) return false;
const reason = normalizeReason(row.reason);
const rec = latestReviewRecord(Array.isArray(records) ? records : [], `${reviewSubject}#${f.id}`);
return Boolean(rec) && rec.hash === row.record
&& rec.sha256 === answerSha({ blockSha, scopeShaEnd, findingId: f.id, answer, reason });
};
if (row && row.answer === 'accepted' && !isSecurityFinding(f)) {
const reason = normalizeReason(row.reason);
if (reason !== '' && !isHollowAnswer(reason) && bound('accepted')) continue;
if (row && row.carriedFrom !== '-') {
// A carried row answers only through its link.
if (carriedBound(text, f, row, ctx)) continue;
unanswered.push(f.id);
continue;
}
const bound = (answer) => directBound(text, f.id, row, answer, ctx);
if (row && row.answer === 'accepted' && !isSecurityFinding(f) && realReason(row) && bound('accepted')) continue;
if (row && row.answer === 'fixed' && bound('fixed')) { fixedPending.push(f.id); continue; }
unanswered.push(f.id);
}
@ -352,8 +460,13 @@ export function pendingFindings({ reviewPath, repo }) {
if (!payload) return { exit: 2, reason: `${reviewPath} has no readable trailing JSON block` };
const { records, top, problem } = answerRecords(repo ?? dirname(resolve(reviewPath)));
// The gate's pre-existing reading (lib/review/verdict-gate.mjs `decideOpenFindings`), so the list asks what the gate counts.
const preExisting = preExistingCheck(text, repo ?? top ?? undefined);
const d = decideAnswers(text, { records, reviewSubject: top ? reviewSubject(top, reviewPath) : undefined, preExisting });
// A finding with `carried_from` is never pre-existing there either.
const pre = preExistingCheck(text, repo ?? top ?? undefined);
const preExisting = (f) => !f.carried_from && pre(f);
const d = decideAnswers(text, {
records, reviewSubject: top ? reviewSubject(top, reviewPath) : undefined, preExisting,
archived: archivedReader(dirname(resolve(reviewPath))),
});
if (d.ok) return { exit: 0, pending: [] };
const open = new Set([...d.unanswered, ...d.fixedPending]);
const pending = countingMajors(payload, { preExisting }).filter((f) => open.has(f.id)).map((f) => ({
@ -436,6 +549,74 @@ export function recordAnswer({ reviewPath, finding, answer, repo, round, store,
};
}
/**
* From round 2, after finalize: carry the previous review's valid `accepted`
* answers to the carried MAJORs of this review (see the header). Writes rows
* only; appends no K2 record and commits nothing.
*/
export function carryAnswers({ reviewPath, previousPath, repo }) {
const refuse = (reason) => ({ exit: 1, written: [], reason });
const cannot = (reason) => ({ exit: 2, written: [], reason });
if (!reviewPath) return cannot('--review is required');
if (!previousPath) return cannot('--previous is required');
if (!repo) return cannot('--repo is required');
let text;
try { text = readFileSync(reviewPath, 'utf8'); } catch (e) { return cannot(`cannot read ${reviewPath}: ${e.message}`); }
const dir = dirname(resolve(reviewPath));
const name = basename(previousPath);
const m = ARCHIVE_NAME_RE.exec(name);
if (!m || dirname(resolve(previousPath)) !== dir) {
return refuse(`--previous ${previousPath} is not an archive review-round-<run>-<n>.md beside ${reviewPath}`);
}
const { start } = scopeShas(text);
const round = frontmatterField(text, 'round');
if (!start || m[1] !== runId(start)) return refuse(`--previous ${name} is not an archive of this review's run`);
if (!/^\d+$/.test(round ?? '') || Number(m[2]) >= Number(round)) {
return refuse(`--previous ${name} is not an earlier round than this review's (round: ${round ?? 'none'})`);
}
const archived = archivedReader(dir);
const prevText = archived(name);
if (typeof prevText !== 'string') return cannot(`cannot read --previous ${previousPath} as a regular file`);
const payload = payloadOf(text);
const prevPayload = payloadOf(prevText);
if (!payload || !Array.isArray(payload.findings)) return cannot(`${reviewPath} has no readable trailing JSON block`);
if (!prevPayload || !Array.isArray(prevPayload.findings)) return cannot(`${previousPath} has no readable trailing JSON block`);
const { records, top, problem } = answerRecords(repo);
const ctx = { records, reviewSubject: top ? reviewSubject(top, reviewPath) : undefined, archived };
const prevRows = new Map(parseAnswers(bodyOf(prevText)).rows.map((r) => [r.id, r]));
const written = [];
const skipped = [];
let next = text;
for (const f of payload.findings) {
if (!f || typeof f !== 'object' || !isMajor(f) || typeof f.carried_from !== 'string') continue;
const row = prevRows.get(f.carried_from);
if (!row || row.answer !== 'accepted') continue;
const oldF = prevPayload.findings.find((x) => x && typeof x === 'object' && x.id === row.id);
let link = null;
if (row.carriedFrom === '-') {
// Given in the previous review itself.
const ok = oldF && !isSecurityFinding(oldF) && !isSecurityFinding(f) && realReason(row)
&& sameFinding(oldF, f) && directBound(prevText, row.id, row, 'accepted', ctx);
if (ok) link = `${name}#${row.id}`;
} else if (oldF && carriedBound(prevText, oldF, row, ctx)) {
// Carried into the previous review: the link to where it was given
// holds when that finding also matches this one.
const [, archive, , , givenId] = LINK_RE.exec(row.carriedFrom);
const given = (payloadOf(archived(archive))?.findings ?? []).find((x) => x && x.id === givenId);
if (given && sameFinding(given, f)) link = row.carriedFrom;
}
if (link === null) { skipped.push(f.id); continue; }
const placed = withAnswerRow(next, f.id, `| ${f.id} | accepted | ${normalizeReason(row.reason)} | ${row.record} | ${link} |`);
if (placed === null) return cannot(`${reviewPath}: the ## Answers table could not be placed; nothing written`);
next = placed;
written.push(f.id);
}
if (written.length > 0) {
try { writeAtomic(reviewPath, next); } catch (e) { return cannot(`cannot write ${reviewPath}: ${e.message}`); }
}
return { exit: 0, written, skipped, ...(problem ? { log: problem } : {}) };
}
// ---- CLI ----
function opt(args, name) {
@ -458,8 +639,10 @@ if (isMain(import.meta.url)) {
repo: opt(args, '--repo'), round: opt(args, '--round'), store: opt(args, '--store'),
trailer: opt(args, '--trailer'), reason,
});
} else if (cmd === 'carry') {
result = carryAnswers({ reviewPath: opt(args, '--review'), previousPath: opt(args, '--previous'), repo: opt(args, '--repo') });
} else {
process.stderr.write('Usage: review-answers.mjs pending|record — see the header of lib/review/review-answers.mjs\n');
process.stderr.write('Usage: review-answers.mjs pending|record|carry — see the header of lib/review/review-answers.mjs\n');
process.exit(2);
}
} catch (e) {

View file

@ -5,41 +5,79 @@
// 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 `## Declined to Judge`. It never moves or lowers a finding.
// 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, 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).
// 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), 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.
// 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 { readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
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 } from './findings-schema.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';
export const MAX_ROUND = 3;
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) {
@ -56,9 +94,111 @@ function splitTrailing(text) {
};
}
/** 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);
@ -69,94 +209,223 @@ function preExistingSection(findings, counts) {
+ `| 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 });
// 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 = String(f?.severity ?? '').toUpperCase();
const sev = sevOf(f);
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 };
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);
/** `head` with ids rewritten in the frontmatter list, body id lines and Source finding lines. */
/** 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');
let fmEnd = -1;
if (lines[0] === '---') fmEnd = lines.indexOf('---', 1);
let inList = false;
const fmEnd = frontmatterEnd(lines);
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);
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 }} ctx
* @returns {{exit: 0, text: string, passes: string[], pre_existing_counts: object, ids_rewritten: number} | {exit: 1|2, reason: string}}
* @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 } = {}) {
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 (!payload || typeof payload !== 'object' || Array.isArray(payload) || !Array.isArray(payload.findings)) {
if (!isPlainObject(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' };
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, { repo, text });
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);
@ -173,8 +442,108 @@ export function finalizeReview(text, { repo } = {}) {
});
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 };
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) {
@ -184,20 +553,31 @@ function writeAtomic(p, text) {
}
/** The CLI's work: check the arguments, read, finalize, write. */
export function finalizeFile({ review, repo, project, round, roundBase }) {
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_ROUND) {
return { exit: 2, reason: `--round must be an integer 1-${MAX_ROUND}` };
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}` }; }
const r = finalizeReview(text, { repo });
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;
@ -215,7 +595,7 @@ if (isMain(import.meta.url)) {
try {
result = finalizeFile({
review: opt(args, '--review'), repo: opt(args, '--repo'), project: opt(args, '--project'),
round: opt(args, '--round'), roundBase: opt(args, '--round-base'),
round: opt(args, '--round'), roundBase: opt(args, '--round-base'), previous: opt(args, '--previous'),
});
} catch (e) {
result = { exit: 2, reason: `review-finalize crashed: ${e.message}` };

View file

@ -0,0 +1,314 @@
// lib/review/review-rounds.mjs
// /trekreview rounds (review_version 1.2, fix round 4.5b2 part B): round 2
// and later see the diff since the previous round and the previous findings,
// carry what is not resolved, and stop at one cap.
//
// `MAX_REVIEW_ROUNDS` is the one cap: /trekreview refuses a fourth round, and
// /trekexecute's review loop reads the same constant (`--cap`).
//
// prepare({projectDir, since, repo, workflow, dryRun}): the round of a run.
// `<run>` is the first 12 hex of `since` (the run's `scope_sha_start`).
// - Same run (review.md exists and its `scope_sha_start` equals `since`): a
// later round. The archives `review-round-<run>-<n>.md` whose bytes differ
// from review.md are A (an archive equal to review.md was written before a
// stop at this point). The previous review's round is P = 1 + |A|, derived,
// never read from the `round` field alone: a `round` field that disagrees
// is exit 2. The new round is P + 1.
// - Round MAX_REVIEW_ROUNDS + 1 is refused (exit 1); so is round 2 or later
// with `workflow` (exit 1): the --workflow path neither hands the reviewers
// the round input nor writes their outputs.
// - The round base is the previous `scope_sha_end`: a full sha that is an
// ancestor of HEAD, else exit 2 (not measured).
// - review.md is copied to `review-round-<run>-<P>.md`; an existing file
// there is kept when its bytes are equal, else exit 2.
// - Otherwise (no review.md, or another run): round 1.
// - Clean slate, every round: the regular files `.review-engine-payload-*.json`
// and `.review-out-*.md` of the project folder are deleted, so any such
// file finalize finds was written this round.
// - `dryRun` computes the same result and writes, copies and deletes nothing.
// Nothing is written before every check has passed.
//
// carry(previous, current, {marks, ran, previousRan}): a current finding
// matches a previous one in the same file with the same rule_key, or with
// `contentOverlap` >= DEFAULT_THRESHOLD (lib/review/plan-review-dedup.mjs); ids
// are never compared, and each finding matches at most one other. A previous
// finding resolves only on positive evidence of this round (the isResolved
// rule of lib/review/critic-diff.mjs, with "every reviewer that ran then"
// standing in for the raisers):
// - a matched previous finding is carried;
// - an engine finding is resolved only when its engine is in `ran.engines`,
// it was not raised again, and no reviewer marks it `resolved: false`;
// - a reviewer-only finding is resolved only when `previousRan.reviewers` is
// non-empty, every reviewer in it is in `ran.reviewers`, each marked it
// `resolved: true`, and none marked it `resolved: false`;
// - `previousRan` null (the previous review is not finalized): nothing resolves.
// An unresolved previous finding stays in `findings` with `carried_from` (the
// previous id), at the higher of the two severities; a matching current
// finding folds into it and keeps its place (and its id until finalize
// recomputes ids), so it is not counted new. A new finding keeps its severity
// wherever it cites: rule A never reaches reviews (decision 95). Two
// occurrences of one class stay two findings (`rule_key#n` ids).
//
// CLI:
// node lib/review/review-rounds.mjs prepare --project <d> --since <sha>
// [--repo <d>] [--workflow] [--dry-run] --json
// prints one JSON line {round, round_base, previous, run} or {reason};
// exit 0 = the round, 1 = refused (the cap, or round 2+ on --workflow),
// 2 = not measured (2 is never green)
// node lib/review/review-rounds.mjs --cap prints MAX_REVIEW_ROUNDS
import { spawnSync } from 'node:child_process';
import { copyFileSync, lstatSync, readFileSync, readdirSync, unlinkSync } from 'node:fs';
import { join } from 'node:path';
import { contentOverlap, DEFAULT_THRESHOLD } from './plan-review-dedup.mjs';
import { FULL_SHA_RE, reviewedSha } from './reviewed-commit.mjs';
import { scopeShas } from './pre-existing.mjs';
import { getRule } from './rule-catalogue.mjs';
import { isMain } from '../util/is-main.mjs';
export const MAX_REVIEW_ROUNDS = 3;
const SEVERITY_RANK = ['BLOCKER', 'MAJOR', 'MINOR', 'SUGGESTION'];
const CLEAN_SLATE_RE = /^(?:\.review-engine-payload-.+\.json|\.review-out-.+\.md)$/;
/** The run id of a round: the first 12 hex of the run's `scope_sha_start`. */
export const runId = (sha) => String(sha ?? '').toLowerCase().slice(0, 12);
/** The archive name of round `n` of run `run`. */
export const archiveName = (run, n) => `review-round-${run}-${n}.md`;
/** A frontmatter field as written (raw), or null when absent. */
export function frontmatterField(text, key) {
const fm = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/.exec(String(text ?? ''));
if (!fm) return null;
const m = new RegExp(`^${key}:[ \\t]*(.*?)[ \\t]*$`, 'm').exec(fm[1]);
return m ? m[1] : null;
}
/** A regular file's bytes, or null (missing, a symlink, a directory, unreadable). */
function readRegular(p) {
try {
if (!lstatSync(p).isFile()) return null;
return readFileSync(p);
} catch { return null; }
}
/** The regular files of `dir` whose names match `re`. */
function regularFiles(dir, re) {
let names;
try { names = readdirSync(dir); } catch { return []; }
return names.filter((n) => re.test(n)).filter((n) => {
try { return lstatSync(join(dir, n)).isFile(); } catch { return false; }
}).sort();
}
const notMeasured = (reason, detail = '') => ({ exit: 2, reason: `${reason} — not measured${detail ? ` (${detail})` : ''}` });
/**
* The round of a run, with its archive and clean slate (see the header).
* @returns {{exit: 0, round: number, round_base: string|null, previous: string|null, run: string, archived?: boolean, deleted: string[]} | {exit: 1|2, reason: string}}
*/
export function prepare({ projectDir, since, repo, workflow = false, dryRun = false } = {}) {
if (!projectDir) return { exit: 2, reason: '--project is required' };
if (!FULL_SHA_RE.test(String(since ?? ''))) return notMeasured('--since must be a full commit id');
try {
if (!lstatSync(projectDir).isDirectory()) return notMeasured(`--project ${projectDir} is not a directory`);
} catch (e) { return notMeasured(`--project ${projectDir}: ${e.message}`); }
const run = runId(since);
const slate = regularFiles(projectDir, CLEAN_SLATE_RE);
const clean = () => {
if (dryRun) return [];
for (const n of slate) unlinkSync(join(projectDir, n));
return slate;
};
const reviewPath = join(projectDir, 'review.md');
let reviewThere = true;
try { lstatSync(reviewPath); } catch { reviewThere = false; }
const review = readRegular(reviewPath);
if (reviewThere && review === null) return notMeasured('review.md is not a readable regular file');
const text = review === null ? null : review.toString('utf8');
const sameRun = text !== null && scopeShas(text).start !== '' && scopeShas(text).start.toLowerCase() === String(since).toLowerCase();
if (!sameRun) {
// Round 1, on either path.
return { exit: 0, round: 1, round_base: null, previous: null, run, deleted: clean() };
}
const archiveRe = new RegExp(`^review-round-${run}-(\\d+)\\.md$`);
const archives = regularFiles(projectDir, archiveRe);
const others = archives.filter((n) => !readRegular(join(projectDir, n)).equals(review));
const P = 1 + others.length;
const roundField = frontmatterField(text, 'round');
if (roundField !== null && roundField !== String(P)) {
return notMeasured('round count does not match the archives', `round: ${roundField}, ${others.length} earlier archive(s) of run ${run}`);
}
const round = P + 1;
if (round > MAX_REVIEW_ROUNDS) {
return { exit: 1, reason: `review round cap reached (${MAX_REVIEW_ROUNDS}) — the operator re-plans` };
}
if (workflow) {
return { exit: 1, reason: 'from round 2 /trekreview runs on the standard path — re-run without --workflow' };
}
const base = reviewedSha(text);
if (!base || !isAncestorOfHead(repo ?? projectDir, base)) {
return notMeasured('round base is not an ancestor of HEAD');
}
const previous = archiveName(run, P);
const target = join(projectDir, previous);
let present = true;
try { lstatSync(target); } catch { present = false; }
// A file there that is not a regular file with review.md's bytes "differs".
if (present && !readRegular(target)?.equals(review)) return notMeasured(`archive ${previous} differs`);
let archived = false;
if (!dryRun && !present) {
copyFileSync(reviewPath, target);
archived = true;
}
return { exit: 0, round, round_base: base, previous, run, archived, deleted: clean() };
}
/** `git merge-base --is-ancestor <sha> HEAD` in `repo` succeeded. */
function isAncestorOfHead(repo, sha) {
const r = spawnSync('git', ['-C', repo, 'merge-base', '--is-ancestor', sha, 'HEAD'], { encoding: 'utf8' });
return !r.error && r.status === 0;
}
// ---- carry ----------------------------------------------------------------------
const sevOf = (f) => String(f?.severity ?? '').toUpperCase();
/** The more severe of two severities; an unknown one ranks as BLOCKER (never lower). */
function higher(a, b) {
const ra = SEVERITY_RANK.indexOf(a);
const rb = SEVERITY_RANK.indexOf(b);
if (ra === -1) return a || 'BLOCKER';
if (rb === -1) return b || 'BLOCKER';
return ra <= rb ? a : b;
}
const textOf = (f) => `${f?.title ?? ''} ${f?.detail ?? ''}`;
/**
* The carry rule: same file, then the same rule_key or a content overlap at
* or above the dedup threshold. Ids are never compared.
*/
export function sameFinding(a, b) {
if (!a || !b || typeof a.file !== 'string' || a.file === '' || a.file !== b.file) return false;
if (typeof a.rule_key === 'string' && a.rule_key !== '' && a.rule_key === b.rule_key) return true;
return contentOverlap(textOf(a), textOf(b)) >= DEFAULT_THRESHOLD;
}
/** Lexicographic comparison of two equal-length number lists. */
function before(a, b) {
for (let j = 0; j < a.length; j++) if (a[j] !== b[j]) return a[j] < b[j];
return false;
}
/** The current finding `p` folds into: same rule_key first, then the nearest line, then the larger overlap, then order. */
function bestMatch(p, current, taken) {
let best = -1;
let key = null;
current.forEach((c, i) => {
if (taken.has(i) || !sameFinding(p, c)) return;
const k = [
c.rule_key === p.rule_key ? 0 : 1,
Number.isInteger(c.line) && Number.isInteger(p.line) ? Math.abs(c.line - p.line) : Number.MAX_SAFE_INTEGER,
-contentOverlap(textOf(p), textOf(c)),
];
if (key === null || before(k, key)) {
best = i;
key = k;
}
});
return best;
}
/** A security finding (the review-answers.mjs `isSecurityFinding` rule; not imported, that module imports this one). */
const isSecurityLike = (f) => getRule(f?.rule_key)?.category === 'security' || f?.engine === 'security-review';
/** The folded finding: the current one's place and text, the higher severity, a security origin never lost. */
function fold(p, c) {
const out = { ...c, severity: higher(sevOf(p), sevOf(c)), carried_from: p.id };
if (isSecurityLike(p) && !isSecurityLike(c)) {
out.rule_key = p.rule_key;
if (p.engine !== undefined) out.engine = p.engine;
}
return out;
}
/** A previous finding carried as it was: no id (finalize computes it), no old marks. */
function carried(p) {
const { id: _id, pre_existing: _pre, carried_from: _cf, ...rest } = p;
return { id: null, ...rest, severity: sevOf(p) || 'BLOCKER', carried_from: p.id };
}
/** Was previous finding `p` resolved this round (positive evidence only)? */
function resolvedNow(p, { marks, ran, previousRan }) {
if (!previousRan || typeof previousRan !== 'object') return false;
const ms = (Array.isArray(marks) ? marks : []).filter((m) => m && m.id === p.id);
if (ms.some((m) => m.resolved === false)) return false;
const ranEngines = Array.isArray(ran?.engines) ? ran.engines : [];
const ranReviewers = Array.isArray(ran?.reviewers) ? ran.reviewers : [];
if (typeof p.engine === 'string' && p.engine !== '') return ranEngines.includes(p.engine);
const then = Array.isArray(previousRan.reviewers) ? previousRan.reviewers : [];
if (then.length === 0) return false;
return then.every((r) => ranReviewers.includes(r) && ms.some((m) => m.reviewer === r && m.resolved === true));
}
/**
* Carry the previous round's findings into this round's (see the header).
* @param {object[]} previous - the previous review's JSON findings
* @param {object[]} current - this round's JSON findings
* @param {{ marks?: Array<{id, resolved, reviewer}>, ran?: {engines, reviewers}, previousRan?: {engines, reviewers}|null }} ctx
* @returns {{ findings: object[], resolved: string[], folded: number }}
*/
export function carry(previous, current, { marks = [], ran = { engines: [], reviewers: [] }, previousRan = null } = {}) {
const prev = (Array.isArray(previous) ? previous : []).filter((p) => p && typeof p === 'object');
const cur = (Array.isArray(current) ? current : []).filter((c) => c && typeof c === 'object');
const findings = cur.map((c) => ({ ...c }));
const taken = new Set();
const appended = [];
const resolved = [];
for (const p of prev) {
const i = bestMatch(p, cur, taken);
if (i >= 0) {
taken.add(i);
findings[i] = fold(p, cur[i]);
continue;
}
if (resolvedNow(p, { marks, ran, previousRan })) {
if (typeof p.id === 'string') resolved.push(p.id);
continue;
}
appended.push(carried(p));
}
return { findings: [...findings, ...appended], resolved, folded: taken.size };
}
// ---- CLI ------------------------------------------------------------------------
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);
if (args[0] === '--cap') {
process.stdout.write(`${MAX_REVIEW_ROUNDS}\n`);
process.exit(0);
}
let result;
if (args[0] !== 'prepare') {
process.stderr.write('Usage: review-rounds.mjs prepare --project <d> --since <sha> [--repo <d>] [--workflow] [--dry-run] --json | --cap\n');
process.exit(2);
}
try {
result = prepare({
projectDir: opt(args, '--project'), since: opt(args, '--since'), repo: opt(args, '--repo'),
workflow: args.includes('--workflow'), dryRun: args.includes('--dry-run'),
});
} catch (e) {
result = { exit: 2, reason: `review-rounds crashed: ${e.message} — not measured` };
}
const { exit, ...rest } = result;
process.stdout.write(`${JSON.stringify(rest)}\n`);
process.exit(exit);
}

View file

@ -29,7 +29,13 @@
// open one is answered `fixed`; `decideOpenFindings`, read against
// the top-level approval log of `--repo`, else of the review file's
// git work tree, else no records). The `--json` output carries
// `unanswered` and `fixed_pending` id arrays.
// `unanswered` and `fixed_pending` id arrays. A round 2+ review that
// review-finalize.mjs did not finalize is exit 2 too (reason
// `review-not-measured: round 2+ review not finalized`): it lacks
// `finalized: true` while an archive `review-round-<run>-<n>.md` of its
// own run (<run> = the first 12 hex of `scope_sha_start`) sits beside
// it; the previous findings reach a later round only through
// finalize's carry.
// Exit 2 is NEVER green.
//
// The pure `decideVerdict` stays text-only: it never reads the answers. The
@ -49,7 +55,7 @@
// `templates/checklist-template.md:8-9`, MIT). The blocking itself is Voyage's
// own design — neither source blocks on its own.
import { readFileSync, existsSync } from 'node:fs';
import { readFileSync, existsSync, readdirSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { dirname } from 'node:path';
import { validateReviewContent, VERDICT_VALUES } from '../validators/review-validator.mjs';
@ -58,8 +64,9 @@ import { illegalBriefLowering } from './coordinator-contract.mjs';
import { reviewedSha, checkReviewedCommit } from './reviewed-commit.mjs';
import { isMain } from '../util/is-main.mjs';
import { resolveRepo } from '../util/approval-log.mjs';
import { answerRecords, decideAnswers, reviewSubject } from './review-answers.mjs';
import { preExistingCheck } from './pre-existing.mjs';
import { answerRecords, archivedReader, decideAnswers, reviewSubject } from './review-answers.mjs';
import { preExistingCheck, scopeShas } from './pre-existing.mjs';
import { frontmatterField, runId } from './review-rounds.mjs';
export const EXIT = Object.freeze({ PASS: 0, BLOCK: 1, UNDECIDED: 2 });
@ -185,6 +192,31 @@ function blockerSectionEntries(body) {
return (section.match(/^###\s+\S/gm) ?? []).length;
}
const NOT_FINALIZED = 'review-not-measured: round 2+ review not finalized';
/**
* A round 2+ review that finalize did not finalize, or null. From round 2 the
* previous findings reach review.md only through finalize's carry, so such a
* review is never read: it lacks `finalized: true` while an archive
* `review-round-<run>-<n>.md` of its own run (lib/review/review-rounds.mjs
* `runId` of `scope_sha_start`) sits beside it. A folder that cannot be
* listed is not measured either.
* @param {string} text - review.md
* @param {string|undefined} reviewPath
* @returns {string|null}
*/
export function unfinalizedRound(text, reviewPath) {
if (frontmatterField(text, 'finalized') === 'true') return null;
const { start } = scopeShas(text);
if (!reviewPath || !start) return null;
const archive = new RegExp(`^review-round-${runId(start)}-\\d+\\.md$`);
let names;
try { names = readdirSync(dirname(reviewPath)); } catch (e) {
return `review-not-measured: cannot list the review folder (${e.message})`;
}
return names.some((n) => archive.test(n)) ? NOT_FINALIZED : null;
}
/**
* The open-findings check: every counting MAJOR of an ALLOW/WARN review must be
* answered (lib/review/review-answers.mjs `decideAnswers`). K2 records come
@ -194,15 +226,25 @@ function blockerSectionEntries(body) {
* `pre_existing: true` and git agrees over `scope_sha_start..scope_sha_end`
* in `repo` (lib/review/pre-existing.mjs `preExistingCheck`); with no repo, or
* no full `scope_sha_start`, no finding is pre-existing. A BLOCKER or a
* security finding counts whatever the mark.
* security finding counts whatever the mark. A carried answer row is read
* against its archive beside the review file (`archivedReader`); with no
* review path no carried row answers. A finding with `carried_from` is never
* pre-existing: it keeps its line from the previous round's end. A round 2+
* review that was not finalized (`unfinalizedRound`) is exit 2 first.
* @param {string} text - review.md
* @param {{ repo?: string, reviewPath: string }} opts
* @returns {{ exit: 0|2, reason: string, unanswered: string[], fixed_pending: string[] }}
*/
export function decideOpenFindings(text, { repo, reviewPath } = {}) {
const notFinal = unfinalizedRound(text, reviewPath);
if (notFinal) return { exit: EXIT.UNDECIDED, reason: notFinal, unanswered: [], fixed_pending: [] };
const { records, top, problem } = answerRecords(repo ?? (reviewPath ? dirname(reviewPath) : undefined));
const subject = top && reviewPath ? reviewSubject(top, reviewPath) : undefined;
const d = decideAnswers(text, { records, reviewSubject: subject, preExisting: preExistingCheck(text, repo) });
// A carried `## Answers` row (Carried from review-round-<run>-<n>.md#<id>)
// is checked against that archive, read beside the review file.
const archived = reviewPath ? archivedReader(dirname(reviewPath)) : undefined;
const pre = preExistingCheck(text, repo);
const d = decideAnswers(text, { records, reviewSubject: subject, preExisting: (f) => !f.carried_from && pre(f), archived });
if (d.ok) return { exit: EXIT.PASS, reason: 'every counting MAJOR is answered', unanswered: [], fixed_pending: [] };
return {
exit: EXIT.UNDECIDED,