fix(review): an answer after a stopped prepare no longer locks the rounds

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-11-04 10:05:04 +01:00
commit d81bd39754
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
2 changed files with 479 additions and 11 deletions

View file

@ -9,18 +9,26 @@
// 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.
// later round. An archive `review-round-<run>-<n>.md` is review.md's own
// archive (written before a stop at this point) when its bytes equal
// review.md's, or when all three hold: <n> is review.md's round (the
// `round` field, or 1 when absent), its trailing JSON block hashes to the
// same value as review.md's, and its `scope_sha_end` equals review.md's
// (an answer, its R-step or any other text outside the block and
// `scope_sha_end` recorded after the stop does not make another round).
// The other archives are A. 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. 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); 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.
// there is kept when its bytes are equal, refreshed with review.md's bytes
// (`archived: true`) when it is the own archive by the rule above, 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
@ -58,8 +66,10 @@
// node lib/review/review-rounds.mjs --cap prints MAX_REVIEW_ROUNDS
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { copyFileSync, lstatSync, readFileSync, readdirSync, unlinkSync } from 'node:fs';
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';
@ -102,6 +112,16 @@ function regularFiles(dir, re) {
}).sort();
}
/**
* sha256 of the trailing fenced block's body when it is a closed `json`
* block, else null. A copy of findingsBlockSha (lib/review/review-answers.mjs,
* which imports this module, so importing it back would close a cycle).
*/
function blockSha(text) {
const last = fencedBlocks(String(text ?? '')).at(-1);
return last && last.closed && isJsonBlock(last) ? createHash('sha256').update(last.body).digest('hex') : null;
}
const notMeasured = (reason, detail = '') => ({ exit: 2, reason: `${reason} — not measured${detail ? ` (${detail})` : ''}` });
/**
@ -136,9 +156,24 @@ export function prepare({ projectDir, since, repo, workflow = false, dryRun = fa
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');
// review.md's own archive (see the header): byte-equal first, then by content.
const ownName = archiveName(run, roundField === null ? 1 : roundField);
const ownBlock = blockSha(text);
const ownEnd = reviewedSha(text);
const isOwn = (name, bytes) => {
if (bytes.equals(review)) return true;
if (name !== ownName || ownBlock === null || ownEnd === '') return false;
const t = bytes.toString('utf8');
return blockSha(t) === ownBlock && reviewedSha(t) === ownEnd;
};
const others = [];
for (const n of archives) {
const bytes = readRegular(join(projectDir, n));
if (bytes === null) return notMeasured(`archive ${n} is not readable`);
if (!isOwn(n, bytes)) others.push(n);
}
const P = 1 + others.length;
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}`);
}
@ -157,10 +192,16 @@ export function prepare({ projectDir, since, repo, workflow = false, dryRun = fa
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`);
// A file there that is not a regular file and review.md's own archive
// "differs"; the own archive with other bytes is refreshed.
let refresh = false;
if (present) {
const bytes = readRegular(target);
if (bytes === null || !isOwn(previous, bytes)) return notMeasured(`archive ${previous} differs`);
refresh = !bytes.equals(review);
}
let archived = false;
if (!dryRun && !present) {
if (!dryRun && (!present || refresh)) {
copyFileSync(reviewPath, target);
archived = true;
}