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

@ -306,6 +306,9 @@ prefix). Flow-style `findings: [a, b]` breaks the frontmatter parser. With
zero findings write `findings: []` — the empty list is the one inline form
the parser reads; a bare `findings:` is null and fails validation.
You never write `ran`, `finalized`, `round`, `round_base`, `resolved` or
`## Answers`; `lib/review/review-finalize.mjs` and `/trekreview` Phase 8.8 do.
### Body sections (in order)
1. `# Review: {task}`

View file

@ -2348,10 +2348,12 @@ the run is `stopped`, and the merge stays closed.
`{report dir}/review-round-{R}-steps.md`, and stop. A later `--resume`
finds the plan as approved and comes back to this review.
**The cap:** `TREKEXECUTE_MAX_REVIEW_ROUNDS` reviews per run, default **3**
(the first review and two fix loops; env-overridable).
A BLOCK at the cap goes to the operator: stop, and the report names every
BLOCKER still open. Each loop also takes 1 from `iterations_remaining`, which
**The cap:** `MAX_REVIEW_ROUNDS` in `lib/review/review-rounds.mjs`, **3**
reviews per run (the first and two fix loops), read with
`node ${CLAUDE_PLUGIN_ROOT}/lib/review/review-rounds.mjs --cap`; `/trekreview`
reads the same constant and refuses a fourth round.
A review at the cap with BLOCKERs or MAJORs answered "fixed" still open goes to the operator: stop, and the report names every one.
Each loop also takes 1 from `iterations_remaining`, which
bounds it a second time (the completion gate's clause 3 counts it).
Borrowed: the order (run the thing, simplify, review) is Cherny's; a verdict in
@ -3235,7 +3237,7 @@ set from the effort.
itself (Phase 7.2). Exit 1 (BLOCK) or 2 (not measured) from the verdict
gate keeps the merge into main closed (Phase 8). A BLOCKER is fixed by new
numbered plan steps the operator approves, in a loop capped at
`TREKEXECUTE_MAX_REVIEW_ROUNDS`, never by improvising. When the lock
`MAX_REVIEW_ROUNDS` (`lib/review/review-rounds.mjs`), never by improvising. When the lock
opens, the guard asks before the merge, and the executor never asks again.
Exit 2 from the step check (K9), the architecture rules (K5) or the
plan-file sweep (K6) is "not measured", never green.

View file

@ -155,6 +155,28 @@ Determine the "before" SHA that bounds the review:
Compute the "after" SHA: `after_sha=$(git rev-parse HEAD)`.
**Then the round.** Rounds converge, at most `MAX_REVIEW_ROUNDS` (**3**, in
`lib/review/review-rounds.mjs`) per run:
```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/review/review-rounds.mjs prepare --project "{project_dir}" --since {before_sha} --json
```
Add `--workflow` when `workflow_substrate == true` and `--dry-run` when
`mode == dry-run`. It prints `{round, round_base, previous}`; keep all three for
Phases 5 and 7. A later round is one where `{project_dir}/review.md` has this
`scope_sha_start` (the same run): `prepare` counts the round from the run's
archives, keeps the previous review as `{project_dir}/{previous}`
(`review-round-<run>-<n>.md`, `<run>` = the first 12 hex of `before_sha`), and
`round_base` is its `scope_sha_end`. Every round it deletes the previous
round's `.review-out-*.md` and `.review-engine-payload-*.json` files, so each
such file finalize reads was written this round. Exit 1 stops: the round cap is
reached (the operator re-plans), or a round 2 or later was asked on
`--workflow` (re-run without it). Exit 2 stops "not measured" (the round base
is not an ancestor of HEAD, the round count does not match the archives, or an
archive differs). Print the reason in one line; no review is written in either
case.
Capture working-tree changes (uncommitted at review time):
```bash
git diff --name-only "$before_sha".."$after_sha"
@ -406,6 +428,21 @@ Each reviewer prompt includes:
- **Brief path** — `{brief_path}` (read on demand; do not inline).
- **Rule catalogue** — reference to `lib/review/rule-catalogue.mjs`.
**From round 2** (`round` ≥ 2 from Phase 3), each reviewer prompt also gets
the round input:
- the round diff, `git diff {round_base}..{after_sha}`, beside the run's diff;
- the previous findings, from the trailing JSON block of
`{project_dir}/{previous}`, each with its id, to mark in the reviewer's own
JSON block as `previous: [{id, resolved}]` (`resolved: true` only when the
round diff fixes it);
- the line "you may read the files around it; a previous finding is never
re-raised as new; flag every occurrence of a finding class".
Finalize (Phase 7) carries a previous finding until every reviewer that ran in
its round marks it resolved, and an engine finding until its engine ran this
round and did not raise it again. The engines (Phase 4.7) stay on the run range
(`/security-review` takes no range).
`brief-conformance-reviewer` additionally receives `sc_evidence_block` from
Phase 4.5, pasted verbatim — the command, exit code and first line of output for
every Success Criterion. It is the ONLY evidence that agent has about whether a
@ -628,13 +665,30 @@ parser at `lib/util/frontmatter.mjs` does not support flow-style arrays.
**Then finalize the review** — one deterministic pass after `fill-finding-ids`:
```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/review/review-finalize.mjs --review "{project_dir}/review.md" --repo . --project "{project_dir}" --round 1
node ${CLAUDE_PLUGIN_ROOT}/lib/review/review-finalize.mjs --review "{project_dir}/review.md" --repo . --project "{project_dir}" --round {round}
```
On exit 1 or 2 the file stays as `fill-finding-ids` wrote it; print the reason in one line and continue.
From round 2 it also gets the round base and the previous review, as `prepare`
printed them, and the carried answers follow it:
```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/review/review-finalize.mjs --review "{project_dir}/review.md" --repo . --project "{project_dir}" --round {round} --round-base {round_base} --previous "{project_dir}/{previous}"
node ${CLAUDE_PLUGIN_ROOT}/lib/review/review-answers.mjs carry --review "{project_dir}/review.md" --previous "{project_dir}/{previous}" --repo .
```
In round 1, on exit 1 or 2 the file stays as `fill-finding-ids` wrote it; print the reason in one line and continue.
From round 2, finalize exit 1 or 2 stops the review: print the reason and "not measured", and stop. The previous
findings reach review.md only through finalize's carry, so the verdict gate reads a round 2+ review without
`finalized: true` as exit 2 (`review-not-measured: round 2+ review not finalized`), never green.
Finalize writes `round`, `round_base`, `finalized: true`, `ran` (what ran this round, read from the
`.review-out-*.md` and `.review-engine-payload-*.json` files) and, from round 2, `resolved` with a
`## Resolved since round <n>` section; a carried BLOCKER gets its `### Step R<n>`. `review-answers.mjs carry`
writes an `## Answers` row with `Carried from` for each carried MAJOR the operator accepted in an
earlier round; on exit 1 or 2 print the reason in one line and continue: no answer is carried.
A finding is pre-existing when it sits on a line the range `scope_sha_start..scope_sha_end` did not add or
change; finalize marks it from git, the verdict gate re-derives the mark, and a BLOCKER or security finding
counts in full whatever the mark.
counts in full whatever the mark. A carried finding (`carried_from`) keeps its line from the previous round's end,
so it is never pre-existing, whatever the mark.
**The verdict is in the file (K7).** The frontmatter carries
`verdict: ALLOW | WARN | BLOCK`, equal to the trailing JSON block's

View file

@ -436,7 +436,7 @@ missing `exit_code` (C15, `PROGRESS_FAILED_CRITERIA_KEY`).
**Path conventions:**
- Project-dir mode (recommended): `{project_dir}/review.md` (one per review iteration; subsequent runs overwrite atomically).
- Multiple review iterations are allowed in the same project; each overwrites the canonical path. Audit trail lives in git history.
- Multiple review iterations are allowed in the same project; each overwrites the canonical path. Audit trail lives in git history. Before round 2 and later, the previous `review.md` is kept as `review-round-<run>-<n>.md` beside it (`<run>` = first 12 hex of the run's `scope_sha_start`); those archives are read by the next round and by the gate for a carried answer.
**Frontmatter schema:**

View file

@ -83,7 +83,7 @@ If `{project_dir}/architecture/overview.md` exists (typically produced by an opt
| `--gates [true\|false\|closed\|open]` | (v3.4.0) Autonomy flag: bare, `true` or `closed` = on; `false` or `open` = off; any other next token is not its value. See `## Autonomy mode` in `docs/operations.md`. `/trekexecute` accepts the flag and pauses on nothing; it adds no question of its own before a merge: the guard asks there (operator decision 2026-10-08). |
| `--profile <name>` | (v4.1.0) Profile for this run's resolver; the step helper and the controller stay on opus. Inherited from plan.md frontmatter `profile:` if present. |
Every mode that executes refuses a plan whose approval in the approval log does not cover its current sha256 (K2). A run ends with the review it starts by itself (`/simplify` → `/trekreview` → verdict gate); a BLOCK or an undecided verdict keeps the merge into main closed. `TREKEXECUTE_MAX_REVIEW_ROUNDS` (default 3) caps the reviews per run.
Every mode that executes refuses a plan whose approval in the approval log does not cover its current sha256 (K2). A run ends with the review it starts by itself (`/simplify` → `/trekreview` → verdict gate); a BLOCK or an undecided verdict keeps the merge into main closed. `MAX_REVIEW_ROUNDS` in `lib/review/review-rounds.mjs` (3) caps the reviews per run; `/trekreview` refuses a fourth round.
## /trekreview modes

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,

View file

@ -22,10 +22,10 @@
| trekbrief-stats | ts, task, slug, mode, interview_turns, review_iterations, brief_quality, research_topics, auto_research, auto_result, intent_approved, project_dir, order_id, order_claimed_at, order_reason | commands/trekbrief.md (orchestrator-emit Phase 7; order_* added by lib/stats/stats-append.mjs) | trekbrief.md:1141-1165 | profile, phase_models, profile_source | none |
| trekresearch-stats | ts, question, mode, scope, engine, slug, project_dir, brief_path, dimensions, dimensions_baseline, dimensions_baseline_preserved, effort, conv_turns, empty_turns, unique_sources, agents_local, agents_external, gemini_used, confidence, contradictions, open_questions | lib/stats/stats-append.mjs (trekresearch.md Stats tracking) | trekresearch.md:829-862 | profile, phase_models, parallel_agents, external_research_enabled, profile_source | none |
| trekplan-stats | ts, task, mode, slug, brief_path, project_dir, codebase_size, codebase_files, agents_deployed, deep_dives, research_briefs_used, research_scout_used, critic_verdict, guardian_verdict, outcome | lib/stats/stats-append.mjs (trekplan.md Phase 12) | trekplan.md:1307-1335 | profile, phase_models, parallel_agents, profile_source | none |
| trekexecute-stats (Phase 9 record) | ts, plan, plan_type, mode, result, steps_total, steps_passed, steps_failed, steps_skipped, failed_at_step | lib/stats/stats-append.mjs (trekexecute.md Phase 9) | trekexecute.md:2990-3012 | profile, phase_models, profile_source | none |
| trekexecute-stats (Phase 9 record) | ts, plan, plan_type, mode, result, steps_total, steps_passed, steps_failed, steps_skipped, failed_at_step | lib/stats/stats-append.mjs (trekexecute.md Phase 9) | trekexecute.md:2992-3014 | profile, phase_models, profile_source | none |
| trekexecute-stats (autonomy events) | ts, event, known_event, payload | lib/stats/event-emit.mjs `emit()` | event-emit.mjs:78-99 | payload.profile, payload.phase_models, payload.profile_source | none |
| trekexecute-stats (PostToolUse Bash) | ts, session_id, command_excerpt, duration_ms, success | hooks/scripts/post-bash-stats.mjs (Bash PostToolUse) | post-bash-stats.mjs:42-54 | none (hook is plugin-level, not profile-aware) | command_excerpt (CWE-212) |
| trekreview-stats | ts, slug, verdict, counts (BLOCKER/MAJOR/MINOR/SUGGESTION), reviewed_files_count, mode, duration_ms | lib/stats/stats-append.mjs (trekreview.md Phase 8) | trekreview.md:654-704 | profile, phase_models, profile_source | none |
| trekreview-stats | ts, slug, verdict, counts (BLOCKER/MAJOR/MINOR/SUGGESTION), reviewed_files_count, mode, duration_ms | lib/stats/stats-append.mjs (trekreview.md Phase 8) | trekreview.md:708-758 | profile, phase_models, profile_source | none |
| trekcontinue-stats | ts, project, next_session_label, status | lib/stats/stats-append.mjs (trekcontinue.md Phase 5) | trekcontinue.md:304-308 | profile, profile_source | none |
| token-usage | ts, session_id, scope, model, tokens_input, tokens_output, tokens_cache_creation, tokens_cache_read, cost_usd, is_estimate, price_table_version | lib/stats/token-usage.mjs `captureTokenUsage` (via hooks/scripts/otel-export.mjs Stop hook, opt-in `VOYAGE_TOKEN_METER`) | token-usage.mjs:buildRecord | n/a (SKAL-2 schema) | session_id (stripped at export) |

View file

@ -56,11 +56,12 @@ test('P11: the review runs by itself after Phase 7, in Cherny\'s order, before P
test('P11: a BLOCKER becomes new numbered plan steps, in a bounded loop that ends with the operator', () => {
const p = section(CMD, '## Phase 7.2 — Simplify, review, verdict (P11)');
assert.match(p, /TREKEXECUTE_MAX_REVIEW_ROUNDS/, 'the cap is named');
assert.match(p, /default \*\*3\*\*/);
assert.match(p, /`MAX_REVIEW_ROUNDS`/, 'the cap is named');
assert.match(p, /lib\/review\/review-rounds\.mjs/, 'the constant\'s module is named');
assert.match(p, /\*\*3\*\*/);
assert.match(p, /### Step R<n>/, 'the review\'s remediation steps are the source');
assert.match(p, /never reworded/);
assert.match(p, /at the cap[^\n]*operator/i);
assert.match(p, /at the cap with BLOCKERs or MAJORs answered "fixed" still open goes to the operator/i);
assert.match(p, /not improvised/);
});

View file

@ -8,6 +8,11 @@
// the Versioning paragraph says what 1.1 -> 1.2 changed, MIGRATION.md
// holds the 1.2 section with its N-1 window, and every other place that
// states the current review_version says 1.2.
// RND /trekreview names the round input: `review-rounds.mjs prepare` in
// Phase 3, the round diff from `{round_base}` and the previous findings
// to mark in Phase 5, and the round fields passed to finalize in Phase 7.
// CAP one cap of three rounds: /trekreview and /trekexecute name
// `review-rounds.mjs` and `MAX_REVIEW_ROUNDS`, and no env name.
// The helpers below are copied, never imported from an earlier prose test.
// Fix the SOURCE a pin guards, not the pin.
@ -114,3 +119,30 @@ test('[V12] every current review_version statement names 1.2', () => {
assert.ok(arch.includes('review_version 1.2'), 'docs/architecture.md names review_version 1.2');
assert.ok(!arch.includes('review_version 1.1'), 'docs/architecture.md no longer names review_version 1.1');
});
// --- RND / CAP: review rounds (Step 6) ----------------------------------------------
test('[RND] /trekreview names the round input: prepare, the round diff, the previous findings to mark, never re-raised as new, every occurrence', () => {
const cmd = read('commands/trekreview.md');
const p3 = section(cmd, '## Phase 3 — Discover scope SHA range');
assert.ok(p3.includes('node ${CLAUDE_PLUGIN_ROOT}/lib/review/review-rounds.mjs prepare --project "{project_dir}" --since {before_sha} --json'),
'Phase 3 runs review-rounds.mjs prepare');
const p5 = flat(section(cmd, '## Phase 5 — Launch parallel reviewers'));
assert.ok(p5.includes('git diff {round_base}..{after_sha}'), 'Phase 5 gives the reviewers the diff from {round_base}');
assert.ok(p5.includes('previous: [{id, resolved}]'), 'Phase 5 asks the reviewers to mark the previous findings as previous: [{id, resolved}]');
assert.match(p5, /a previous finding is never re-raised as new/, 'Phase 5 says a previous finding is never re-raised as new');
assert.match(p5, /flag every occurrence of a finding class/, 'Phase 5 asks for every occurrence of a finding class');
const p7 = flat(section(cmd, '## Phase 7 — Write review.md'));
for (const part of ['--round {round}', '--round-base {round_base}', '--previous "{project_dir}/{previous}"', 'review-answers.mjs carry']) {
assert.ok(p7.includes(part), `Phase 7 names ${part}`);
}
});
test('[CAP] /trekreview and /trekexecute both name review-rounds.mjs and MAX_REVIEW_ROUNDS, and neither names TREKEXECUTE_MAX_REVIEW_ROUNDS', () => {
for (const rel of ['commands/trekreview.md', 'commands/trekexecute.md']) {
const text = read(rel);
assert.ok(text.includes('review-rounds.mjs'), `${rel} names review-rounds.mjs`);
assert.ok(text.includes('MAX_REVIEW_ROUNDS'), `${rel} names MAX_REVIEW_ROUNDS`);
assert.ok(!text.includes('TREKEXECUTE_MAX_REVIEW_ROUNDS'), `${rel} no longer names TREKEXECUTE_MAX_REVIEW_ROUNDS`);
}
});

View file

@ -0,0 +1,910 @@
// tests/lib/review-rounds.test.mjs
// [RND] [CAP] [CRY] fix round 4.5b2 part B, Step 6: /trekreview rounds converge.
// RND `lib/review/review-rounds.mjs prepare` derives the round from the
// run's archives (`review-round-<run>-<n>.md`, <run> = the first 12 hex
// of scope_sha_start), checks the round base, archives the previous
// review and clears the previous round's reviewer and engine files;
// `carry` keeps a previous finding until this round gives positive
// evidence that it is resolved; review-finalize.mjs runs the carry pass
// with `--previous`, writes `ran`, `resolved`, `round`, `round_base`,
// `finalized` and an R-step for a carried BLOCKER.
// CAP one cap of three rounds (`MAX_REVIEW_ROUNDS`).
// CRY an accepted answer is carried to the next round only by a link to the
// same run's earlier archive, checked by the gate.
// Tests run the CLIs (prepare, finalize, review-answers, validator, gate) in
// throwaway git repos with no hooks; `carry` is also called directly. Each
// tagged test's main assertion is red at the step's start; a case that
// already holds there is a Control inside it.
import { after, test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { appendFileSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { computeFindingId, computeFindingIds } from '../../lib/parsers/finding-id.mjs';
import { childEnv } from '../helpers/child-env.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const ROUNDS = join(ROOT, 'lib', 'review', 'review-rounds.mjs');
const FINALIZE = join(ROOT, 'lib', 'review', 'review-finalize.mjs');
const ANSWERS = join(ROOT, 'lib', 'review', 'review-answers.mjs');
const ENGINE = join(ROOT, 'lib', 'review', 'engine-findings.mjs');
const GATE = join(ROOT, 'lib', 'review', 'verdict-gate.mjs');
const VALIDATOR = join(ROOT, 'lib', 'validators', 'review-validator.mjs');
const PROJECT_REL = '.claude/projects/2026-11-02-r';
const TRAILER = 'Co-Authored-By: Claude Test <noreply@example.invalid>';
const CCR = 'code-correctness-reviewer';
const BCR = 'brief-conformance-reviewer';
// Loaded so that each new assertion fails on its own while the module is absent.
let rounds = {};
try {
rounds = await import(pathToFileURL(ROUNDS).href);
} catch (e) {
if (existsSync(ROUNDS)) throw e;
}
const made = [];
after(() => { for (const b of made) rmSync(b, { recursive: true, force: true }); });
function g(cwd, ...args) {
const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: childEnv() });
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
return r.stdout.trim();
}
function putIn(dir, rel, text) {
mkdirSync(dirname(join(dir, rel)), { recursive: true });
writeFileSync(join(dir, rel), text);
}
const parse = (r) => { try { return JSON.parse(r.stdout); } catch { return {}; } };
const run = (args, opts = {}) => spawnSync(process.execPath, args, { encoding: 'utf8', env: childEnv(), ...opts });
// Ten lines `export const <tag><n> = <n>;`; `changes` maps a line number to new text.
const ten = (tag, changes = {}) => Array.from({ length: 10 }, (_, i) => (i + 1 in changes ? changes[i + 1] : `export const ${tag}${i + 1} = ${i + 1};`)).join('\n') + '\n';
const jsonOf = (text) => JSON.parse(text.slice(text.lastIndexOf('```json\n') + 8, text.lastIndexOf('```')));
const fmOf = (text) => text.slice(0, text.indexOf('\n---\n', 4) + 5);
/**
* A finding as a reviewer writes it. `f` holds severity, file, line and the
* optional rule_key, engine, title, detail, id and carried_from.
*/
function finding(f, i) {
return {
...(f.id ? { id: f.id } : {}),
severity: f.severity, rule_key: f.rule_key ?? 'MISSING_TEST', ...(f.engine ? { engine: f.engine } : {}),
file: f.file, line: f.line, brief_ref: 'SC1', title: f.title ?? `title ${f.file} ${f.line} ${i + 1}`,
detail: f.detail ?? `detail ${f.file} ${f.line} ${i + 1}`, recommended_action: f.action ?? 'Add a test.',
...(f.carried_from ? { carried_from: f.carried_from } : {}),
};
}
/**
* A review_version 1.2 review of `start..end`, ids filled as fill-finding-ids
* fills them (computed over the array). One `### Step R<n>` per BLOCKER
* unless `steps` names them ([{title, source}]).
*/
function rndReview({ start, end, findings, verdict, steps, extraSteps = '' }) {
const list = findings.map(finding);
const ids = computeFindingIds(list);
list.forEach((f, i) => { if (!f.id) f.id = ids[i]; });
const ordered = list.map(({ id, ...rest }) => ({ id, ...rest }));
const counts = { BLOCKER: 0, MAJOR: 0, MINOR: 0, SUGGESTION: 0 };
for (const f of ordered) counts[f.severity] += 1;
const v = verdict ?? (counts.BLOCKER > 0 ? 'BLOCK' : counts.MAJOR > 0 ? 'WARN' : 'ALLOW');
const json = JSON.stringify({ verdict: v, counts, allow_blocked_by: [], findings: ordered }, null, 2);
const sections = ['BLOCKER', 'MAJOR', 'MINOR', 'SUGGESTION'].map((sev) => {
const of = ordered.filter((f) => f.severity === sev);
if (of.length === 0) return '';
return `## Findings (${sev})\n\n${of.map((f) => `### ${f.id}\n\n- file: ${f.file}\n- line: ${f.line}\n- rule_key: ${f.rule_key}\n- title: ${f.title}\n`).join('\n')}\n`;
}).join('');
const stepList = steps ?? ordered.filter((f) => f.severity === 'BLOCKER').map((f) => ({ title: `fix ${f.title}`, source: f.id, file: f.file }));
const stepText = stepList.map((s, i) => `### Step R${i + 1}: ${s.title}\n\n- **Files:** \`${s.file ?? 'a.mjs'}\`\n- **Source finding:** \`${s.source}\`\n- **Test first:** a test that fails today\n- **Verify:** re-run /trekreview\n- **On failure:** escalate\n`).join('\n');
return {
ids: ordered.map((f) => f.id),
text: `---
type: trekreview
review_version: "1.2"
task: "t"
slug: r
project_dir: ${PROJECT_REL}/
brief_path: ${PROJECT_REL}/brief.md
scope_sha_start: ${start}
scope_sha_end: ${end}
reviewed_files_count: 2
verdict: ${v}
findings:
${ordered.length ? ordered.map((f) => ` - ${f.id}`).join('\n') : ' []'}
---
# Review: t
## Executive Summary
x
## Coverage
| File | Treatment | Reason |
|------|-----------|--------|
| a.mjs | deep-review | default |
${sections}## Declined to Judge
None.
Trimmed: 0
## Remediation Steps
${stepText || 'None.\n'}${extraSteps}
## Remediation Summary
x
\`\`\`json
${json}
\`\`\`
`.replace('findings:\n []', 'findings: []'),
};
}
/**
* A throwaway repo: start (a.mjs, b.mjs, ten lines each), c1 (a.mjs and b.mjs
* line 3 changed: round 1's work), c2 (a.mjs line 5 changed: round 2's work).
* HEAD is c2. The project folder is excluded from git, as a local artifact.
*/
function rndRepo() {
const base = mkdtempSync(join(tmpdir(), 'review-rounds-rnd-'));
made.push(base);
const repo = join(base, 'repo');
mkdirSync(repo, { recursive: true });
mkdirSync(join(base, 'hooks-none'));
g(repo, 'init', '-q', '-b', 'main');
appendFileSync(join(repo, '.git', 'config'), '[user]\n\tname = Test\n\temail = test@example.invalid\n'
+ `[commit]\n\tgpgsign = false\n[core]\n\thooksPath = "${join(base, 'hooks-none')}"\n`);
appendFileSync(join(repo, '.git', 'info', 'exclude'), '.claude/projects/\n');
const commit = (files, msg) => {
for (const [rel, text] of Object.entries(files)) putIn(repo, rel, text);
g(repo, 'add', '-A');
g(repo, 'commit', '-q', '-m', msg);
return g(repo, 'rev-parse', 'HEAD');
};
const start = commit({ 'a.mjs': ten('a'), 'b.mjs': ten('b') }, 'chore: start');
const c1 = commit({ 'a.mjs': ten('a', { 3: 'export const a3 = 33;' }), 'b.mjs': ten('b', { 3: 'export const b3 = 33;' }) }, 'feat: round 1');
const c2 = commit({ 'a.mjs': ten('a', { 3: 'export const a3 = 33;', 5: 'export const a5 = 55;' }) }, 'feat: round 2');
const project = join(repo, ...PROJECT_REL.split('/'));
mkdirSync(project, { recursive: true });
const reviewPath = join(project, 'review.md');
const fx = {
base, repo, start, c1, c2, project, reviewPath, run: start.slice(0, 12), commit,
write: (text, p = reviewPath) => writeFileSync(p, text),
read: (p = reviewPath) => readFileSync(p, 'utf8'),
archive: (n, runId = fx.run) => join(project, `review-round-${runId}-${n}.md`),
/** Every file name in the project folder with its bytes. */
snapshot: () => Object.fromEntries(readdirSync(project).sort().map((n) => [n, readFileSync(join(project, n), 'utf8')])),
prepare: (extra = [], since = start) => run([ROUNDS, 'prepare', '--project', project, '--since', since, '--json', ...extra], { cwd: repo }),
finalize: (round, extra = []) => run([FINALIZE, '--review', reviewPath, '--repo', repo, '--project', project,
'--round', String(round), ...extra], { cwd: repo }),
finalize2: () => fx.finalize(2, ['--round-base', c1, '--previous', fx.archive(1)]),
gate: () => run([GATE, '--json', reviewPath, '--repo', repo]),
validate: () => run([VALIDATOR, '--strict', reviewPath]),
/** A reviewer's whole answer, saved as Phase 5 saves it; `previous` marks previous findings by id. */
out: (reviewer, previous = [], findings = []) => writeFileSync(join(project, `.review-out-${reviewer}.md`),
`Reviewer prose.\n\n\`\`\`json\n${JSON.stringify({ reviewer, findings, previous }, null, 2)}\n\`\`\`\n`),
/** An engine payload, converted by the real engine-findings.mjs CLI. */
engine: (engine, findings = []) => {
const input = join(base, `engine-${engine}.json`);
writeFileSync(input, JSON.stringify({ engine, findings }));
const r = run([ENGINE, input]);
assert.equal(r.status, 0, `engine-findings: ${r.stdout}${r.stderr}`);
writeFileSync(join(project, `.review-engine-payload-${engine}.json`), r.stdout);
},
};
return fx;
}
/** Round 1 written, finalized and archived by prepare: the folder at the start of round 2. */
function roundOne(fx, findings, { outs = [CCR, BCR], engines = ['code-review', 'security-review'], finalize = true } = {}) {
const r1 = rndReview({ start: fx.start, end: fx.c1, findings });
fx.write(r1.text);
for (const name of outs) fx.out(name);
for (const e of engines) fx.engine(e);
if (finalize) {
const f = fx.finalize(1);
assert.equal(f.status, 0, `round-1 finalize: ${f.stdout}${f.stderr}`);
}
const p = fx.prepare();
assert.equal(p.status, 0, `prepare for round 2: ${p.stdout}${p.stderr}`);
return { ...r1, prev: jsonOf(fx.read(fx.archive(1))) };
}
/** Round 2's coordinator output, with fresh reviewer and engine files. */
function roundTwo(fx, findings, { outs = {}, engines = ['code-review', 'security-review'], ...opts } = {}) {
const r2 = rndReview({ start: fx.start, end: fx.c2, findings, ...opts });
fx.write(r2.text);
for (const [name, marks] of Object.entries(outs)) fx.out(name, marks);
for (const e of engines) fx.engine(e);
return r2;
}
const MAJ_A3 = { severity: 'MAJOR', file: 'a.mjs', line: 3, title: 'Parser drops the last token', detail: 'parse() stops one token early' };
const BLK_B3 = { severity: 'BLOCKER', file: 'b.mjs', line: 3, rule_key: 'BROKEN_SUCCESS_CRITERION', title: 'Build output is never written', detail: 'write() returns before the flush' };
// ---- [RND] range and the fail-closed base ----------------------------------------
test('[RND] from round 2, prepare returns the previous scope_sha_end as round_base', () => {
assert.ok(existsSync(ROUNDS), 'lib/review/review-rounds.mjs must exist');
const fx = rndRepo();
const r1 = rndReview({ start: fx.start, end: fx.c1, findings: [MAJ_A3] });
fx.write(r1.text);
const r = fx.prepare();
assert.equal(r.status, 0, r.stdout + r.stderr);
const out = parse(r);
assert.equal(out.round, 2, r.stdout);
assert.equal(out.round_base, fx.c1, r.stdout);
assert.equal(out.previous, `review-round-${fx.run}-1.md`, r.stdout);
assert.equal(fx.read(fx.archive(1)), r1.text, 'the previous review is archived byte for byte');
});
test('[RND] prepare exits 2 when the previous scope_sha_end is not an ancestor of HEAD', () => {
assert.ok(existsSync(ROUNDS), 'lib/review/review-rounds.mjs must exist');
const fx = rndRepo();
// A side branch from c1, as in [R6] verdict-gate.test.mjs.
g(fx.repo, 'checkout', '-q', '-b', 'side', fx.c1);
const side = fx.commit({ 'b.mjs': ten('b', { 3: 'export const b3 = 33;', 9: 'export const b9 = 99;' }) }, 'feat: side');
g(fx.repo, 'checkout', '-q', 'main');
fx.write(rndReview({ start: fx.start, end: side, findings: [MAJ_A3] }).text);
const before = fx.snapshot();
const r = fx.prepare();
assert.equal(r.status, 2, r.stdout + r.stderr);
assert.match(String(parse(r).reason), /round base is not an ancestor of HEAD — not measured/, r.stdout);
assert.deepEqual(fx.snapshot(), before, 'a refused prepare archives nothing');
// A short sha is no round base either.
fx.write(rndReview({ start: fx.start, end: fx.c1.slice(0, 12), findings: [MAJ_A3] }).text);
const s = fx.prepare();
assert.equal(s.status, 2, s.stdout + s.stderr);
// Control: the round-1 end on main is an ancestor of HEAD.
fx.write(rndReview({ start: fx.start, end: fx.c1, findings: [MAJ_A3] }).text);
const c = fx.prepare(['--dry-run']);
assert.equal(c.status, 0, `Control: ${c.stdout}${c.stderr}`);
});
// ---- [RND] carry -------------------------------------------------------------------
const P = (f, i = 0) => ({ id: computeFindingId(f.file, f.line, f.rule_key ?? 'MISSING_TEST'), ...finding(f, i) });
test('[RND] a previous finding is carried until marked resolved by every reviewer', () => {
assert.equal(typeof rounds.carry, 'function', 'review-rounds.mjs exports carry');
const p = P(MAJ_A3);
const both = { engines: [], reviewers: [CCR, BCR] };
const one = rounds.carry([p], [], { marks: [{ id: p.id, resolved: true, reviewer: CCR }], ran: both, previousRan: both });
assert.equal(one.findings.length, 1, JSON.stringify(one));
assert.equal(one.findings[0].carried_from, p.id);
assert.equal(one.findings[0].severity, 'MAJOR');
assert.deepEqual(one.resolved, []);
const all = rounds.carry([p], [], {
marks: [{ id: p.id, resolved: true, reviewer: CCR }, { id: p.id, resolved: true, reviewer: BCR }], ran: both, previousRan: both,
});
assert.deepEqual(all.findings, []);
assert.deepEqual(all.resolved, [p.id]);
});
test('[RND] a repeated finding is folded and not counted as new', () => {
const fx = rndRepo();
roundOne(fx, [MAJ_A3]);
// The same finding raised again with other words: same file, same rule_key.
roundTwo(fx, [{ ...MAJ_A3, title: 'Last token lost by the parser' }], { outs: { [CCR]: [] } });
const f = fx.finalize2();
assert.equal(f.status, 0, f.stdout + f.stderr);
const payload = jsonOf(fx.read());
assert.equal(payload.findings.length, 1, JSON.stringify(payload.findings));
assert.equal(payload.findings[0].carried_from, computeFindingId('a.mjs', 3, 'MISSING_TEST'), 'the re-raised finding is the carried one');
assert.deepEqual(payload.counts, { BLOCKER: 0, MAJOR: 1, MINOR: 0, SUGGESTION: 0 }, 'one MAJOR, not two');
});
test('[RND] a new MAJOR outside the round diff keeps MAJOR', () => {
assert.equal(typeof rounds.carry, 'function', 'review-rounds.mjs exports carry');
const fresh = P({ severity: 'MAJOR', file: 'b.mjs', line: 3, title: 'New thing' });
const r = rounds.carry([], [fresh], { marks: [], ran: { engines: [], reviewers: [CCR] }, previousRan: { engines: [], reviewers: [CCR] } });
assert.deepEqual(r.findings.map((x) => [x.severity, x.carried_from]), [['MAJOR', undefined]]);
// In a finalized round 2: b.mjs:3 changed in round 1 only (outside c1..c2), and it still counts as MAJOR.
const fx = rndRepo();
roundOne(fx, [MAJ_A3]);
roundTwo(fx, [MAJ_A3, { severity: 'MAJOR', file: 'b.mjs', line: 3, title: 'New thing' }], { outs: { [CCR]: [] } });
const f = fx.finalize2();
assert.equal(f.status, 0, f.stdout + f.stderr);
const fresh2 = jsonOf(fx.read()).findings.find((x) => x.file === 'b.mjs');
assert.equal(fresh2.severity, 'MAJOR');
assert.equal(fresh2.carried_from, undefined);
const gate = fx.gate();
assert.equal(gate.status, 2, gate.stdout);
assert.ok(parse(gate).unanswered.includes(fresh2.id), `the new MAJOR counts: ${gate.stdout}`);
});
test('[RND] two occurrences of one class stay two findings', () => {
assert.equal(typeof rounds.carry, 'function', 'review-rounds.mjs exports carry');
const ran = { engines: [], reviewers: [CCR] };
const p1 = P({ severity: 'MAJOR', file: 'a.mjs', line: 3 });
const p2 = P({ severity: 'MAJOR', file: 'a.mjs', line: 8 });
const both = rounds.carry([p1, p2], [P({ severity: 'MAJOR', file: 'a.mjs', line: 8 }), P({ severity: 'MAJOR', file: 'a.mjs', line: 3 })],
{ marks: [], ran, previousRan: ran });
assert.deepEqual(both.findings.map((x) => `${x.line}<-${x.carried_from}`).sort(), [`3<-${p1.id}`, `8<-${p2.id}`]);
// One previous occurrence, two current ones on the same line: one carried, one new, two ids (rule_key#2).
const fx = rndRepo();
roundOne(fx, [{ severity: 'MAJOR', file: 'a.mjs', line: 3 }]);
roundTwo(fx, [{ severity: 'MAJOR', file: 'a.mjs', line: 3 }, { severity: 'MAJOR', file: 'a.mjs', line: 3, title: 'second' }], { outs: { [CCR]: [] } });
const f = fx.finalize2();
assert.equal(f.status, 0, f.stdout + f.stderr);
const list = jsonOf(fx.read()).findings;
assert.equal(list.length, 2, JSON.stringify(list));
assert.equal(list.filter((x) => x.carried_from).length, 1, JSON.stringify(list));
assert.deepEqual(list.map((x) => x.id), computeFindingIds(list));
assert.notEqual(list[0].id, list[1].id);
});
test('[RND] a review.md from another run starts at round 1', () => {
assert.ok(existsSync(ROUNDS), 'lib/review/review-rounds.mjs must exist');
const fx = rndRepo();
fx.write(rndReview({ start: fx.c1, end: fx.c2, findings: [MAJ_A3] }).text);
const before = fx.snapshot();
const r = fx.prepare();
assert.equal(r.status, 0, r.stdout + r.stderr);
assert.deepEqual([parse(r).round, parse(r).round_base, parse(r).previous], [1, null, null], r.stdout);
assert.deepEqual(fx.snapshot(), before, 'nothing is archived for another run');
});
// ---- [RND] engine findings ----------------------------------------------------------
const ENG_MAJ = { severity: 'MAJOR', file: 'b.mjs', line: 3, rule_key: 'CODE_REVIEW_DEFECT', engine: 'code-review', title: 'Off by one in loop' };
test('[RND] an engine MAJOR is resolved when its engine ran this round and did not raise it again', () => {
assert.equal(typeof rounds.carry, 'function', 'review-rounds.mjs exports carry');
const p = P(ENG_MAJ);
const prevRan = { engines: ['code-review'], reviewers: [CCR] };
const r = rounds.carry([p], [], { marks: [], ran: { engines: ['code-review'], reviewers: [CCR] }, previousRan: prevRan });
assert.deepEqual([r.findings.length, r.resolved], [0, [p.id]]);
});
test('[RND] an engine MAJOR is carried when its engine did not run, or raised it again, whatever a reviewer marks', () => {
assert.equal(typeof rounds.carry, 'function', 'review-rounds.mjs exports carry');
const p = P(ENG_MAJ);
const prevRan = { engines: ['code-review'], reviewers: [CCR] };
const marks = [{ id: p.id, resolved: true, reviewer: CCR }];
const notRun = rounds.carry([p], [], { marks, ran: { engines: [], reviewers: [CCR] }, previousRan: prevRan });
assert.deepEqual(notRun.findings.map((x) => x.carried_from), [p.id]);
assert.deepEqual(notRun.resolved, []);
const again = rounds.carry([p], [P({ ...ENG_MAJ, line: 4 })], { marks, ran: { engines: ['code-review'], reviewers: [CCR] }, previousRan: prevRan });
assert.deepEqual(again.findings.map((x) => x.carried_from), [p.id]);
assert.deepEqual(again.resolved, []);
});
test('[RND] a --quick round 2 (no security payload) carries every previous security-review finding', () => {
const fx = rndRepo();
const sec = { severity: 'BLOCKER', file: 'b.mjs', line: 3, rule_key: 'SECURITY_REVIEW_VULNERABILITY', engine: 'security-review', title: 'Path escapes the root' };
const { prev } = roundOne(fx, [sec, ENG_MAJ], { outs: [CCR] });
const [secId, majId] = prev.findings.map((x) => x.id);
// --quick: only /code-review ran and wrote its payload.
roundTwo(fx, [], { outs: { [CCR]: [{ id: secId, resolved: true }, { id: majId, resolved: true }] }, engines: ['code-review'] });
const f = fx.finalize2();
assert.equal(f.status, 0, f.stdout + f.stderr);
const payload = jsonOf(fx.read());
assert.deepEqual(payload.ran, { engines: ['code-review'], reviewers: [CCR] });
assert.deepEqual(payload.findings.map((x) => x.carried_from), [secId], 'the security finding is carried');
assert.deepEqual(payload.resolved, [majId], 'Control: the code-review MAJOR is resolved by a clean code-review run');
});
test('[RND] an engine that ran clean while a reviewer marks the finding resolved: false carries it', () => {
assert.equal(typeof rounds.carry, 'function', 'review-rounds.mjs exports carry');
const p = P(ENG_MAJ);
const ran = { engines: ['code-review'], reviewers: [CCR] };
const r = rounds.carry([p], [], { marks: [{ id: p.id, resolved: false, reviewer: CCR }], ran, previousRan: ran });
assert.deepEqual([r.findings.map((x) => x.carried_from), r.resolved], [[p.id], []]);
});
// ---- [RND] reviewer-only findings ---------------------------------------------------
test('[RND] a reviewer-only finding: one reviewer\'s true while the other round-1 reviewer did not run carries it; both true resolves it', () => {
const fx = rndRepo();
const { prev } = roundOne(fx, [MAJ_A3]);
const [id] = prev.findings.map((x) => x.id);
roundTwo(fx, [], { outs: { [CCR]: [{ id, resolved: true }] } });
const f = fx.finalize2();
assert.equal(f.status, 0, f.stdout + f.stderr);
const one = jsonOf(fx.read());
assert.deepEqual(one.findings.map((x) => x.carried_from), [id], 'carried: brief-conformance-reviewer ran in round 1, not in round 2');
assert.deepEqual(one.resolved, []);
roundTwo(fx, [], { outs: { [CCR]: [{ id, resolved: true }], [BCR]: [{ id, resolved: true }] } });
const f2 = fx.finalize2();
assert.equal(f2.status, 0, f2.stdout + f2.stderr);
const both = jsonOf(fx.read());
assert.deepEqual(both.findings, []);
assert.deepEqual(both.resolved, [id]);
const text = fx.read();
assert.match(text, /^## Resolved since round 1$/m, text);
assert.ok(text.slice(text.indexOf('## Resolved since round 1')).includes('Parser drops the last token'), text);
});
// ---- [RND] round count ------------------------------------------------------------------
test('[RND] a round-1 review with no round field, then prepare, returns round 2 and writes review-round-<run>-1.md; a second prepare after a stop returns round 2 again', () => {
assert.ok(existsSync(ROUNDS), 'lib/review/review-rounds.mjs must exist');
const fx = rndRepo();
const r1 = rndReview({ start: fx.start, end: fx.c1, findings: [MAJ_A3] });
assert.doesNotMatch(fmOf(r1.text), /^round:/m, 'the round-1 review has no round field (finalize failed)');
fx.write(r1.text);
const a = fx.prepare();
assert.equal(a.status, 0, a.stdout + a.stderr);
assert.equal(parse(a).round, 2, a.stdout);
assert.equal(fx.read(fx.archive(1)), r1.text);
// A stop at this point: review.md is still round 1, and the archive equals it.
const b = fx.prepare();
assert.equal(b.status, 0, b.stdout + b.stderr);
assert.deepEqual([parse(b).round, parse(b).previous], [2, `review-round-${fx.run}-1.md`], b.stdout);
assert.equal(fx.read(fx.archive(1)), r1.text, 'the archive is kept');
assert.ok(!existsSync(fx.archive(2)), 'no second archive');
});
test('[RND] an archive with other bytes is exit 2; a round field that disagrees with the archives is exit 2', () => {
assert.ok(existsSync(ROUNDS), 'lib/review/review-rounds.mjs must exist');
const fx = rndRepo();
fx.write(rndReview({ start: fx.start, end: fx.c1, findings: [MAJ_A3] }).text);
fx.write('another review\n', fx.archive(2));
const r = fx.prepare();
assert.equal(r.status, 2, r.stdout + r.stderr);
assert.match(String(parse(r).reason), new RegExp(`archive review-round-${fx.run}-2\\.md differs — not measured`), r.stdout);
assert.equal(fx.read(fx.archive(2)), 'another review\n', 'the differing archive is left alone');
rmSync(fx.archive(2));
// round: 2 with no archive of round 1.
fx.write(rndReview({ start: fx.start, end: fx.c1, findings: [MAJ_A3] }).text.replace('\nfindings:', '\nround: 2\nfindings:'));
const m = fx.prepare();
assert.equal(m.status, 2, m.stdout + m.stderr);
assert.match(String(parse(m).reason), /round count does not match the archives — not measured/, m.stdout);
});
test('[RND] prepare --dry-run returns the round and leaves the folder byte for byte unchanged', () => {
assert.ok(existsSync(ROUNDS), 'lib/review/review-rounds.mjs must exist');
const fx = rndRepo();
fx.write(rndReview({ start: fx.start, end: fx.c1, findings: [MAJ_A3] }).text);
fx.out(CCR);
fx.engine('code-review');
const before = fx.snapshot();
const r = fx.prepare(['--dry-run']);
assert.equal(r.status, 0, r.stdout + r.stderr);
assert.deepEqual([parse(r).round, parse(r).round_base], [2, fx.c1], r.stdout);
assert.deepEqual(fx.snapshot(), before);
});
test('[RND] a second run in the same folder starts at round 1, and its round 2 archives under its own run', () => {
assert.ok(existsSync(ROUNDS), 'lib/review/review-rounds.mjs must exist');
const fx = rndRepo();
fx.write(rndReview({ start: fx.start, end: fx.c1, findings: [MAJ_A3] }).text);
assert.equal(fx.prepare().status, 0);
const first = fx.read(fx.archive(1));
fx.write(rndReview({ start: fx.start, end: fx.c2, findings: [MAJ_A3] }).text);
// Run 2 starts at c1.
const one = fx.prepare([], fx.c1);
assert.equal(one.status, 0, one.stdout + one.stderr);
assert.equal(parse(one).round, 1, one.stdout);
fx.write(rndReview({ start: fx.c1, end: fx.c2, findings: [MAJ_A3] }).text);
const two = fx.prepare([], fx.c1);
assert.equal(two.status, 0, two.stdout + two.stderr);
const run2 = fx.c1.slice(0, 12);
assert.deepEqual([parse(two).round, parse(two).previous], [2, `review-round-${run2}-1.md`], two.stdout);
assert.ok(existsSync(fx.archive(1, run2)));
assert.equal(fx.read(fx.archive(1)), first, 'the first run\'s archive is untouched');
assert.deepEqual(readdirSync(fx.project).filter((n) => n.startsWith('review-round-')).sort(),
[`review-round-${fx.run}-1.md`, `review-round-${run2}-1.md`].sort());
});
test('[RND] prepare --workflow for a round 2 exits 1', () => {
assert.ok(existsSync(ROUNDS), 'lib/review/review-rounds.mjs must exist');
const fx = rndRepo();
// Control: round 1 on --workflow is allowed.
const c = fx.prepare(['--workflow']);
assert.equal(c.status, 0, `Control: ${c.stdout}${c.stderr}`);
assert.equal(parse(c).round, 1);
fx.write(rndReview({ start: fx.start, end: fx.c1, findings: [MAJ_A3] }).text);
const before = fx.snapshot();
const r = fx.prepare(['--workflow']);
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.match(String(parse(r).reason), /from round 2 \/trekreview runs on the standard path — re-run without --workflow/, r.stdout);
assert.deepEqual(fx.snapshot(), before);
});
// ---- [RND] resolution by evidence -----------------------------------------------------
test('[RND] a previous review without finalized: true resolves nothing', () => {
const fx = rndRepo();
const { prev } = roundOne(fx, [MAJ_A3, ENG_MAJ], { finalize: false });
assert.doesNotMatch(fmOf(fx.read(fx.archive(1))), /^finalized:/m);
const marks = prev.findings.map((x) => ({ id: x.id, resolved: true }));
roundTwo(fx, [], { outs: { [CCR]: marks, [BCR]: marks } });
const f = fx.finalize2();
assert.equal(f.status, 0, f.stdout + f.stderr);
const payload = jsonOf(fx.read());
assert.deepEqual(payload.findings.map((x) => x.carried_from).sort(), prev.findings.map((x) => x.id).sort());
assert.deepEqual(payload.resolved, []);
});
test('[RND] a previous review with ran.reviewers: [] resolves no reviewer finding', () => {
const fx = rndRepo();
const { prev } = roundOne(fx, [MAJ_A3], { outs: [] });
assert.deepEqual(prev.ran?.reviewers, [], 'round 1 was finalized with no reviewer output');
const [id] = prev.findings.map((x) => x.id);
roundTwo(fx, [], { outs: { [CCR]: [{ id, resolved: true }], [BCR]: [{ id, resolved: true }] } });
const f = fx.finalize2();
assert.equal(f.status, 0, f.stdout + f.stderr);
const payload = jsonOf(fx.read());
assert.deepEqual(payload.findings.map((x) => x.carried_from), [id]);
assert.deepEqual(payload.resolved, []);
});
test('[RND] a previous code-review MAJOR is resolved by a code-review payload converted from an empty engine run', () => {
const fx = rndRepo();
const { prev } = roundOne(fx, [ENG_MAJ], { outs: [CCR] });
const [id] = prev.findings.map((x) => x.id);
// Control: with no code-review payload this round, the finding is carried.
roundTwo(fx, [], { outs: { [CCR]: [] }, engines: [] });
const c = fx.finalize2();
assert.equal(c.status, 0, c.stdout + c.stderr);
assert.deepEqual(jsonOf(fx.read()).findings.map((x) => x.carried_from), [id]);
// `node lib/review/engine-findings.mjs` on {"engine":"code-review","findings":[]}.
roundTwo(fx, [], { outs: { [CCR]: [] }, engines: ['code-review'] });
const f = fx.finalize2();
assert.equal(f.status, 0, f.stdout + f.stderr);
const payload = jsonOf(fx.read());
assert.deepEqual(payload.ran.engines, ['code-review']);
assert.deepEqual(payload.findings, []);
assert.deepEqual(payload.resolved, [id]);
});
test('[RND] prepare deletes old .review-engine-payload-*.json and .review-out-*.md files', () => {
assert.ok(existsSync(ROUNDS), 'lib/review/review-rounds.mjs must exist');
const fx = rndRepo();
fx.write(rndReview({ start: fx.start, end: fx.c1, findings: [MAJ_A3] }).text);
fx.out(CCR);
fx.out(BCR);
fx.engine('code-review');
fx.engine('security-review');
writeFileSync(join(fx.project, '.review-engine-code-review.json'), '{}');
writeFileSync(join(fx.project, '.review-scope.txt'), 'a.mjs\n');
const r = fx.prepare();
assert.equal(r.status, 0, r.stdout + r.stderr);
const left = readdirSync(fx.project).sort();
assert.deepEqual(left.filter((n) => n.startsWith('.review-out-') || n.startsWith('.review-engine-payload-')), [], left.join(', '));
// Control: the engine transcription and the scope list stay.
assert.ok(left.includes('.review-engine-code-review.json') && left.includes('.review-scope.txt'), left.join(', '));
});
// ---- [RND] ids and R-steps after the carry ----------------------------------------------
/** Every `Source finding:` id under ## Remediation Steps. */
const sources = (text) => [...text.slice(text.indexOf('## Remediation Steps')).matchAll(/Source finding:\**\s*`?([0-9a-f]{40})/g)].map((m) => m[1]);
test('[RND] a finalized round-2 review with a fold and a carried BLOCKER has every id computed, the BLOCKER\'s R-step, and passes the validator and the gate', () => {
const fx = rndRepo();
const { prev } = roundOne(fx, [MAJ_A3, BLK_B3]);
const blkPrev = prev.findings.find((x) => x.severity === 'BLOCKER').id;
// Round 2 re-raises the MAJOR (one line lower: a fold) and not the BLOCKER.
roundTwo(fx, [{ ...MAJ_A3, line: 4 }], { outs: { [CCR]: [], [BCR]: [] } });
const f = fx.finalize2();
assert.equal(f.status, 0, f.stdout + f.stderr);
const text = fx.read();
const payload = jsonOf(text);
assert.equal(payload.findings.length, 2, JSON.stringify(payload.findings));
assert.deepEqual(payload.findings.map((x) => x.id), computeFindingIds(payload.findings), 'every id is the computed one');
const blk = payload.findings.find((x) => x.severity === 'BLOCKER');
assert.equal(blk.carried_from, blkPrev);
assert.equal(payload.verdict, 'BLOCK', 'the verdict is raised by the carried BLOCKER');
assert.match(fmOf(text), /^verdict: BLOCK$/m);
assert.deepEqual(payload.counts, { BLOCKER: 1, MAJOR: 1, MINOR: 0, SUGGESTION: 0 });
assert.match(fmOf(text), /^round: 2$/m);
assert.match(fmOf(text), new RegExp(`^round_base: ${fx.c1}$`, 'm'));
assert.match(fmOf(text), /^finalized: true$/m);
const fm = fmOf(text);
for (const x of payload.findings) {
assert.ok(fm.includes(` - ${x.id}\n`), `the frontmatter lists ${x.id}`);
assert.ok(text.includes(`### ${x.id}\n`), `the body has an entry for ${x.id}`);
}
assert.ok(sources(text).includes(blk.id), `the carried BLOCKER has its R-step with the recomputed id:\n${text}`);
const v = fx.validate();
assert.equal(v.status, 0, `review-validator --strict: ${v.stdout}${v.stderr}`);
const r = fx.gate();
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.equal(parse(r).reason, 'verdict BLOCK', r.stdout);
});
test('[RND] a BLOCKER re-raised with the coordinator\'s own R-step keeps exactly that one R-step; an orphan R-step makes finalize exit 1', () => {
const fx = rndRepo();
const { prev } = roundOne(fx, [BLK_B3]);
const blkPrev = prev.findings[0].id;
const r2 = roundTwo(fx, [{ ...BLK_B3, line: 4, title: 'Build output never reaches disk' }], { outs: { [CCR]: [], [BCR]: [] } });
const f = fx.finalize2();
assert.equal(f.status, 0, f.stdout + f.stderr);
const text = fx.read();
const payload = jsonOf(text);
const carried = payload.findings.find((x) => x.carried_from === blkPrev);
assert.ok(carried, `the re-raised BLOCKER is the carried one: ${JSON.stringify(payload.findings)}`);
assert.equal((text.match(/^### Step R\d+/gm) ?? []).length, 1, text);
assert.ok(text.includes('### Step R1: fix Build output never reaches disk'), 'the one R-step is the coordinator\'s');
assert.deepEqual(sources(text), [carried.id]);
const ids = new Set(payload.findings.map((x) => x.id));
assert.deepEqual(sources(text).filter((s) => !ids.has(s)), [], 'no Source finding names an absent id');
assert.equal(fx.validate().status, 0);
// A planted orphan R-step.
const orphan = '\n### Step R9: fix nothing\n\n- **Files:** `a.mjs`\n- **Source finding:** `' + 'e'.repeat(40) + '`\n- **On failure:** escalate\n';
roundTwo(fx, [{ ...BLK_B3, line: 4, title: 'Build output never reaches disk' }], { outs: { [CCR]: [], [BCR]: [] }, extraSteps: orphan });
const planted = fx.read();
assert.equal(r2.ids.length, 1);
const o = fx.finalize2();
assert.equal(o.status, 1, o.stdout + o.stderr);
assert.equal(fx.read(), planted, 'a refused finalize leaves the file as it was');
});
test('[RND] a round-2 review that finalize did not finalize is not measured: the gate exits 2', () => {
const fx = rndRepo();
roundOne(fx, [BLK_B3, MAJ_A3]);
// Round 2 re-raises nothing; the coordinator's one R-step names an id the review does not hold.
roundTwo(fx, [], { outs: { [CCR]: [], [BCR]: [] }, steps: [{ title: 'fix an old finding', source: 'f'.repeat(40) }] });
const f = fx.finalize2();
assert.equal(f.status, 1, f.stdout + f.stderr);
assert.doesNotMatch(fmOf(fx.read()), /^finalized: true$/m, 'a refused finalize leaves the file unfinalized');
const r = fx.gate();
assert.equal(r.status, 2, r.stdout + r.stderr);
assert.equal(parse(r).reason, 'review-not-measured: round 2+ review not finalized', r.stdout);
// An unfinalized round-2 review with no findings and no R-step: exit 2 as well.
roundTwo(fx, [], { outs: { [CCR]: [], [BCR]: [] } });
const n = fx.gate();
assert.equal(n.status, 2, n.stdout + n.stderr);
assert.equal(parse(n).reason, 'review-not-measured: round 2+ review not finalized', n.stdout);
// Control: the same review finalized is decided on (the carried BLOCKER blocks).
const ok = fx.finalize2();
assert.equal(ok.status, 0, `Control: ${ok.stdout}${ok.stderr}`);
const b = fx.gate();
assert.equal(b.status, 1, `Control: ${b.stdout}${b.stderr}`);
// Control: an unfinalized round-1 review with only another run's archive beside it is decided on.
const other = rndRepo();
other.write(rndReview({ start: other.start, end: other.c2, findings: [] }).text);
other.write('another run\n', other.archive(1, 'f'.repeat(12)));
const one = other.gate();
assert.equal(one.status, 0, `Control: ${one.stdout}${one.stderr}`);
});
test('[RND] a carried MAJOR whose line moved under the round\'s fix is never pre-existing: the gate keeps exit 2', () => {
const fx = rndRepo();
fx.write(rndReview({ start: fx.start, end: fx.c1, findings: [MAJ_A3] }).text);
fx.out(CCR);
fx.out(BCR);
const f1 = fx.finalize(1);
assert.equal(f1.status, 0, f1.stdout + f1.stderr);
// The round's fix puts two lines on top of a.mjs: the old line 3 is now line 5.
const c3 = fx.commit({ 'a.mjs': `// header 1\n// header 2\n${readFileSync(join(fx.repo, 'a.mjs'), 'utf8')}` }, 'fix: round 2 work');
const p = fx.prepare();
assert.equal(p.status, 0, p.stdout + p.stderr);
fx.write(rndReview({ start: fx.start, end: c3, findings: [] }).text);
fx.out(CCR);
fx.out(BCR);
const f2 = fx.finalize(2, ['--round-base', fx.c1, '--previous', fx.archive(1)]);
assert.equal(f2.status, 0, f2.stdout + f2.stderr);
const text = fx.read();
const carried = jsonOf(text).findings.find((x) => x.carried_from);
assert.ok(carried, `the MAJOR is carried: ${JSON.stringify(jsonOf(text).findings)}`);
assert.equal(carried.line, 3, 'a carried finding keeps its line');
assert.equal(carried.pre_existing, false, 'finalize never marks a carried finding pre-existing');
const r = fx.gate();
assert.equal(r.status, 2, r.stdout + r.stderr);
assert.match(String(parse(r).reason), /^review-unanswered: 1 MAJOR/, r.stdout);
// A hand-set mark does not take it out either.
fx.write(editFindings(text, (x) => (x.carried_from ? { ...x, pre_existing: true } : x)));
const h = fx.gate();
assert.equal(h.status, 2, h.stdout + h.stderr);
assert.match(String(parse(h).reason), /^review-unanswered: 1 MAJOR/, h.stdout);
});
// ---- [CAP] the cap ----------------------------------------------------------------------
test('[CAP] MAX_REVIEW_ROUNDS is 3, and --cap prints it', () => {
assert.equal(rounds.MAX_REVIEW_ROUNDS, 3);
const r = run([ROUNDS, '--cap']);
assert.equal(r.status, 0, r.stdout + r.stderr);
assert.equal(r.stdout.trim(), '3');
});
test('[CAP] prepare refuses round 4 with exit 1', () => {
assert.ok(existsSync(ROUNDS), 'lib/review/review-rounds.mjs must exist');
const fx = rndRepo();
const r3 = rndReview({ start: fx.start, end: fx.c2, findings: [MAJ_A3] }).text.replace('\nfindings:', '\nround: 3\nfindings:');
fx.write(r3);
fx.write('round 1\n', fx.archive(1));
fx.write('round 2\n', fx.archive(2));
const before = fx.snapshot();
const r = fx.prepare();
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.match(String(parse(r).reason), /review round cap reached \(3\) — the operator re-plans/, r.stdout);
assert.deepEqual(fx.snapshot(), before, 'a refused round writes nothing');
// Control: round 3 is allowed.
rmSync(fx.archive(2));
fx.write(r3.replace('round: 3', 'round: 2'));
const c = fx.prepare(['--dry-run']);
assert.equal(c.status, 0, `Control: ${c.stdout}${c.stderr}`);
assert.equal(parse(c).round, 3);
});
// ---- [CRY] carried answers ---------------------------------------------------------------
/**
* Round 1 finalized with a MAJOR on a.mjs:3, answered `accepted` through
* review-answers.mjs record (K2 record committed); prepare; round 2 re-raises
* the MAJOR; finalize carries it; review-answers.mjs carry writes the row.
*/
function cryFlow() {
const fx = rndRepo();
fx.write(rndReview({ start: fx.start, end: fx.c1, findings: [MAJ_A3] }).text);
fx.out(CCR);
const f1 = fx.finalize(1);
assert.equal(f1.status, 0, f1.stdout + f1.stderr);
const oldId = jsonOf(fx.read()).findings[0].id;
const rec = run([ANSWERS, 'record', '--review', fx.reviewPath, '--finding', oldId, '--answer', 'accepted', '--repo', fx.repo,
'--round', '1', '--store', 'local', '--trailer', TRAILER], { input: 'the parser keeps the old contract on purpose' });
assert.equal(rec.status, 0, `record: ${rec.stdout}${rec.stderr}`);
const record = parse(rec).record;
const p = fx.prepare();
assert.equal(p.status, 0, p.stdout + p.stderr);
roundTwo(fx, [MAJ_A3], { outs: { [CCR]: [] } });
const f2 = fx.finalize2();
assert.equal(f2.status, 0, f2.stdout + f2.stderr);
const carry = run([ANSWERS, 'carry', '--review', fx.reviewPath, '--previous', fx.archive(1), '--repo', fx.repo]);
return { fx, oldId, record, carry, link: `review-round-${fx.run}-1.md#${oldId}` };
}
/** The review with its one `## Answers` row's cells replaced by `fn(cells)`. */
function editRow(text, fn) {
return text.split('\n').map((l) => {
if (!/^\| [0-9a-f]{40} \| /.test(l)) return l;
return `| ${fn(l.slice(1, -1).split('|').map((c) => c.trim())).join(' | ')} |`;
}).join('\n');
}
/** The review with its JSON block's findings changed by `fn` (ids kept). */
function editFindings(text, fn) {
const at = text.lastIndexOf('```json\n') + 8;
const end = text.lastIndexOf('```');
const payload = JSON.parse(text.slice(at, end));
payload.findings = payload.findings.map(fn);
return `${text.slice(0, at)}${JSON.stringify(payload, null, 2)}\n${text.slice(end)}`;
}
test('[CRY] a round-2 review that carries an accepted MAJOR with the link to its earlier answer exits 0', () => {
const { fx, oldId, record, carry, link } = cryFlow();
assert.equal(carry.status, 0, `review-answers carry: ${carry.stdout}${carry.stderr}`);
const text = fx.read();
const newId = jsonOf(text).findings[0].id;
assert.equal(jsonOf(text).findings[0].carried_from, oldId);
assert.ok(text.includes(`| ${newId} | accepted | the parser keeps the old contract on purpose | ${record} | ${link} |`), text);
const r = fx.gate();
assert.equal(r.status, 0, r.stdout + r.stderr);
});
test('[CRY] the same answer without a carried finding and link keeps exit 2', () => {
const { fx, carry } = cryFlow();
assert.equal(carry.status, 0, `review-answers carry: ${carry.stdout}${carry.stderr}`);
const good = fx.read();
assert.equal(fx.gate().status, 0, 'the carried row opens the gate');
// Control: the row without its link.
fx.write(editRow(good, (c) => [...c.slice(0, 4), '-']));
const r = fx.gate();
assert.equal(r.status, 2, r.stdout);
assert.match(String(parse(r).reason), /^review-unanswered:/, r.stdout);
// Control: the link kept, the finding not carried.
fx.write(editFindings(good, ({ carried_from: _c, ...rest }) => rest));
const n = fx.gate();
assert.equal(n.status, 2, n.stdout);
assert.match(String(parse(n).reason), /^review-unanswered:/, n.stdout);
});
test('[CRY] a carried row whose finding no longer matches is unanswered', () => {
const { fx, carry } = cryFlow();
assert.equal(carry.status, 0, `review-answers carry: ${carry.stdout}${carry.stderr}`);
const good = fx.read();
assert.equal(fx.gate().status, 0, 'the carried row opens the gate');
// Control: the finding (same id, same carried_from) now says something else under another key.
fx.write(editFindings(good, (f) => ({ ...f, rule_key: 'MISSING_ERROR_HANDLING', title: 'Unbounded retry storm', detail: 'renderer loops forever on timeout' })));
const r = fx.gate();
assert.equal(r.status, 2, r.stdout);
assert.match(String(parse(r).reason), /^review-unanswered:/, r.stdout);
});
test('[CRY] a row linking to another run\'s archive keeps exit 2, even with a valid K2 record and a content match', () => {
const { fx, oldId, carry } = cryFlow();
assert.equal(carry.status, 0, `review-answers carry: ${carry.stdout}${carry.stderr}`);
const good = fx.read();
assert.equal(fx.gate().status, 0, 'the carried row opens the gate');
// Control: the same archive bytes under another run's name.
const other = 'f'.repeat(12);
copyFileSync(fx.archive(1), fx.archive(1, other));
fx.write(editRow(good, (c) => [...c.slice(0, 4), `review-round-${other}-1.md#${oldId}`]));
const r = fx.gate();
assert.equal(r.status, 2, r.stdout);
assert.match(String(parse(r).reason), /^review-unanswered:/, r.stdout);
});
test('[CRY] a row linking to an archive whose <n> is not lower than the review\'s round keeps exit 2', () => {
const { fx, oldId, carry } = cryFlow();
assert.equal(carry.status, 0, `review-answers carry: ${carry.stdout}${carry.stderr}`);
const good = fx.read();
assert.equal(fx.gate().status, 0, 'the carried row opens the gate');
// Control: the same archive bytes as round 2 of this run, in a round-2 review.
copyFileSync(fx.archive(1), fx.archive(2));
fx.write(editRow(good, (c) => [...c.slice(0, 4), `review-round-${fx.run}-2.md#${oldId}`]));
const r = fx.gate();
assert.equal(r.status, 2, r.stdout);
assert.match(String(parse(r).reason), /^review-unanswered:/, r.stdout);
// Control: a review with no round field accepts no carried link.
fx.write(good.replace(/^round: 2\n/m, ''));
const n = fx.gate();
assert.equal(n.status, 2, n.stdout);
assert.match(String(parse(n).reason), /^review-unanswered:/, n.stdout);
});
test('[CRY] an answer to one carried occurrence, linked from another carried occurrence of the same class, keeps exit 2', () => {
const fx = rndRepo();
const Y1 = { severity: 'MAJOR', file: 'a.mjs', line: 3, title: 'Parser drops the last token', detail: 'parse() stops one token early' };
const Y2 = { severity: 'MAJOR', file: 'a.mjs', line: 5, title: 'Cache never expires entries', detail: 'ttl ignored in get()' };
fx.write(rndReview({ start: fx.start, end: fx.c1, findings: [Y1, Y2] }).text);
fx.out(CCR);
const f1 = fx.finalize(1);
assert.equal(f1.status, 0, f1.stdout + f1.stderr);
const [id1, id2] = jsonOf(fx.read()).findings.map((x) => x.id);
const rec = run([ANSWERS, 'record', '--review', fx.reviewPath, '--finding', id1, '--answer', 'accepted', '--repo', fx.repo,
'--round', '1', '--store', 'local', '--trailer', TRAILER], { input: 'the parser keeps the old contract on purpose' });
assert.equal(rec.status, 0, `record: ${rec.stdout}${rec.stderr}`);
const p = fx.prepare();
assert.equal(p.status, 0, p.stdout + p.stderr);
roundTwo(fx, [Y1, Y2], { outs: { [CCR]: [] } });
const f2 = fx.finalize2();
assert.equal(f2.status, 0, f2.stdout + f2.stderr);
const carry = run([ANSWERS, 'carry', '--review', fx.reviewPath, '--previous', fx.archive(1), '--repo', fx.repo]);
assert.equal(carry.status, 0, `review-answers carry: ${carry.stdout}${carry.stderr}`);
const good = fx.read();
const g1 = jsonOf(good).findings.find((x) => x.carried_from === id1);
const g2 = jsonOf(good).findings.find((x) => x.carried_from === id2);
assert.ok(g1 && g2, `both MAJORs are carried: ${JSON.stringify(jsonOf(good).findings)}`);
const row = good.split('\n').find((l) => l.startsWith(`| ${g1.id} | accepted |`));
assert.ok(row && row.endsWith(`| review-round-${fx.run}-1.md#${id1} |`), `the carried row of the answered MAJOR:\n${good}`);
// Control: only the answered occurrence is answered.
const c = fx.gate();
assert.equal(c.status, 2, c.stdout);
assert.deepEqual(parse(c).unanswered, [g2.id], c.stdout);
// The same row copied to the other occurrence: its link names id1, the finding was carried from id2.
fx.write(good.replace(row, `${row}\n${row.replace(`| ${g1.id} |`, `| ${g2.id} |`)}`));
const r = fx.gate();
assert.equal(r.status, 2, r.stdout);
assert.match(String(parse(r).reason), /^review-unanswered:/, r.stdout);
assert.deepEqual(parse(r).unanswered, [g2.id], r.stdout);
// Control: round 3 carries the answer through the round-2 archive; a copy for the other occurrence does not.
fx.write(good);
const p3 = fx.prepare();
assert.equal(p3.status, 0, `Control: ${p3.stdout}${p3.stderr}`);
assert.equal(parse(p3).round, 3, p3.stdout);
fx.write(rndReview({ start: fx.start, end: fx.c2, findings: [Y1, Y2] }).text);
fx.out(CCR, []);
const f3 = fx.finalize(3, ['--round-base', fx.c2, '--previous', fx.archive(2)]);
assert.equal(f3.status, 0, `Control: ${f3.stdout}${f3.stderr}`);
const carry3 = run([ANSWERS, 'carry', '--review', fx.reviewPath, '--previous', fx.archive(2), '--repo', fx.repo]);
assert.equal(carry3.status, 0, `Control: ${carry3.stdout}${carry3.stderr}`);
const third = fx.read();
const h1 = jsonOf(third).findings.find((x) => x.carried_from === g1.id);
const h2 = jsonOf(third).findings.find((x) => x.carried_from === g2.id);
assert.ok(h1 && h2, `Control: both MAJORs are carried into round 3: ${JSON.stringify(jsonOf(third).findings)}`);
const row3 = third.split('\n').find((l) => l.startsWith(`| ${h1.id} | accepted |`));
assert.ok(row3 && row3.endsWith(`| review-round-${fx.run}-1.md#${id1} |`), `Control: the round-3 row links where the answer was given:\n${third}`);
const c3 = fx.gate();
assert.deepEqual(parse(c3).unanswered, [h2.id], `Control: ${c3.stdout}`);
fx.write(third.replace(row3, `${row3}\n${row3.replace(`| ${h1.id} |`, `| ${h2.id} |`)}`));
const r3 = fx.gate();
assert.equal(r3.status, 2, r3.stdout);
assert.deepEqual(parse(r3).unanswered, [h2.id], r3.stdout);
});