// tests/lib/review-rounds-stopped-prepare.test.mjs // [RSP] A round-2 `prepare` that stopped (the archive of round 1 is written, // review.md is still round 1), then an answer recorded on review.md, then // `prepare` again: review.md now differs from its archive by the `## Answers` // row (and, for `fixed`, its R-step), not by its findings. `prepare` // recognises the archive as this review's own (same round number, same // trailing JSON block, same scope_sha_end), refreshes it with review.md and // returns round 2, so the answer is not counted as an extra round and is // carried through the refreshed archive. An archive that cannot be read is // "not measured", never a crash. // `keep:` cases hold at the base already and stay untagged. // Modules load through `import * as m`, so a missing export is a failed // assertion at the red base, never a crash. Every child process gets // childEnv(). The fixtures are copied from tests/lib/review-rounds.test.mjs // (`rndReview`, `rndRepo`, `cryFlow`), which earlier red wrappers list. import { after, test } from 'node:test'; import { strict as assert } from 'node:assert'; import { spawnSync } from 'node:child_process'; import { appendFileSync, chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import * as rounds from '../../lib/review/review-rounds.mjs'; import { 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 GATE = join(ROOT, 'lib', 'review', 'verdict-gate.mjs'); const PROJECT_REL = '.claude/projects/2026-11-03-s'; const TRAILER = 'Co-Authored-By: Claude Test '; const CCR = 'code-correctness-reviewer'; const REASON = 'the parser keeps the old contract on purpose'; const made = []; const locked = []; after(() => { for (const p of locked) { try { chmodSync(p, 0o644); } catch { /* gone */ } } 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 = ;`; `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('```'))); /** A finding as a reviewer writes it (copied from tests/lib/review-rounds.test.mjs `finding`). */ 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` (copied from tests/lib/review-rounds.test.mjs `rndReview`). */ function rndReview({ start, end, findings, verdict }) { 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(''); return { ids: ordered.map((f) => f.id), text: `--- type: trekreview review_version: "1.2" task: "t" slug: s 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 None. ## Remediation Summary x \`\`\`json ${json} \`\`\` `.replace('findings:\n []', 'findings: []'), }; } /** * A throwaway repo (copied from tests/lib/review-rounds.test.mjs `rndRepo`): * start, c1 (round 1's work), c2 (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-stopped-')); 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), write: (text, p = reviewPath) => writeFileSync(p, text), read: (p = reviewPath) => readFileSync(p, 'utf8'), archive: (n) => join(project, `review-round-${start.slice(0, 12)}-${n}.md`), archives: () => readdirSync(project).filter((n) => n.startsWith('review-round-')).sort(), prepare: (extra = []) => run([ROUNDS, 'prepare', '--project', project, '--since', start, '--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]), record: (id, answer) => run([ANSWERS, 'record', '--review', reviewPath, '--finding', id, '--answer', answer, '--repo', repo, '--round', '1', '--store', 'local', '--trailer', TRAILER], { input: REASON }), carry: () => run([ANSWERS, 'carry', '--review', reviewPath, '--previous', fx.archive(1), '--repo', repo]), 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`), }; return fx; } const MAJ_A3 = { severity: 'MAJOR', file: 'a.mjs', line: 3, title: 'Parser drops the last token', detail: 'parse() stops one token early' }; /** Round 1 finalized (round: 1), then a round-2 prepare that archived it and stopped. Returns the MAJOR's id. */ function stoppedRoundTwo(fx) { 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, `round-1 finalize: ${f1.stdout}${f1.stderr}`); assert.match(fx.read(), /^round: 1$/m, 'finalize wrote round: 1'); const p = fx.prepare(); assert.equal(p.status, 0, `first prepare: ${p.stdout}${p.stderr}`); assert.equal(parse(p).round, 2, p.stdout); assert.equal(fx.read(fx.archive(1)), fx.read(), 'the first prepare archived review.md byte for byte'); return jsonOf(fx.read()).findings[0].id; } /** The review with `## Remediation Steps` holding one R-step whose source is `id`. */ function withRStep(text, id) { const head = '## Remediation Steps\n\n'; const at = text.indexOf(head); assert.ok(at >= 0, 'the review has a ## Remediation Steps section'); const end = text.indexOf('\n## ', at + head.length); const step = `### Step R1: fix the parser\n\n- **Files:** \`a.mjs\`\n- **Source finding:** \`${id}\`\n` + '- **Test first:** a test that fails today\n- **Verify:** re-run /trekreview\n- **On failure:** escalate\n'; return `${text.slice(0, at + head.length)}${step}${text.slice(end)}`; } /** 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)}`; } /** `text` with one prose line added under `## Executive Summary` (outside the JSON block and the frontmatter). */ function withNote(text) { const head = '## Executive Summary\n\n'; assert.ok(text.includes(head), 'the review has a ## Executive Summary section'); return text.replace(head, `${head}A note added later.\n\n`); } /** `text` without its trailing fenced JSON block. */ function withoutBlock(text) { const at = text.lastIndexOf('```json\n'); assert.ok(at >= 0, 'the review has a trailing JSON block'); const out = text.slice(0, at); assert.equal(out.includes('```'), false, 'no fenced block is left'); return out; } /** Import statements of `text` that name review-answers (static, side-effect or dynamic). */ function answersImports(text) { const re = /(?:\bfrom\s*|\bimport\s*\(?\s*)(['"`])[^'"`]*review-answers(?:\.mjs)?\1/g; return [...String(text).matchAll(re)].map((m) => m[0]); } test('[RSP] prepare, stop, record an accepted answer, prepare again gives round 2', () => { assert.equal(typeof rounds.prepare, 'function', 'review-rounds.mjs exports prepare'); const fx = rndRepo(); const id = stoppedRoundTwo(fx); const rec = fx.record(id, 'accepted'); assert.equal(rec.status, 0, `record: ${rec.stdout}${rec.stderr}`); assert.match(fx.read(), /^## Answers$/m, 'record wrote the ## Answers row into review.md'); assert.notEqual(fx.read(fx.archive(1)), fx.read(), 'review.md now differs from its archive'); const p = fx.prepare(); assert.equal(p.status, 0, `prepare after the answer: ${p.stdout}${p.stderr}`); assert.equal(parse(p).round, 2, p.stdout); assert.equal(parse(p).previous, `review-round-${fx.run}-1.md`, p.stdout); assert.equal(parse(p).archived, true, `the own archive was refreshed: ${p.stdout}`); const archived = fx.read(fx.archive(1)); assert.match(archived, /^## Answers$/m, 'the archive now holds the ## Answers row'); assert.ok(archived.includes(`| ${id} | accepted | ${REASON} |`), archived); assert.equal(archived, fx.read(), 'the archive is review.md as it is now'); assert.deepEqual(fx.archives(), [`review-round-${fx.run}-1.md`], 'no second archive'); }); test('[RSP] the carried answer binds through the refreshed archive', () => { assert.equal(typeof rounds.prepare, 'function', 'review-rounds.mjs exports prepare'); const fx = rndRepo(); const oldId = stoppedRoundTwo(fx); const rec = fx.record(oldId, 'accepted'); assert.equal(rec.status, 0, `record: ${rec.stdout}${rec.stderr}`); const record = parse(rec).record; const p = fx.prepare(); assert.equal(p.status, 0, `prepare after the answer: ${p.stdout}${p.stderr}`); assert.equal(parse(p).round, 2, p.stdout); // Round 2 re-raises the MAJOR; finalize carries it; carry writes the row from the archive. fx.write(rndReview({ start: fx.start, end: fx.c2, findings: [MAJ_A3] }).text); fx.out(CCR, []); const f2 = fx.finalize2(); assert.equal(f2.status, 0, `round-2 finalize: ${f2.stdout}${f2.stderr}`); const c = fx.carry(); assert.equal(c.status, 0, `carry: ${c.stdout}${c.stderr}`); const text = fx.read(); const newF = jsonOf(text).findings[0]; assert.equal(newF.carried_from, oldId, text); assert.deepEqual(parse(c).written, [newF.id], `carry wrote the row: ${c.stdout}`); assert.ok(text.includes(`| ${newF.id} | accepted | ${REASON} | ${record} | review-round-${fx.run}-1.md#${oldId} |`), text); const gate = fx.gate(); assert.equal(gate.status, 0, `the carried row is bound: ${gate.stdout}${gate.stderr}`); }); test('[RSP] a fixed answer with its R-step after the stop still gives round 2', () => { assert.equal(typeof rounds.prepare, 'function', 'review-rounds.mjs exports prepare'); const fx = rndRepo(); const id = stoppedRoundTwo(fx); // /trekreview writes the R-step first; record --answer fixed refuses without it. fx.write(withRStep(fx.read(), id)); const rec = fx.record(id, 'fixed'); assert.equal(rec.status, 0, `record fixed: ${rec.stdout}${rec.stderr}`); assert.match(fx.read(), /^### Step R1: fix the parser$/m); const p = fx.prepare(); assert.equal(p.status, 0, `prepare after the fixed answer: ${p.stdout}${p.stderr}`); assert.equal(parse(p).round, 2, p.stdout); const archived = fx.read(fx.archive(1)); assert.match(archived, /^### Step R1: fix the parser$/m, 'the archive holds the R-step'); assert.ok(archived.includes(`| ${id} | fixed | ${REASON} |`), archived); assert.deepEqual(fx.archives(), [`review-round-${fx.run}-1.md`], 'no second archive'); }); test('[RSP] an unreadable archive is not measured, not a crash', () => { assert.equal(typeof rounds.prepare, 'function', 'review-rounds.mjs exports prepare'); const fx = rndRepo(); stoppedRoundTwo(fx); const rec = fx.record(jsonOf(fx.read()).findings[0].id, 'accepted'); assert.equal(rec.status, 0, `record: ${rec.stdout}${rec.stderr}`); const before = fx.read(); chmodSync(fx.archive(1), 0o000); locked.push(fx.archive(1)); let readable = true; try { readFileSync(fx.archive(1)); } catch { readable = false; } assert.equal(readable, false, 'mode 000 does not stop a read here (running as root?): this case cannot be shown, so it fails'); const p = fx.prepare(); assert.equal(p.status, 2, `prepare: ${p.stdout}${p.stderr}`); const reason = String(parse(p).reason); assert.match(reason, new RegExp(`archive review-round-${fx.run}-1\\.md is not readable`), p.stdout); assert.doesNotMatch(reason, /crashed/, p.stdout); assert.match(reason, /not measured/, p.stdout); assert.equal(fx.read(), before, 'review.md is untouched'); }); test('keep: an archive whose findings differ is still counted as an earlier round', () => { assert.equal(typeof rounds.prepare, 'function', 'review-rounds.mjs exports prepare'); const fx = rndRepo(); stoppedRoundTwo(fx); const own = fx.read(fx.archive(1)); // review.md edited elsewhere: a finding's title changed in the JSON block. fx.write(editFindings(fx.read(), (x) => ({ ...x, title: 'Another title' }))); const r = fx.prepare(); assert.equal(r.status, 2, r.stdout + r.stderr); assert.match(String(parse(r).reason), /round count does not match the archives — not measured/, r.stdout); assert.equal(fx.read(fx.archive(1)), own, 'the archive is left alone'); // review.md with another scope_sha_end (same JSON block). fx.write(own.replace(`scope_sha_end: ${fx.c1}`, `scope_sha_end: ${fx.c2}`)); assert.notEqual(fx.read(), own); const s = fx.prepare(); assert.equal(s.status, 2, s.stdout + s.stderr); assert.match(String(parse(s).reason), /round count does not match the archives — not measured/, s.stdout); assert.equal(fx.read(fx.archive(1)), own, 'the archive is left alone'); }); test('keep: review-rounds imports nothing from review-answers', () => { const real = readFileSync(ROUNDS, 'utf8'); assert.deepEqual(answersImports(real), [], 'lib/review/review-rounds.mjs imports review-answers'); // The scanner sees a planted import in a temp copy. const base = mkdtempSync(join(tmpdir(), 'review-rounds-imports-')); made.push(base); const copy = join(base, 'review-rounds.mjs'); copyFileSync(ROUNDS, copy); writeFileSync(copy, real.replace("import { isMain } from '../util/is-main.mjs';", "import { isMain } from '../util/is-main.mjs';\nimport { findingsBlockSha } from './review-answers.mjs';")); assert.equal(answersImports(readFileSync(copy, 'utf8')).length, 1, 'the planted import is found'); for (const planted of ["const m = await import('./review-answers.mjs');", "import './review-answers.mjs';"]) { assert.equal(answersImports(`${real}\n${planted}\n`).length, 1, `the planted ${planted} is found`); } // The clean copy passes again. copyFileSync(ROUNDS, copy); assert.deepEqual(answersImports(readFileSync(copy, 'utf8')), [], 'the clean copy passes'); }); test('keep: two equal archives still give the same round as before', () => { assert.equal(typeof rounds.prepare, 'function', 'review-rounds.mjs exports prepare'); const fx = rndRepo(); stoppedRoundTwo(fx); // A second archive with review.md's bytes: byte-equal archives are never counted. copyFileSync(fx.reviewPath, fx.archive(2)); const before = Object.fromEntries(fx.archives().map((n) => [n, fx.read(join(fx.project, n))])); const r = fx.prepare(); assert.equal(r.status, 0, r.stdout + r.stderr); assert.deepEqual([parse(r).round, parse(r).previous, parse(r).archived], [2, `review-round-${fx.run}-1.md`, false], r.stdout); const now = Object.fromEntries(fx.archives().map((n) => [n, fx.read(join(fx.project, n))])); assert.deepEqual(now, before, 'both archives are kept as they were'); assert.ok(existsSync(fx.archive(2))); }); test('keep: an archive under another number is counted although its block and scope_sha_end match', () => { assert.equal(typeof rounds.prepare, 'function', 'review-rounds.mjs exports prepare'); const fx = rndRepo(); stoppedRoundTwo(fx); // Archive 2: review.md's text plus one prose line; same JSON block, same scope_sha_end, other bytes. fx.write(withNote(fx.read()), fx.archive(2)); assert.notEqual(fx.read(fx.archive(2)), fx.read()); assert.equal(jsonOf(fx.read(fx.archive(2))).findings[0].id, jsonOf(fx.read()).findings[0].id); assert.match(fx.read(fx.archive(2)), new RegExp(`^scope_sha_end: ${fx.c1}$`, 'm')); const before = Object.fromEntries(fx.archives().map((n) => [n, readFileSync(join(fx.project, n))])); assert.deepEqual(Object.keys(before), [`review-round-${fx.run}-1.md`, `review-round-${fx.run}-2.md`]); const r = fx.prepare(); assert.equal(r.status, 2, r.stdout + r.stderr); assert.match(String(parse(r).reason), /round count does not match the archives — not measured/, r.stdout); const now = Object.fromEntries(fx.archives().map((n) => [n, readFileSync(join(fx.project, n))])); assert.deepEqual(now, before, 'both archives are left unchanged, byte for byte'); }); test('keep: a same-number archive is not the own archive without a JSON block or a commit scope_sha_end', () => { assert.equal(typeof rounds.prepare, 'function', 'review-rounds.mjs exports prepare'); const fx = rndRepo(); stoppedRoundTwo(fx); const finalized = fx.read(); // No trailing JSON block in review.md nor in archive 1; archive 1 has one more prose line. const bare = withoutBlock(finalized); fx.write(bare); fx.write(withNote(bare), fx.archive(1)); const own1 = readFileSync(fx.archive(1)); const r = fx.prepare(); assert.equal(r.status, 2, r.stdout + r.stderr); assert.match(String(parse(r).reason), /round count does not match the archives — not measured/, r.stdout); assert.deepEqual(readFileSync(fx.archive(1)), own1, 'the archive is left unchanged'); // The JSON block kept, but scope_sha_end is not a commit id in either file. const noEnd = finalized.replace(`scope_sha_end: ${fx.c1}`, 'scope_sha_end: HEAD'); assert.notEqual(noEnd, finalized); fx.write(noEnd); fx.write(withNote(noEnd), fx.archive(1)); const own2 = readFileSync(fx.archive(1)); const s = fx.prepare(); assert.equal(s.status, 2, s.stdout + s.stderr); assert.match(String(parse(s).reason), /round count does not match the archives — not measured/, s.stdout); assert.deepEqual(readFileSync(fx.archive(1)), own2, 'the archive is left unchanged'); assert.deepEqual(fx.archives(), [`review-round-${fx.run}-1.md`], 'no other archive'); });