voyage/tests/scripts/release-check.test.mjs
Kjell Tore Guttormsen bef9c5b3ac
fix: the findings of the release review of 6.3.0
The review of v6.2.0..f669b87 (BLOCK: 1 BLOCKER, 12 MAJOR, 1 MINOR), each
fixed behind a test that failed first, or, for the two missing tests, a test
shown to fail on a stub of the branch it covers.

- Review steps appended by /trekexecute Phase 7.2 are held to the step rules
  on any plan version: plan-validator --steps-from <N> (on a plan below 1.8 a
  template review step validated and was executed).
- The guard refuses where no one can be asked also when its rules will not
  load; the wave merge path names that refusal instead of MERGE_REFUSED.
  Tests for the classifier-error refusal and post-approval-log's own check.
- release-check: the version files may change only in their version after
  the review (JSON compared without it), README diff lines are read inside
  hunks only, an unreadable diff is exit 2, and npm test runs without
  NODE_TEST_CONTEXT.
- What counts as a step of only tests: a note in parentheses is not a path,
  and a data file under specs/ is not a test. End-to-end: rides on the last
  step that builds behaviour (validator hint, trekplan, template).
- A removed line is cited as <rev>:<path>:<line> (findings-schema hint,
  trekreview re-ask).
- skill-lint counts a trailing newline as no line; regression-check takes
  commandEnv from lib/util/command-env.mjs; CLAUDE.md names four mode-read
  exemptions.
- /security-review, older than v6.2.0: the criteria runner refuses git
  --output, which writes a file.

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

362 lines
14 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, 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,
} = {}) {
const base = mkdtempSync(join(tmpdir(), 'release-check-'));
const repo = join(base, 'repo');
mkdirSync(join(repo, '.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(repo, 'package.json'), pkg('1.0.0'));
writeFileSync(join(repo, '.claude-plugin', 'plugin.json'), JSON.stringify({ name: 'fx', version: '1.0.0' }) + '\n');
writeFileSync(join(repo, 'README.md'), '# fx\n\n![Version](https://img.shields.io/badge/version-1.0.0-blue)\n\nText.\n');
writeFileSync(join(repo, 'CHANGELOG.md'), '# Changelog\n\n## v1.0.0 — 2026-10-01 — first\n\n- first\n');
writeFileSync(join(repo, 'a.mjs'), 'export const a = 1;\n');
for (const [rel, text] of Object.entries(files)) writeFileSync(join(repo, rel), text);
if (attributes) writeFileSync(join(repo, '.git', 'info', 'attributes'), attributes);
git(repo, 'add', '-A');
git(repo, 'commit', '-q', '-m', 'feat: first');
writeFileSync(join(repo, 'a.mjs'), 'export const a = 2;\n');
git(repo, 'add', 'a.mjs');
git(repo, 'commit', '-q', '-m', 'fix: the work');
const workSha = git(repo, 'rev-parse', 'HEAD');
if (version !== null) {
writeFileSync(join(repo, 'package.json'), pkg(version));
writeFileSync(join(repo, '.claude-plugin', 'plugin.json'), JSON.stringify({ name: 'fx', version }) + '\n');
} else {
writeFileSync(join(repo, '.claude-plugin', 'plugin.json'), JSON.stringify({ name: 'fx' }) + '\n');
writeFileSync(join(repo, 'package.json'), JSON.stringify({ name: 'fx', scripts: { test: 'node -e "process.exit(0)"' } }) + '\n');
}
writeFileSync(join(repo, 'README.md'), `# fx\n\n![Version](https://img.shields.io/badge/version-${version ?? '1.0.0'}-blue)\n\nText.\n`);
writeFileSync(join(repo, '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(repo, 'add', '-A');
git(repo, 'commit', '-q', '-m', 'chore(release): fixture');
if (afterRelease) {
for (const [rel, text] of Object.entries(afterRelease)) writeFileSync(join(repo, rel), text);
git(repo, 'add', '-A');
git(repo, 'commit', '-q', '-m', 'after the review');
}
if (dirty) writeFileSync(join(repo, '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, 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(); }
});