voyage/tests/scripts/release-check.test.mjs
Kjell Tore Guttormsen 29b4ac91b3
fix: round 7 of the release review of 6.3.0, for 6.3.1
Round 7 (516dffa..45aa9e1, WARN: 12 MAJOR, 0 BLOCKER) and the fence defect
found while writing its review.md. Each fix began with a test that failed on
0f400d3 (v6.3.0); suite 2208 (2206/0/2), 14 tests more.

- Tests-only (operator 2026-10-11): the plan's Files: alone decides, and the
  text says so (red-proof, trekexecute Sub-step B, step-implementer,
  HANDOVER-CONTRACTS). red-proof no longer reads the step's diff, so a change
  already in the tree when the step began no longer counts against it, and a
  plan placed outside .claude/ reads the same. The round-6 test that had the
  diff overrule the plan is rewritten to the new rule.
- The Files: reader keeps a plain file beside a backticked path
  (", Dockerfile", "the CLI entry src/cli.mjs", "and Makefile"). Measured on
  the 520 Files: fields in 182 plans: no field reads differently.
- Red proof reports a command its timeout killed as killed after the timeout,
  not "could not start"; the release check tells a timeout from output past
  its buffer and from another signal. One reading of a stop
  (criteria-runner stoppedBy) for red proof, the criteria runner and the
  release check.
- A trailing `# comment` is dropped in a backticked check too, and in a
  command passed as written, so the words screened are the words run.
- The release check reads a relative --review in --repo.
- The 15-minute timeout is defined once (criteria-runner.mjs).
- The findings check reads each cited file once per validation.
- One isMain helper (lib/util/is-main.mjs) for every CLI: the 31 copies of
  the realpath guard and the 20 modules still on the file:// guard, which
  exited 0 having done nothing under a symlink or a path with a space.
- The Bash guard writes its no-one-to-ask refusal once, on a direct condition.
- The findings check ends a ```json block only at a fence on its own line, so
  three backticks inside a JSON string no longer end it (also in 6.2.0).
- Brief-conformance notes: a test for test-lock green on exit 127 (it
  passed at once; it fails when 127 is read as could-not-run); the prose now
  names the git revision `@~1` as the one leading-@ exception.

Not changed: the example plan's steps 1-2 keep their tests-only warning
(operator decision 2026-10-09; the r5 test records it). Brief note 4 (a
Regression: whose runner is not installed goes to the fix rounds) is a
routing question for the operator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:11:57 +02:00

491 lines
20 KiB
JavaScript

// tests/scripts/release-check.test.mjs
// P13 — the check before a release (wave-2 order 8). One command answers "may
// this HEAD be released?": the suite is green at HEAD, the review verdict for
// what is released is not BLOCK (K7) and covers it, and every delivery in the
// release says whether it is a one-way or a two-way door.
// 0 = ready, 1 = not ready (listed), 2 = could not run (never green).
//
// Every test builds a throwaway git repo shaped like a release: one commit with
// the work, then a release commit that only bumps versions and the CHANGELOG.
// The repo's own `npm test` is a one-liner that exits 0 or 1, so the suite
// check runs for real without running Voyage's suite inside its own suite.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const CHECK = join(ROOT, 'scripts', 'release-check.mjs');
const mod = existsSync(CHECK) ? await import('../../scripts/release-check.mjs') : {};
const git = (cwd, ...args) => {
const r = spawnSync('git', args, { cwd, encoding: 'utf8' });
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
return r.stdout.trim();
};
function reviewText({ verdict = 'ALLOW', sha }) {
const id = 'a'.repeat(40);
const severity = verdict === 'BLOCK' ? 'BLOCKER' : 'MINOR';
const fmVerdict = verdict === null ? '' : `verdict: ${verdict}\n`;
const json = JSON.stringify({
verdict: verdict ?? undefined,
counts: { BLOCKER: severity === 'BLOCKER' ? 1 : 0, MAJOR: 0, MINOR: severity === 'MINOR' ? 1 : 0, SUGGESTION: 0 },
allow_blocked_by: [],
findings: [{ id, severity, rule_key: 'MISSING_TEST', file: 'a.mjs', line: 1, title: 't', detail: 'd', recommended_action: 'Add a test.' }],
}, null, 2);
return `---
type: trekreview
review_version: "1.1"
task: "t"
slug: s
project_dir: .claude/projects/2026-10-08-s/
brief_path: .claude/projects/2026-10-08-s/brief.md
scope_sha_end: ${sha}
reviewed_files_count: 1
${fmVerdict}findings:
- ${id}
---
# Review: t
## Executive Summary
x
## Coverage
| File | Treatment | Reason |
|------|-----------|--------|
| a.mjs | deep-review | default |
## Remediation Summary
x
\`\`\`json
${json}
\`\`\`
`;
}
const DOORS = `### Doors
- two-way: the fixture change — revert it and release a new patch.
`;
/**
* A repo with the work in one commit and a release commit for 1.0.1 on top.
* Options switch one thing off at a time; the default is a ready release.
*/
function fixture({
suiteExit = 0, doors = DOORS, verdict = 'ALLOW', writeReview = true,
reviewSha = 'work', afterRelease = null, dirty = false, version = '1.0.1',
testScript = null, files = {}, attributes = null, sub = null,
} = {}) {
const base = mkdtempSync(join(tmpdir(), 'release-check-'));
const repo = join(base, 'repo');
// sub: the plugin lives in a subdirectory of a larger repo (release review of 6.3.0, round 2).
const dir = sub ? join(repo, sub) : repo;
mkdirSync(join(dir, '.claude-plugin'), { recursive: true });
git(repo, 'init', '-q', '-b', 'main');
git(repo, 'config', 'user.email', 't@example.invalid');
git(repo, 'config', 'user.name', 't');
git(repo, 'config', 'commit.gpgsign', 'false');
const pkg = (v) => JSON.stringify({ name: 'fx', version: v, scripts: { test: testScript ?? `node -e "process.exit(${suiteExit})"` } }, null, 2) + '\n';
writeFileSync(join(dir, 'package.json'), pkg('1.0.0'));
writeFileSync(join(dir, '.claude-plugin', 'plugin.json'), JSON.stringify({ name: 'fx', version: '1.0.0' }) + '\n');
writeFileSync(join(dir, 'README.md'), '# fx\n\n![Version](https://img.shields.io/badge/version-1.0.0-blue)\n\nText.\n');
writeFileSync(join(dir, 'CHANGELOG.md'), '# Changelog\n\n## v1.0.0 — 2026-10-01 — first\n\n- first\n');
writeFileSync(join(dir, 'a.mjs'), 'export const a = 1;\n');
for (const [rel, text] of Object.entries(files)) writeFileSync(join(dir, rel), text);
if (attributes) writeFileSync(join(repo, '.git', 'info', 'attributes'), attributes);
git(dir, 'add', '-A');
git(dir, 'commit', '-q', '-m', 'feat: first');
writeFileSync(join(dir, 'a.mjs'), 'export const a = 2;\n');
git(dir, 'add', 'a.mjs');
git(dir, 'commit', '-q', '-m', 'fix: the work');
const workSha = git(dir, 'rev-parse', 'HEAD');
if (version !== null) {
writeFileSync(join(dir, 'package.json'), pkg(version));
writeFileSync(join(dir, '.claude-plugin', 'plugin.json'), JSON.stringify({ name: 'fx', version }) + '\n');
} else {
writeFileSync(join(dir, '.claude-plugin', 'plugin.json'), JSON.stringify({ name: 'fx' }) + '\n');
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'fx', scripts: { test: 'node -e "process.exit(0)"' } }) + '\n');
}
writeFileSync(join(dir, 'README.md'), `# fx\n\n![Version](https://img.shields.io/badge/version-${version ?? '1.0.0'}-blue)\n\nText.\n`);
writeFileSync(join(dir, 'CHANGELOG.md'),
`# Changelog\n\n## v${version ?? '9.9.9'} — 2026-10-08 — fixture\n\n${doors ?? ''}\n### Fixed\n\n- the work\n\n## v1.0.0 — 2026-10-01 — first\n\n- first\n`);
git(dir, 'add', '-A');
git(dir, 'commit', '-q', '-m', 'chore(release): fixture');
if (afterRelease) {
for (const [rel, text] of Object.entries(afterRelease)) {
mkdirSync(dirname(join(dir, rel)), { recursive: true });
writeFileSync(join(dir, rel), text);
}
git(dir, 'add', '-A');
git(dir, 'commit', '-q', '-m', 'after the review');
}
if (dirty) writeFileSync(join(dir, 'a.mjs'), 'export const a = 3;\n');
const review = join(base, 'review.md');
const sha = reviewSha === 'work' ? workSha : reviewSha;
if (writeReview) writeFileSync(review, reviewText({ verdict, sha }));
return { base, repo: dir, review, cleanup: () => rmSync(base, { recursive: true, force: true }) };
}
function run(fx, extra = []) {
return spawnSync(process.execPath, [CHECK, '--repo', fx.repo, '--review', fx.review, ...extra], { encoding: 'utf8' });
}
test('release-check — the CLI exists', () => {
assert.ok(existsSync(CHECK), 'scripts/release-check.mjs must exist (P13)');
});
test('release-check — everything in place → 0 (ready)', () => {
const fx = fixture();
try {
const r = run(fx);
assert.equal(r.status, 0, r.stdout + r.stderr);
assert.match(r.stdout, /READY/);
} finally { fx.cleanup(); }
});
test('release-check — a ready release prints the release approval command (K2)', () => {
const fx = fixture();
try {
const r = run(fx);
assert.equal(r.status, 0, r.stdout + r.stderr);
assert.match(r.stdout, /approval-log\.mjs append --kind release --subject CHANGELOG\.md --sha256-of CHANGELOG\.md/);
} finally { fx.cleanup(); }
});
test('release-check — review verdict BLOCK → 1', () => {
const fx = fixture({ verdict: 'BLOCK' });
try {
const r = run(fx);
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.match(r.stdout, /BLOCK/);
} finally { fx.cleanup(); }
});
test('release-check — no review.md → 2 (never green)', () => {
const fx = fixture({ writeReview: false });
try {
const r = run(fx);
assert.equal(r.status, 2, r.stdout + r.stderr);
} finally { fx.cleanup(); }
});
test('release-check — a review the verdict gate cannot decide (no verdict) → 2', () => {
const fx = fixture({ verdict: null });
try {
const r = run(fx);
assert.equal(r.status, 2, r.stdout + r.stderr);
} finally { fx.cleanup(); }
});
test('release-check — no door type in the release section → 1', () => {
const fx = fixture({ doors: null });
try {
const r = run(fx);
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.match(r.stdout, /door/i);
} finally { fx.cleanup(); }
});
test('release-check — a door line that is neither one-way nor two-way → 1', () => {
const fx = fixture({ doors: '### Doors\n\n- two-way: fine\n- maybe: not a door type\n' });
try {
const r = run(fx);
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.match(r.stdout, /maybe/);
} finally { fx.cleanup(); }
});
test('release-check — an empty Doors section → 1', () => {
const fx = fixture({ doors: '### Doors\n\n' });
try {
const r = run(fx);
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.match(r.stdout, /no door entries/i);
} finally { fx.cleanup(); }
});
test('release-check — the suite red at HEAD → 1', () => {
const fx = fixture({ suiteExit: 1 });
try {
const r = run(fx);
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.match(r.stdout, /suite/i);
} finally { fx.cleanup(); }
});
test('release-check — uncommitted changes → 1 (the suite would not measure HEAD)', () => {
const fx = fixture({ dirty: true });
try {
const r = run(fx);
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.match(r.stdout, /uncommitted/i);
} finally { fx.cleanup(); }
});
test('release-check — a code change after the reviewed commit → 1 (the review does not cover it)', () => {
const fx = fixture({ afterRelease: { 'a.mjs': 'export const a = 99;\n' } });
try {
const r = run(fx);
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.match(r.stdout, /a\.mjs/);
} finally { fx.cleanup(); }
});
test('release-check — a README prose change after the reviewed commit → 1 (only the badge may move)', () => {
const fx = fixture({ afterRelease: { 'README.md': '# fx\n\n![Version](https://img.shields.io/badge/version-1.0.1-blue)\n\nNew text.\n' } });
try {
const r = run(fx);
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.match(r.stdout, /README\.md/);
} finally { fx.cleanup(); }
});
test('release-check — a review of a commit that is not in this history → 1', () => {
const fx = fixture({ reviewSha: 'f'.repeat(40) });
try {
const r = run(fx);
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.match(r.stdout, /not in the history of HEAD/);
} finally { fx.cleanup(); }
});
test('release-check — no version to release (no --version, no plugin.json version) → 2', () => {
const fx = fixture({ version: null });
try {
assert.equal(run(fx).status, 2);
} finally { fx.cleanup(); }
});
test('release-check — not a git repo → 2', () => {
const dir = mkdtempSync(join(tmpdir(), 'release-check-nogit-'));
try {
const r = spawnSync(process.execPath, [CHECK, '--repo', dir, '--review', join(dir, 'review.md'), '--version', '1.0.0'], { encoding: 'utf8' });
assert.equal(r.status, 2, r.stdout + r.stderr);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('release-check — --json reports every check and the exit', () => {
const fx = fixture({ verdict: 'BLOCK' });
try {
const r = run(fx, ['--json']);
assert.equal(r.status, 1);
const out = JSON.parse(r.stdout);
assert.equal(out.exit, 1);
assert.equal(out.version, '1.0.1');
assert.deepEqual(out.checks.map((c) => c.id), ['tree', 'suite', 'review', 'coverage', 'door']);
assert.equal(out.checks.find((c) => c.id === 'review').status, 'fail');
} finally { fx.cleanup(); }
});
test('release-check — parseDoors reads one-way and two-way entries and names the bad ones', () => {
assert.equal(typeof mod.parseDoors, 'function');
const section = '### Doors\n\n- one-way: plan 1.9 rejects plan 1.7 files\n (users regenerate their plans)\n- two-way: a new check\n- maybe: x\n\n### Added\n\n- one-way: not in Doors\n';
const d = mod.parseDoors(section);
assert.deepEqual(d.entries.map((e) => e.door), ['one-way', 'two-way']);
assert.deepEqual(d.bad, ['- maybe: x']);
assert.equal(d.found, true);
});
test('release-check — releaseSection matches the CHANGELOG heading dialects exactly', () => {
assert.equal(typeof mod.releaseSection, 'function');
const text = '# C\n\n## v1.10.0 — x\n\nten\n\n## [1.1.0] - y\n\none\n\n## v1.1.0-pre\n\npre\n';
assert.match(mod.releaseSection(text, '1.1.0'), /one/);
assert.doesNotMatch(mod.releaseSection(text, '1.1.0'), /ten|pre/);
assert.match(mod.releaseSection(text, '1.10.0'), /ten/);
assert.equal(mod.releaseSection(text, '2.0.0'), null);
});
// ---- release review of 6.3.0 (2026-10-10): what counts as bookkeeping ----------
const AFTER_PKG = JSON.stringify({ name: 'fx', version: '1.0.1', scripts: { test: 'node -e "process.exit(0)" && echo changed' } }, null, 2) + '\n';
test('release-check — a change to package.json after the review other than its version → 1', () => {
const fx = fixture({ afterRelease: { 'package.json': AFTER_PKG } });
try {
const r = run(fx);
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.match(r.stdout, /package\.json \(lines other than the version\)/);
} finally { fx.cleanup(); }
});
test('release-check — a change to plugin.json after the review other than its version → 1', () => {
const fx = fixture({ afterRelease: { '.claude-plugin/plugin.json': JSON.stringify({ name: 'fx', version: '1.0.1', hooks: './x.json' }) + '\n' } });
try {
const r = run(fx);
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.match(r.stdout, /plugin\.json \(lines other than the version\)/);
} finally { fx.cleanup(); }
});
test('release-check — a README line that starts with "++ " is a change, not a diff header → 1', () => {
const fx = fixture({ afterRelease: { 'README.md': '# fx\n\n![Version](https://img.shields.io/badge/version-1.0.1-blue)\n\nText.\n++ new claim\n' } });
try {
const r = run(fx);
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.match(r.stdout, /README\.md \(lines other than the version badge\)/);
} finally { fx.cleanup(); }
});
test('release-check — a README diff that cannot be read → 2 (never green)', () => {
const fx = fixture({ attributes: 'README.md diff=broken\n' });
try {
spawnSync('git', ['config', 'diff.broken.textconv', 'false'], { cwd: fx.repo });
const r = run(fx);
assert.equal(r.status, 2, r.stdout + r.stderr);
assert.match(r.stdout, /README\.md/);
} finally { fx.cleanup(); }
});
test('release-check — a red suite is red when the check itself runs under node --test', () => {
const fx = fixture({
testScript: 'node --test red.test.mjs',
files: { 'red.test.mjs': "import { test } from 'node:test';\nimport assert from 'node:assert';\ntest('red', () => assert.equal(1, 2));\n" },
});
try {
const control = run(fx);
assert.equal(control.status, 1, `the control: the suite is red: ${control.stdout}`);
const r = spawnSync(process.execPath, [CHECK, '--repo', fx.repo, '--review', fx.review],
{ encoding: 'utf8', env: { ...process.env, NODE_TEST_CONTEXT: 'child-v8' } });
assert.equal(r.status, 1, `under a parent test runner: ${r.stdout}`);
assert.match(r.stdout, /npm test exited/);
} finally { fx.cleanup(); }
});
// ---- round 2 of the release review (2026-10-10) --------------------------------
test('release-check — a plugin in a subdirectory of a larger repo is checked there → 0', () => {
const fx = fixture({ sub: 'voyage' });
try {
const r = run(fx);
assert.equal(r.status, 0, r.stdout + r.stderr);
} finally { fx.cleanup(); }
});
test('release-check — no review: the suite is not run, exit 2 at once', () => {
const fx = fixture({ writeReview: false, testScript: 'node -e "require(\'fs\').writeFileSync(\'suite-ran\', \'\')"' });
try {
const r = run(fx);
assert.equal(r.status, 2, r.stdout + r.stderr);
assert.equal(existsSync(join(fx.repo, 'suite-ran')), false, 'the suite did not run');
assert.match(r.stdout, /suite\s+not run/);
} finally { fx.cleanup(); }
});
test('release-check — a flag with no value is exit 2, not 1', () => {
const r = spawnSync(process.execPath, [CHECK, '--repo'], { encoding: 'utf8' });
assert.equal(r.status, 2, r.stdout + r.stderr);
assert.match(r.stdout + r.stderr, /not measured|NOT MEASURED/i);
});
// ==== round 3 of the release review of 6.3.0 (2026-10-10) ====================
test('release-check r3 — the review and the approval log committed after the review do not count as unreviewed → 0', () => {
const fx = fixture({ afterRelease: {
'.claude/projects/2026-10-08-s/review.md': 'the review, committed (artifact_store: git)\n',
'.claude/voyage/approvals.jsonl': '{"kind":"release"}\n',
} });
try {
const r = run(fx);
assert.equal(r.status, 0, r.stdout + r.stderr);
} finally { fx.cleanup(); }
});
test('release-check r3 — scope_sha_end is read as written: an all-digit sha keeps its leading zero', () => {
assert.equal(typeof mod.reviewedSha, 'function');
assert.equal(mod.reviewedSha('---\ntype: trekreview\nscope_sha_end: 0123456\n---\nbody\n'), '0123456');
assert.equal(mod.reviewedSha('---\nscope_sha_end: "00ab12c"\n---\n'), '00ab12c');
assert.equal(mod.reviewedSha('---\ntype: trekreview\n---\nscope_sha_end: 1\n'), '', 'only the frontmatter counts');
});
// ==== round 4 of the release review of 6.3.0 (2026-10-10) ====================
test('release-check r4 — scope_sha_end must be a sha: a ref name fails coverage → 1', () => {
assert.equal(mod.reviewedSha('---\nscope_sha_end: HEAD\n---\n'), '');
assert.equal(mod.reviewedSha('---\nscope_sha_end: main\n---\n'), '');
const fx = fixture({ reviewSha: 'HEAD' });
try {
const r = run(fx);
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.match(r.stdout, /scope_sha_end/);
} finally { fx.cleanup(); }
});
test('release-check r4 — other text on the badge line is not release bookkeeping → 1', () => {
const fx = fixture({ afterRelease: {
'README.md': '# fx\n\n![Version](https://img.shields.io/badge/version-1.0.1-blue) [install](https://example.invalid/i.sh)\n\nText.\n',
} });
try {
const r = run(fx);
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.match(r.stdout, /README\.md/);
} finally { fx.cleanup(); }
});
// ==== round 5 of the release review of 6.3.0 (2026-10-11) ====================
test('release-check r5 — a test moved into an allowed place after the review is still a removal → 1', () => {
const fx = fixture({ files: { 'guard.test.mjs': 'export const guarded = true;\n' } });
try {
mkdirSync(join(fx.repo, '.claude', 'voyage'), { recursive: true });
git(fx.repo, 'mv', 'guard.test.mjs', '.claude/voyage/guard.test.mjs');
git(fx.repo, 'commit', '-q', '-m', 'move the test out of the suite');
const r = run(fx);
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.match(r.stdout, /guard\.test\.mjs/);
} finally { fx.cleanup(); }
});
// ==== round 6 of the release review of 6.3.0 (2026-10-11) ====================
test('release-check r6 — a suite that never ends is stopped and read as not measured → 2', () => {
const fx = fixture({ testScript: 'node -e "setTimeout(() => {}, 4000)"' });
try {
const r = mod.releaseCheck({ repo: fx.repo, review: fx.review, suiteTimeoutMs: 1000 });
const suite = r.checks.find((c) => c.id === 'suite');
assert.equal(suite.status, 'error', JSON.stringify(suite));
assert.match(suite.detail, /timeout|killed/i);
assert.equal(r.exit, 2);
} finally { fx.cleanup(); }
});
// ==== round 7 of the release review of 6.3.0 (2026-10-11), fixed in 6.3.1 ====
test('release-check r7 — a suite killed for too much output is not called a timeout → 2', () => {
const fx = fixture({ testScript: "node -e \"process.stdout.write('x'.repeat(200000))\"" });
try {
const r = mod.releaseCheck({ repo: fx.repo, review: fx.review, suiteMaxBuffer: 4096 });
const suite = r.checks.find((c) => c.id === 'suite');
assert.equal(suite.status, 'error', JSON.stringify(suite));
assert.doesNotMatch(suite.detail, /timeout/i, suite.detail);
assert.match(suite.detail, /output/i, suite.detail);
assert.equal(r.exit, 2);
} finally { fx.cleanup(); }
});
test('release-check r7 — a relative --review is read in --repo, like every other path → 0', () => {
const fx = fixture();
try {
mkdirSync(join(fx.repo, '.claude', 'projects', 'x'), { recursive: true });
writeFileSync(join(fx.repo, '.claude', 'projects', 'x', 'review.md'), readFileSync(fx.review, 'utf8'));
writeFileSync(join(fx.repo, '.git', 'info', 'exclude'), '.claude/\n');
// Run from the directory above the repo, as `--repo ../voyage` from a sibling would.
const r = spawnSync(process.execPath, [CHECK, '--repo', 'repo', '--review', '.claude/projects/x/review.md'], { cwd: fx.base, encoding: 'utf8' });
assert.doesNotMatch(r.stdout, /does not exist/, r.stdout);
assert.equal(r.status, 0, r.stdout + r.stderr);
} finally { fx.cleanup(); }
});