feat(review): stop rule in the review rounds (decision 217, 220)

prepare (also --dry-run) judges the previous round's archive and
review.md when it would open round 3: blocking = BLOCKER + counting MAJOR
with the verdict gate's pre-existing reading, new = no carried_from, class
= classOf. Fired -> exit 1, `review-stop-rule: <describeStop line>` and
`stop_rule`, nothing archived. The cap stays. A round with no readable
JSON block gives no summary and the rule is not judged.

countingMajors moves from review-answers.mjs into review-rounds.mjs and is
re-exported by review-answers (as isSecurityFinding is); review-rounds
still imports nothing from review-answers.

Existing test updated for the planned rule: the [CRY] occurrence case adds
a third round-1 MAJOR resolved in round 2, so its round-3 control is not
stopped by "not fewer".

Part 1, step 1.3 of the E plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-11-08 07:13:07 +01:00
commit 6b44be4c43
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
4 changed files with 164 additions and 21 deletions

View file

@ -4,8 +4,8 @@
// from git and never trusted as written: `lib/review/review-finalize.mjs`
// writes it (`markFindings`), and the verdict gate re-derives it
// (`preExistingCheck`) before a MAJOR leaves the answer set. A BLOCKER or a
// security finding counts in full whatever the mark (lib/review/review-answers.mjs
// `countingMajors`); every uncertain case counts as new.
// security finding counts in full whatever the mark (lib/review/review-rounds.mjs
// `countingMajors`, re-exported by review-answers.mjs); every uncertain case counts as new.
//
// The line sets come from one `git diff -U0` of `scope_sha_start..scope_sha_end`
// with flags that no repo config changes (`--no-color`, `--no-ext-diff`,

View file

@ -82,7 +82,7 @@ import { basename, dirname, join, resolve } from 'node:path';
import { fencedBlocks, isJsonBlock } from './findings-schema.mjs';
import { reviewedSha } from './reviewed-commit.mjs';
import { preExistingCheck, scopeShas } from './pre-existing.mjs';
import { archiveName, frontmatterField, isSecurityFinding, runId, sameFinding } from './review-rounds.mjs';
import { archiveName, countingMajors, frontmatterField, isSecurityFinding, runId, sameFinding } from './review-rounds.mjs';
import {
appendApproval, isHollowAnswer, logPath, normalizeSubject, readLog, verifyLog,
} from '../util/approval-log.mjs';
@ -203,21 +203,12 @@ export function parseAnswers(body) {
return { rows, errors };
}
/** A security finding (defined in review-rounds.mjs; re-exported here for the existing callers). */
export { isSecurityFinding };
/**
* Every MAJOR finding (case-insensitive) that counts toward the lock. A finding
* is left out only when `preExisting(f)` is true and it is not a security finding.
* @param {{findings?: object[]}} payload
* @param {{ preExisting?: (f: object) => boolean }} [opts]
* @returns {object[]}
* A security finding, and every MAJOR that counts toward the lock (both
* defined in review-rounds.mjs, which the stop rule reads them from;
* re-exported here for the existing callers).
*/
export function countingMajors(payload, { preExisting = () => false } = {}) {
const findings = Array.isArray(payload?.findings) ? payload.findings : [];
return findings.filter((f) => f && typeof f === 'object' && isMajor(f)
&& !(preExisting(f) === true && !isSecurityFinding(f)));
}
export { countingMajors, isSecurityFinding };
/** The parsed trailing JSON block, or null. */
function payloadOf(text) {

View file

@ -21,6 +21,17 @@
// disagrees is exit 2. The new round is P + 1. An archive that is a
// regular file but cannot be read is exit 2 (not measured).
// - Round MAX_REVIEW_ROUNDS + 1 is refused (exit 1).
// - The stop rule (decisions 217, 220; lib/review/stop-rule.mjs): from round
// 3 on (P >= 2; under the cap only round 3), the previous archive (round
// P - 1, the other archive with the highest <n>) and review.md (round P)
// are judged. Blocking = BLOCKER + counting MAJOR (`countingMajors`, with
// the verdict gate's pre-existing reading: a finding with no
// `carried_from` whose mark `preExistingCheck` re-derives); new = no
// `carried_from`; class = `classOf`. Fired -> exit 1 with
// `review-stop-rule: <describeStop line>` and `stop_rule`; nothing is
// archived. A round whose trailing JSON block cannot be read gives no
// summary, and the rule is then not judged (as a plan verdict without
// history). The cap stays.
// - 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
@ -59,7 +70,8 @@
// node lib/review/review-rounds.mjs prepare --project <d> --since <sha>
// [--repo <d>] [--dry-run] --json
// prints one JSON line {round, round_base, previous, run} or {reason};
// exit 0 = the round, 1 = refused (the cap),
// exit 0 = the round, 1 = refused (the cap, or the stop rule:
// `review-stop-rule: ...`, with `stop_rule`),
// 2 = not measured (2 is never green)
// node lib/review/review-rounds.mjs --cap prints MAX_REVIEW_ROUNDS
@ -70,8 +82,9 @@ import { join } from 'node:path';
import { fencedBlocks, isJsonBlock } from './findings-schema.mjs';
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 { preExistingCheck, scopeShas } from './pre-existing.mjs';
import { getRule } from './rule-catalogue.mjs';
import { decideStop, describeStop, roundSummary } from './stop-rule.mjs';
import { isMain } from '../util/is-main.mjs';
export const MAX_REVIEW_ROUNDS = 3;
@ -122,6 +135,51 @@ function blockSha(text) {
const notMeasured = (reason, detail = '') => ({ exit: 2, reason: `${reason} — not measured${detail ? ` (${detail})` : ''}` });
const isMajor = (f) => String(f?.severity ?? '').toUpperCase() === 'MAJOR';
const isBlocker = (f) => String(f?.severity ?? '').toUpperCase() === 'BLOCKER';
/**
* Every MAJOR finding (case-insensitive) that counts toward the lock. A finding
* is left out only when `preExisting(f)` is true and it is not a security finding.
* Defined here and re-exported by review-answers.mjs, which imports this module.
* @param {{findings?: object[]}} payload
* @param {{ preExisting?: (f: object) => boolean }} [opts]
* @returns {object[]}
*/
export function countingMajors(payload, { preExisting = () => false } = {}) {
const findings = Array.isArray(payload?.findings) ? payload.findings : [];
return findings.filter((f) => f && typeof f === 'object' && isMajor(f)
&& !(preExisting(f) === true && !isSecurityFinding(f)));
}
/** The parsed trailing JSON block of a review (an object), or null. */
function payloadOf(text) {
const last = fencedBlocks(String(text ?? '')).at(-1);
if (!last || !last.closed || !isJsonBlock(last)) return null;
try {
const p = JSON.parse(last.body);
return p && typeof p === 'object' && !Array.isArray(p) && Array.isArray(p.findings) ? p : null;
} catch { return null; }
}
/**
* One review round's stop-rule summary: its BLOCKERs and counting MAJORs, read
* as the verdict gate reads them (a finding with no `carried_from` leaves the
* count only when `preExistingCheck` re-derives its mark), or null when the
* review has no readable JSON block.
* @returns {{blocking: number, newByClass: Object<string, number>}|null}
*/
export function reviewRoundSummary(text, repo) {
const payload = payloadOf(text);
if (!payload) return null;
const pre = preExistingCheck(text, repo);
const blocking = [
...payload.findings.filter((f) => f && typeof f === 'object' && isBlocker(f)),
...countingMajors(payload, { preExisting: (f) => !f.carried_from && pre(f) }),
];
return roundSummary(blocking, { fullCount: false });
}
/**
* 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}}
@ -179,6 +237,19 @@ export function prepare({ projectDir, since, repo, dryRun = false } = {}) {
if (round > MAX_REVIEW_ROUNDS) {
return { exit: 1, reason: `review round cap reached (${MAX_REVIEW_ROUNDS}) — the operator re-plans` };
}
if (P >= 2) {
// The stop rule on the pair (P - 1, P): the latest other archive and review.md.
const nOf = (name) => Number(archiveRe.exec(name)[1]);
const prevName = [...others].sort((a, b) => nOf(b) - nOf(a))[0];
const prevBytes = readRegular(join(projectDir, prevName));
const at = repo ?? projectDir;
const a = prevBytes === null ? null : reviewRoundSummary(prevBytes.toString('utf8'), at);
const b = reviewRoundSummary(text, at);
if (a && b) {
const d = decideStop([{ round: P - 1, ...a }, { round: P, ...b }]);
if (d.fired) return { exit: 1, reason: `review-stop-rule: ${describeStop(d)}`, stop_rule: d };
}
}
const base = ownEnd;
if (!base || !isAncestorOfHead(repo ?? projectDir, base)) {
return notMeasured('round base is not an ancestor of HEAD');