test(r8): move the live tests out of the round files

R8 of cleanup R-a (plan .claude/projects/2026-11-07-cleanup-r-a, appendix A).
The live tests of the round files move into the test named after their
source; each moved test was seen red under a mutation of its source.
New: tests/lib/lib-imports.test.mjs, tests/lib/timing.test.mjs (from
timing-under-load). The [#1] --gates rule and shim-line pins move into
tests/lib/autonomy-gate.test.mjs.

Removed: the round test files (retterunde, retterunde-prose-pins,
trial-mechanical-fixes, trial2-fixes, trial2-minor, trial2-minor-prose-pins,
fix-round-4-5a-prose, fix-round-4-5b2-b-prose, timing-under-load), the 8 R8
e2e scripts, and seven round test files on the R-a removal list that pinned
them (fix-round-4-5b-prose, fix-round-4-5b-part-2/3/4-scripts,
fix-round-4-5b-part-4-prose, fix-round-4-5b2-b/c-scripts). The diff-mode e2e
pin (test 24) and the red-script round-wrapper cases go too.

tests/synthetic/plan-run-C.md becomes tests/fixtures/plan-run-c.md,
regenerated from buildPlan({ version: '1.9' }). The red judge, reporter and
judge test headers now name the shared red script.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-11-07 10:19:49 +01:00
commit 651be3468b
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
60 changed files with 1755 additions and 7965 deletions

View file

@ -15,8 +15,9 @@ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'nod
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { execFileSync, spawnSync } from 'node:child_process';
import { runHook } from '../helpers/hook-helper.mjs';
import { findSection } from '../../lib/parsers/plan-schema.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
@ -349,3 +350,87 @@ test('trekcontinue Bug 4 (f-2) confirm deletes and (f-3) idempotent re-run handl
rmSync(root, { recursive: true, force: true });
}
});
// ---------------------------------------------------------------
// Phase 0 runs the arg-parser CLI and reads its JSON (no parseArgs($ARGUMENTS)
// function call, which no Bash line runs).
// ---------------------------------------------------------------
/** Whitespace (line breaks too) collapsed to one space. */
const flat = (s) => s.replace(/\s+/g, ' ').trim();
/**
* The text of a heading's section, up to the next heading of the same or a
* higher level outside a fence. The heading must exist.
*/
function sectionText(text, heading) {
const level = heading.match(/^#+/)[0].length;
const s = findSection(text, heading.replace(/^#+ /, ''), level);
assert.ok(s, `missing ${heading}`);
const lines = s.text.split('\n');
let fenced = false;
for (let i = 1; i < lines.length; i++) {
if (/^\s{0,3}(`{3,}|~{3,})/.test(lines[i])) fenced = !fenced;
const m = !fenced && lines[i].match(/^(#+) /);
if (m && m[1].length < level) return lines.slice(0, i).join('\n');
}
return s.text;
}
/** The single line in `text` that runs `lib/<mod>` (and holds `verb`, when given). */
function libLine(text, mod, verb = '') {
const esc = mod.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
const re = new RegExp(`^\\s*node \\$\\{CLAUDE_PLUGIN_ROOT\\}/lib/${esc}\\b`);
const hits = text.split('\n').filter((l) => re.test(l) && l.includes(verb));
assert.equal(hits.length, 1, `expected exactly one \`${mod}${verb ? ` ${verb}` : ''}\` line; found ${hits.length}`);
return hits[0].trim();
}
/** The runtime ```bash block of `text` that holds `cmd`, or undefined. */
function bashBlockHolding(text, cmd) {
return text.split('\n```bash\n').slice(1).map((b) => b.split('\n```')[0]).find((b) => b.includes(cmd));
}
/** A Phase 0 parser block run through bash with the plugin root filled in and `args` in place of $ARGUMENTS. */
function runParser(cmd, args) {
const filled = cmd.split('$ARGUMENTS').join(args).split('${CLAUDE_PLUGIN_ROOT}').join(ROOT);
return spawnSync('bash', ['-c', filled], { cwd: ROOT, encoding: 'utf8' });
}
const PHASE0_PARSER_LINE = "node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekcontinue --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'";
test('trekcontinue Phase 0 has no parseArgs($ARGUMENTS call, and its parser line run with --help sets the help flag', () => {
const phase0 = sectionText(readCommand(), '## Phase 0 — `--help` handling');
assert.doesNotMatch(phase0, /parseArgs\(\$ARGUMENTS/, 'Phase 0 still calls parseArgs($ARGUMENTS, ...) as a function');
const cmd = libLine(phase0, 'parsers/arg-parser.mjs');
assert.equal(cmd, PHASE0_PARSER_LINE, 'the Phase 0 parser line is not the trekcontinue parser call on $ARGUMENTS');
const block = bashBlockHolding(phase0, cmd);
assert.ok(block, 'the parser line is not in a runtime ```bash block');
assert.deepEqual(block.split('\n').slice(-2), ['$ARGUMENTS', 'VOYAGE_ARGS_EOF_7F3C9E'],
'the block does not end with $ARGUMENTS as the heredoc body and its closing line');
assert.match(flat(phase0), /Run first, exactly like this:/);
assert.match(flat(phase0), /Read `flags` and `positional` from the JSON it prints/);
assert.match(flat(phase0), /`flags\['--help'\] === true`/, 'Phase 0 does not dispatch on the parsed --help flag');
assert.match(flat(phase0), /`positional\[0\] === '-h'`/, 'Phase 0 does not dispatch on a lone -h as the first positional');
const help = runParser(block, '--help');
assert.equal(help.status, 0, help.stdout + help.stderr);
assert.equal(JSON.parse(help.stdout).flags['--help'], true, 'the parser line run with --help does not set the help flag');
const dir = runParser(block, '.claude/projects/demo');
assert.equal(dir.status, 0, dir.stdout + dir.stderr);
const r = JSON.parse(dir.stdout);
assert.equal(r.flags['--help'], undefined, 'a project dir set the --help flag');
assert.deepEqual(r.positional, ['.claude/projects/demo']);
const h = runParser(block, '-h');
assert.equal(h.status, 0, h.stdout + h.stderr);
assert.equal(JSON.parse(h.stdout).positional[0], '-h');
const p05 = flat(sectionText(readCommand(), '## Phase 0.5 — Cleanup mode dispatch'));
assert.doesNotMatch(p05, /After `parseArgs` has resolved/);
assert.match(p05, /After the parser has printed/);
});
test('trekcontinue — stats write through stats-append, never silently skipped', () => {
const md = readCommand();
assert.match(md, /node \$\{CLAUDE_PLUGIN_ROOT\}\/lib\/stats\/stats-append\.mjs trekcontinue <<'JSON'/);
assert.doesNotMatch(md, /skip (stats )?silently|silently skip/i);
assert.doesNotMatch(md, /\$\{CLAUDE_PLUGIN_DATA\}\/trekcontinue-stats\.jsonl/);
});

View file

@ -17,6 +17,7 @@ import { dirname, join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { childEnv } from '../helpers/child-env.mjs';
import { findSection } from '../../lib/parsers/plan-schema.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
@ -233,3 +234,78 @@ test('[DJ5] Phase 3 run with an injection label creates no file and keeps the la
test("[DJ5] a label with an apostrophe is kept", () => {
assertLabelKept('bash', "Fix the user's login");
});
// ---------------------------------------------------------------
// Phase 0 runs the arg parser and dispatches only on its exact result, so
// a slug such as "fix-header" never asks for help.
// ---------------------------------------------------------------
/** Whitespace (line breaks too) collapsed to one space. */
const flat = (s) => s.replace(/\s+/g, ' ').trim();
/**
* The text of a heading's section, up to the next heading of the same or a
* higher level outside a fence. The heading must exist.
*/
function sectionText(text, heading) {
const level = heading.match(/^#+/)[0].length;
const s = findSection(text, heading.replace(/^#+ /, ''), level);
assert.ok(s, `missing ${heading}`);
const lines = s.text.split('\n');
let fenced = false;
for (let i = 1; i < lines.length; i++) {
if (/^\s{0,3}(`{3,}|~{3,})/.test(lines[i])) fenced = !fenced;
const m = !fenced && lines[i].match(/^(#+) /);
if (m && m[1].length < level) return lines.slice(0, i).join('\n');
}
return s.text;
}
/** The single line in `text` that runs `lib/<mod>` (and holds `verb`, when given). */
function libLine(text, mod, verb = '') {
const esc = mod.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
const re = new RegExp(`^\\s*node \\$\\{CLAUDE_PLUGIN_ROOT\\}/lib/${esc}\\b`);
const hits = text.split('\n').filter((l) => re.test(l) && l.includes(verb));
assert.equal(hits.length, 1, `expected exactly one \`${mod}${verb ? ` ${verb}` : ''}\` line; found ${hits.length}`);
return hits[0].trim();
}
/** The runtime ```bash block of `text` that holds `cmd`, or undefined. */
function bashBlockHolding(text, cmd) {
return text.split('\n```bash\n').slice(1).map((b) => b.split('\n```')[0]).find((b) => b.includes(cmd));
}
/** A Phase 0 parser block run through bash with the plugin root filled in and `args` in place of $ARGUMENTS. */
function runParser(cmd, args) {
const filled = cmd.split('$ARGUMENTS').join(args).split('${CLAUDE_PLUGIN_ROOT}').join(ROOT);
return spawnSync('bash', ['-c', filled], { cwd: ROOT, encoding: 'utf8' });
}
const PHASE0_PARSER_LINE = "node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekendsession --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'";
test('trekendsession Phase 0 has no "contains --help or -h" check, and its parser line run on fix-header gives no help', () => {
const phase0 = sectionText(readCommand(), '## Phase 0 — `--help` handling');
assert.doesNotMatch(flat(phase0), /contains\s+`?--help`?\s+or\s+`?-h`?/i,
'Phase 0 still prints help when $ARGUMENTS merely contains --help or -h');
assert.match(flat(phase0), /`flags\['--help'\] === true`/, 'Phase 0 does not dispatch on the parsed --help flag');
assert.match(flat(phase0), /`positional\[0\] === '-h'`/, 'Phase 0 does not dispatch on a lone -h as the first positional');
const cmd = libLine(phase0, 'parsers/arg-parser.mjs');
assert.equal(cmd, PHASE0_PARSER_LINE, 'the Phase 0 parser line is not the trekendsession parser call on $ARGUMENTS');
const block = bashBlockHolding(phase0, cmd);
assert.ok(block, 'the parser line is not in a runtime ```bash block');
assert.equal(block.split('\n')[0], 'setopt nonomatch 2>/dev/null || true', 'the block does not open with setopt nonomatch');
assert.deepEqual(block.split('\n').slice(-2), ['$ARGUMENTS', 'VOYAGE_ARGS_EOF_7F3C9E'],
'the block does not end with $ARGUMENTS as the heredoc body and its closing line');
const plain = runParser(block, 'fix-header');
assert.equal(plain.status, 0, `the parser line refused fix-header: ${plain.stdout}${plain.stderr}`);
const r = JSON.parse(plain.stdout);
assert.equal(r.flags['--help'], undefined, 'fix-header set the --help flag');
assert.deepEqual(r.positional, ['fix-header']);
assert.notEqual(r.positional[0], '-h');
const help = runParser(block, '--help');
assert.equal(help.status, 0, help.stdout + help.stderr);
assert.equal(JSON.parse(help.stdout).flags['--help'], true, 'control: --help must set the help flag');
const two = runParser(block, 'fix-header "Session 2"');
assert.equal(two.status, 0, two.stdout + two.stderr);
assert.deepEqual(JSON.parse(two.stdout).positional, ['fix-header', 'Session 2']);
});

View file

@ -3,14 +3,18 @@
// Low effort runs the plan as one session, step after step; it sets no --gates
// (fix round 4-5a, #33).
import { test } from 'node:test';
import { after, test } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFileSync } from 'node:fs';
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { resolvePhaseSignal } from '../../lib/profiles/phase-signal-resolver.mjs';
import { validateBriefContent } from '../../lib/validators/brief-validator.mjs';
import { parseDocument } from '../../lib/util/frontmatter.mjs';
import { findSection } from '../../lib/parsers/plan-schema.mjs';
import { childEnv } from '../helpers/child-env.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
@ -155,3 +159,219 @@ test('trekexecute — iterations_remaining decrement + backfill + gate cross-che
assert.match(gate, /recovery_depth/, 'gate cross-check must reconcile against recovery_depth');
assert.match(gate, /attempts/, 'gate cross-check must reconcile against attempts');
});
// --- helpers for the run-the-line tests below -------------------------------
/** Whitespace (line breaks too) collapsed to one space. */
const flat = (s) => s.replace(/\s+/g, ' ').trim();
/**
* The text of a heading's section, up to the next heading of the same or a
* higher level outside a fence. The heading must exist.
*/
function sectionText(text, heading) {
const level = heading.match(/^#+/)[0].length;
const s = findSection(text, heading.replace(/^#+ /, ''), level);
assert.ok(s, `missing ${heading}`);
const lines = s.text.split('\n');
let fenced = false;
for (let i = 1; i < lines.length; i++) {
if (/^\s{0,3}(`{3,}|~{3,})/.test(lines[i])) fenced = !fenced;
const m = !fenced && lines[i].match(/^(#+) /);
if (m && m[1].length < level) return lines.slice(0, i).join('\n');
}
return s.text;
}
/** The single line in `text` that runs `lib/<mod>` (and holds `verb`, when given). */
function libLine(text, mod, verb = '') {
const esc = mod.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
const re = new RegExp(`^\\s*node \\$\\{CLAUDE_PLUGIN_ROOT\\}/lib/${esc}\\b`);
const hits = text.split('\n').filter((l) => re.test(l) && l.includes(verb));
assert.equal(hits.length, 1, `expected exactly one \`${mod}${verb ? ` ${verb}` : ''}\` line; found ${hits.length}`);
return hits[0].trim();
}
/** Run a command line from the prose with the plugin root filled in. */
function bash(cmd, cwd) {
return spawnSync('bash', ['-c', cmd.split('${CLAUDE_PLUGIN_ROOT}').join(ROOT)], { cwd, encoding: 'utf8' });
}
// --- Phase 8: the state-file block runs from any working directory ----------
test('trekexecute Phase 8: the state-file block imports through the plugin root and runs outside it', () => {
const md = read();
assert.doesNotMatch(md, /from '\.\/lib\//, 'a relative import only works with cwd = plugin root');
const start = md.indexOf('node --input-type=module -e "\nimport path from \'node:path\';');
assert.ok(start > 0, 'the state-file block');
const end = md.indexOf('\n```', start);
const block = md.slice(start, end)
.replaceAll('${CLAUDE_PLUGIN_ROOT}', ROOT)
.replace("'{project_dir}' '{next_session_brief_path}' '{next_session_label}' '{status}'", '"$1" brief.md "Session 2" ready');
const elsewhere = mkdtempSync(join(tmpdir(), 'voyage-state-'));
try {
const r = spawnSync('/bin/bash', ['-c', block, 'x', elsewhere], { cwd: elsewhere, encoding: 'utf8' });
assert.equal(r.status, 0, r.stderr);
assert.ok(existsSync(join(elsewhere, '.session-state.local.json')));
assert.match(readFileSync(join(elsewhere, 'NEXT-SESSION-PROMPT.local.md'), 'utf8'), /produced_by: trekexecute/);
} finally {
rmSync(elsewhere, { recursive: true, force: true });
}
});
// --- Phase 1 --project: the brief check and the execute resolver -------------
// Phase 1's --project branch runs the two lines /trekplan runs, for the execute
// phase, and halts only on BRIEF_V51_MISSING_SIGNALS.
/** Phase 1's rule 1 (the --project branch), as lines. */
function projectRule() {
const sec = sectionText(read(), '## Phase 1 — Parse mode and validate input').split('\n');
const start = sec.findIndex((l) => l.startsWith('1. If arguments contain `--project <dir>`'));
assert.ok(start !== -1, 'Phase 1 must still open with the --project rule');
const end = sec.findIndex((l, i) => i > start && /^2\. /.test(l));
assert.ok(end !== -1, 'Phase 1 must still have a rule 2');
return sec.slice(start, end).join('\n');
}
test('trekexecute Phase 1 --project runs brief-validator --soft --json and the execute resolver, and halts only on BRIEF_V51_MISSING_SIGNALS', () => {
const rule = projectRule();
const validator = libLine(rule, 'validators/brief-validator.mjs', '--soft --json');
const resolver = libLine(rule, 'profiles/resolver.mjs', '--resolve-phase-model --phase execute');
assert.equal(validator, 'node ${CLAUDE_PLUGIN_ROOT}/lib/validators/brief-validator.mjs --soft --json "{dir}/brief.md"');
assert.equal(resolver,
'node ${CLAUDE_PLUGIN_ROOT}/lib/profiles/resolver.mjs --resolve-phase-model --phase execute --brief-path "{dir}/brief.md" [--profile {profile}] --json');
const f = flat(rule);
for (const s of [
'The validator exits 1 when its `errors` are not empty; read its JSON either way.',
'Halt only when the validator\'s `errors` hold `BRIEF_V51_MISSING_SIGNALS`, with the message from "Sequencing gate surface" below.',
'Any other error is printed in one line and the run continues.',
'A validator or resolver that cannot run (no JSON) is "not measured" in one line, and the run continues.',
'Capture the resolver\'s JSON as `phase_signal_result` (Composition rule below).',
'No `{dir}/brief.md`: skip both lines.',
]) assert.ok(f.includes(s), `the --project rule is missing: ${s}`);
// The two lines run as printed: the validator names the one halting code for a
// 2.1 brief without signals, and the resolver hands Phase 2.5 its low effort.
const dir = mkdtempSync(join(tmpdir(), 'trekexecute-project-'));
try {
copyFileSync(join(ROOT, 'tests', 'fixtures', 'brief-v21-no-signals.md'), join(dir, 'brief.md'));
const v = bash(validator.split('{dir}').join(dir), ROOT);
assert.equal(v.status, 1, `validator: ${v.stdout}${v.stderr}`);
assert.ok(JSON.parse(v.stdout).errors.some((e) => e.code === 'BRIEF_V51_MISSING_SIGNALS'), v.stdout);
copyFileSync(join(ROOT, 'tests', 'fixtures', 'brief-effort-low.md'), join(dir, 'brief.md'));
const r = bash(resolver.split('{dir}').join(dir).replace(' [--profile {profile}]', ''), ROOT);
assert.equal(r.status, 0, `resolver: ${r.stdout}${r.stderr}`);
assert.equal(JSON.parse(r.stdout).effort, 'low');
} finally { rmSync(dir, { recursive: true, force: true }); }
});
// --- The merge lock refuses a review of an older commit ---------------------
// The merge lock (Phase 7.2 step 4 and the Phase 8 Main-merge gate) runs the
// verdict gate with `--repo .`, so a review of an older commit than the one
// being merged is refused (`review-stale`), and only the closing run's own
// review opens the merge.
const MERGE_GATE = '### Main-merge gate (MAIN_MERGE_GATE — always pauses)';
const PHASE_72 = '## Phase 7.2 — Simplify, review, verdict (P11)';
const LOCK_LINE = 'node ${CLAUDE_PLUGIN_ROOT}/lib/review/verdict-gate.mjs --json "{review_path}" --repo .';
const STEP4_LINE = 'node ${CLAUDE_PLUGIN_ROOT}/lib/review/verdict-gate.mjs --json "{project_dir}/review.md" --repo .';
/** A review_version 1.1 review.md of `sha`: ALLOW with no findings, or BLOCK over one BLOCKER. */
function lockReview(verdict, sha) {
const id = 'd'.repeat(40);
const findings = verdict === 'BLOCK'
? [{ id, severity: 'BLOCKER', rule_key: 'MISSING_TEST', file: 'a.mjs', line: 1, title: 't', detail: 'd', recommended_action: 'Add a test.' }]
: [];
const counts = { BLOCKER: findings.length, MAJOR: 0, MINOR: 0, SUGGESTION: 0 };
const block = JSON.stringify({ verdict, counts, allow_blocked_by: [], findings }, null, 2);
return [
'---', 'type: trekreview', 'review_version: "1.1"', 'task: "t"', 'slug: s',
'project_dir: .claude/projects/2026-10-23-s/', 'brief_path: .claude/projects/2026-10-23-s/brief.md',
`scope_sha_end: ${sha}`, 'reviewed_files_count: 1', `verdict: ${verdict}`,
findings.length ? `findings:\n - ${id}` : 'findings: []', '---', '', '# Review: t', '',
'## Executive Summary', '', 'x', '', '## Coverage', '', '| File | Treatment | Reason |', '|---|---|---|',
'| a.mjs | summary-only | default |', '', '## Remediation Summary', '', 'x', '', '```json', block, '```', '',
].join('\n');
}
/**
* A throwaway repo (no git hooks): the reviewed commit, then a later commit
* that changes source. `review(verdict, sha)` writes review.md outside the
* repo and returns its folder.
*/
function staleRepo() {
const base = mkdtempSync(join(tmpdir(), 'trekexecute-lock-'));
const repo = join(base, 'repo');
const out = join(base, 'project');
mkdirSync(repo);
mkdirSync(out);
mkdirSync(join(base, 'hooks-none'));
const g = (...args) => {
const r = spawnSync('git', args, { cwd: repo, encoding: 'utf8', env: childEnv() });
assert.equal(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`);
return r.stdout.trim();
};
g('init', '-q', '-b', 'main');
g('config', 'user.name', 'Test');
g('config', 'user.email', 'test@example.invalid');
g('config', 'commit.gpgsign', 'false');
g('config', 'core.hooksPath', join(base, 'hooks-none'));
writeFileSync(join(repo, 'a.mjs'), 'export const a = 1;\n');
g('add', 'a.mjs');
g('commit', '-q', '-m', 'feat: the reviewed work');
const reviewed = g('rev-parse', 'HEAD');
writeFileSync(join(repo, 'a.mjs'), 'export const a = 2;\n');
g('add', 'a.mjs');
g('commit', '-q', '-m', 'feat: a later source change');
const head = g('rev-parse', 'HEAD');
const review = (verdict, sha) => {
writeFileSync(join(out, 'review.md'), lockReview(verdict, sha));
return out;
};
return { repo, reviewed, head, review, cleanup: () => rmSync(base, { recursive: true, force: true }) };
}
let sharedStale = null;
after(() => sharedStale?.cleanup());
/** The file's one staleRepo, built on first use: its tests only rewrite review.md, outside the repo. */
function staleFixture() {
if (!sharedStale) sharedStale = staleRepo();
return sharedStale;
}
/** The lock line with its review path filled in, run in `repo` through bash(). */
function runLock(cmd, dir, repo) {
const filled = cmd.split('{review_path}').join(join(dir, 'review.md')).split('{project_dir}').join(dir);
return bash(filled, repo);
}
test('trekexecute merge lock: the Main-merge gate and Phase 7.2 step 4 each hold exactly one verdict-gate.mjs line, with --repo .', () => {
const text = read();
assert.equal(libLine(sectionText(text, MERGE_GATE), 'review/verdict-gate.mjs'), LOCK_LINE,
'the Phase 8 lock does not run the verdict gate against the repo being merged');
assert.equal(libLine(sectionText(text, PHASE_72), 'review/verdict-gate.mjs'), STEP4_LINE,
'Phase 7.2 step 4 does not run the verdict gate against the repo being merged');
});
test('trekexecute merge lock: the extracted lock lines, run on an ALLOW review of an older commit with a later source commit, exit 2 with review-stale', () => {
const text = read();
const lines = [libLine(sectionText(text, MERGE_GATE), 'review/verdict-gate.mjs'), libLine(sectionText(text, PHASE_72), 'review/verdict-gate.mjs')];
const fx = staleFixture();
for (const cmd of lines) {
const ok = runLock(cmd, fx.review('ALLOW', fx.head), fx.repo);
assert.equal(ok.status, 0, `control: an ALLOW review of HEAD opens the lock: ${ok.stdout}${ok.stderr}`);
const r = runLock(cmd, fx.review('ALLOW', fx.reviewed), fx.repo);
assert.equal(r.status, 2, `${cmd}: an ALLOW review of an older commit must exit 2: ${r.stdout}${r.stderr}`);
const reason = JSON.parse(r.stdout).reason;
assert.ok(reason.startsWith('review-stale'), `the reason must start review-stale: ${reason}`);
}
});
test('trekexecute merge lock: Phase 7.2 step 4 run on a BLOCK review of an older commit still exits 1, so the fix loop opens', () => {
const step4 = libLine(sectionText(read(), PHASE_72), 'review/verdict-gate.mjs');
const fx = staleFixture();
const r = runLock(step4, fx.review('BLOCK', fx.reviewed), fx.repo);
assert.equal(r.status, 1, `a BLOCK review must exit 1 before any staleness check: ${r.stdout}${r.stderr}`);
assert.equal(JSON.parse(r.stdout).verdict, 'BLOCK');
});

View file

@ -343,3 +343,39 @@ test('trekresearch — SC7: brief_version 2.1 + no phase_signals + no partial
`sequencing gate must fire; errors=${JSON.stringify(r.errors)}`,
);
});
// --- Spawn model, turn budget and stats writer ---
/** Whitespace (line breaks too) collapsed to one space. */
const flat = (s) => s.replace(/\s+/g, ' ').trim();
/** From the line holding `marker` to the first blank line, flattened. */
function paragraphAt(text, marker) {
const lines = text.split('\n');
const start = lines.findIndex((l) => l.includes(marker));
assert.ok(start !== -1, `commands/trekresearch.md must hold "${marker}"`);
let end = lines.length;
for (let i = start + 1; i < lines.length; i++) if (lines[i].trim() === '') { end = i; break; }
return flat(lines.slice(start, end).join(' '));
}
test('trekresearch — Phase 5 follow-ups take phase_signal_result.model, else opus', () => {
const text = read();
assert.doesNotMatch(flat(phase5(text)), /model: "opus"/);
const part = paragraphAt(text, 'launch a targeted follow-up agent');
assert.match(part, /launch a targeted follow-up agent \(model: phase_signal_result\.model, else opus — a follow-up may be one of Claude Code's built-in agents, which have no Voyage frontmatter to fall back to; see Launch rules\)/);
assert.match(flat(text), /captured as `phase_signal_result` and used at Agent-spawn sites in Phase 4 and Phase 5 to inject the resolved model/);
});
test('trekresearch — promises no maxTurns budget', () => {
const text = read();
assert.doesNotMatch(text, /maxTurns/);
assert.match(text, /- convention-scanner: medium\+ codebases only \(50\+ files\)/);
});
test('trekresearch — stats write through stats-append, never silently skipped', () => {
const md = read();
assert.match(md, /node \$\{CLAUDE_PLUGIN_ROOT\}\/lib\/stats\/stats-append\.mjs trekresearch <<'JSON'/);
assert.doesNotMatch(md, /skip (stats )?silently|silently skip/i);
assert.doesNotMatch(md, /\$\{CLAUDE_PLUGIN_DATA\}\/trekresearch-stats\.jsonl/);
});

View file

@ -121,3 +121,10 @@ test('command-modes.md — /trekreview table documents --workflow + its CC floor
assert.match(section, /2\.1\.154/,
'command-modes.md /trekreview --workflow row must note the Claude Code 2.1.154+ floor');
});
test('trekreview — stats write through stats-append, never silently skipped', () => {
const md = read();
assert.match(md, /node \$\{CLAUDE_PLUGIN_ROOT\}\/lib\/stats\/stats-append\.mjs trekreview <<'JSON'/);
assert.doesNotMatch(md, /skip (stats )?silently|silently skip/i);
assert.doesNotMatch(md, /\$\{CLAUDE_PLUGIN_DATA\}\/trekreview-stats\.jsonl/);
});

View file

@ -1,168 +0,0 @@
#!/usr/bin/env bash
# tests/e2e-critic-diff-mode.sh
# End to end for the critic loop's diff mode (decision 43): the plan gate CLI
# run the way /trekplan Phase 9 runs it, in a temp git repo.
# 1. git init a temp repo and build a 1.8 plan with
# tests/helpers/plan-v18-builder.mjs.
# 2. --hash, a round-1 reviewer payload (one major) through
# lib/review/plan-review-dedup.mjs --stdin into a file, then
# --record < file (exit 0, REVISE).
# 3. Edit one step line with node -e.
# 4. --diff names the edited line (its number found with grep -n).
# 5. Save a copy of the plan.
# 6. A: round 2 marks the round-1 major resolved and adds a new major on a
# line outside the edit (exit 0, PASS); the bare gate exits 0.
# 7. Restore the copy. B: the same, with the new major on the edited line
# (exit 0, REVISE); the bare gate exits 1 with PLAN_GATE_OPEN_MAJOR.
# 8. git status --porcelain --untracked-files=all lists no snapshot; the
# round-1 snapshot file exists under .git/voyage/plan-snapshots/.
# 9. Round 3 (plan unchanged, counted fully): a minor merged with its copy
# that claims a lowering from blocker, and a minor with a lowered_from
# that is not a severity (exit 0, BLOCK, two blockers); the bare gate
# exits 1 with PLAN_GATE_OPEN_BLOCKER.
# Prints "e2e: ok" and exits 0, or names the failed check and exits 1.
# bash 3.2 compatible, ASCII only.
set -u
cd "$(dirname "$0")/.." || { echo "e2e: FAIL - cannot cd to the repo root" >&2; exit 1; }
ROOT="$(pwd)"
GATE="$ROOT/lib/validators/plan-gate.mjs"
DEDUP="$ROOT/lib/review/plan-review-dedup.mjs"
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
TMP="$(mktemp -d "${TMPDIR:-/tmp}/e2e-critic-diff-mode.XXXXXX")" || { echo "e2e: FAIL - mktemp" >&2; exit 1; }
trap 'rm -rf "$TMP"' EXIT
fail() {
echo "e2e: FAIL - $1" >&2
exit 1
}
REPO="$TMP/repo"
PLAN="$REPO/plan.md"
# gate <args...>: the plan gate run from inside the temp repo (findings cite plan.md).
gate() {
(cd "$REPO" && node "$GATE" "$@")
}
# round <payload-file>: --hash, dedup --stdin, then --record; prints the record line.
round() {
h="$(gate --hash plan.md)" || fail "--hash exited non-zero"
node "$DEDUP" --stdin < "$1" > "$TMP/dedup.json" || fail "plan-review-dedup --stdin on $1"
gate --record plan.md --reviewed "$h" < "$TMP/dedup.json"
}
# ---- 1. a temp git repo and a 1.8 plan ----
mkdir -p "$REPO" || fail "mkdir $REPO"
git -C "$REPO" init -q || fail "git init $REPO"
node --input-type=module -e '
import { writeFileSync } from "node:fs";
import { buildPlan } from "./tests/helpers/plan-v18-builder.mjs";
writeFileSync(process.argv[1], buildPlan());
' "$PLAN" || fail "could not write the plan with tests/helpers/plan-v18-builder.mjs"
# ---- 2. round 1: one major ----
L1="$(grep -n 'Verifies: run1()' "$PLAN" | head -n 1 | cut -d: -f1)"
[ -n "$L1" ] || fail "no Verifies line for step 1 in the plan"
M1='Step 1 verification never states the expected return value'
printf '{"plan_critic":{"findings":[{"file":"plan.md","line":%s,"rule_key":"step-quality","severity":"major","text":"%s"}]},"scope_guardian":{"findings":[]}}\n' \
"$L1" "$M1" > "$TMP/r1.json"
H1="$(gate --hash plan.md)" || fail "--hash before round 1"
out="$(round "$TMP/r1.json")"
rc=$?
[ "$rc" -eq 0 ] || fail "round 1 --record: exit $rc, expected 0: $out"
echo "$out" | grep -q '"verdict":"REVISE"' || fail "round 1: no REVISE in: $out"
ID1="$(node -e '
const t = require("node:fs").readFileSync(process.argv[1], "utf8");
const v = JSON.parse(t.match(/```json\n([\s\S]*?)\n```/)[1]);
process.stdout.write(v.findings[0].id);
' "$PLAN")" || fail "could not read the round-1 finding id"
echo "$ID1" | grep -Eq '^[0-9a-f]{40}$' || fail "round-1 finding id is not 40 hex: $ID1"
# ---- 3. edit one step line ----
node -e '
const fs = require("node:fs");
const p = process.argv[1];
const t = fs.readFileSync(p, "utf8");
const from = "Add `run2()` to `src/mod2.mjs`.";
if (!t.includes(from)) process.exit(1);
fs.writeFileSync(p, t.replace(from, "Add `run2()` and its error path to `src/mod2.mjs`."));
' "$PLAN" || fail "could not edit the Step 2 Changes line"
LE="$(grep -n 'its error path' "$PLAN" | head -n 1 | cut -d: -f1)"
[ -n "$LE" ] || fail "the edited line is not in the plan"
# ---- 4. --diff names the edited line ----
out="$(gate --diff plan.md)"
rc=$?
[ "$rc" -eq 0 ] || fail "--diff: exit $rc, expected 0: $out"
echo "$out" | head -n 1 | grep -q "^diff-base: $H1 (round 1)\$" || fail "--diff: first line is not the round-1 base: $out"
echo "$out" | grep -q "^@@ lines $LE-$LE @@\$" || fail "--diff: no hunk header for line $LE in: $out"
echo "$out" | grep -q "^+$LE .*its error path" || fail "--diff: line $LE is not named as a new line in: $out"
# ---- 5. save a copy ----
cp "$PLAN" "$TMP/plan-after-edit.md" || fail "could not copy the plan"
# ---- 6. A: the new major is outside the edit ----
LO="$(grep -n 'checkout -- src/mod1.mjs' "$PLAN" | head -n 1 | cut -d: -f1)"
[ -n "$LO" ] || fail "no On failure line for step 1 in the plan"
MA='The rollback named for this step gives no check that the revert actually worked'
printf '{"plan_critic":{"previous":[{"id":"%s","resolved":true}],"findings":[{"file":"plan.md","line":%s,"rule_key":"step-quality","severity":"major","text":"%s"}]},"scope_guardian":{"previous":[{"id":"%s","resolved":true}],"findings":[]}}\n' \
"$ID1" "$LO" "$MA" "$ID1" > "$TMP/r2a.json"
out="$(round "$TMP/r2a.json")"
rc=$?
[ "$rc" -eq 0 ] || fail "A: round 2 --record: exit $rc, expected 0: $out"
echo "$out" | grep -q '"verdict":"PASS"' || fail "A: round 2 outside the diff is not PASS: $out"
echo "$out" | grep -q "\"diff_base\":\"$H1\"" || fail "A: round 2 did not diff against round 1: $out"
out="$(gate plan.md)"
rc=$?
[ "$rc" -eq 0 ] || fail "A: the bare gate exit $rc, expected 0: $out"
# ---- 7. B: the new major is on the edited line ----
cp "$TMP/plan-after-edit.md" "$PLAN" || fail "could not restore the plan"
MB='The error path named here gives callers no thrown type to match on'
printf '{"plan_critic":{"previous":[{"id":"%s","resolved":true}],"findings":[{"file":"plan.md","line":%s,"rule_key":"step-quality","severity":"major","text":"%s"}]},"scope_guardian":{"previous":[{"id":"%s","resolved":true}],"findings":[]}}\n' \
"$ID1" "$LE" "$MB" "$ID1" > "$TMP/r2b.json"
out="$(round "$TMP/r2b.json")"
rc=$?
[ "$rc" -eq 0 ] || fail "B: round 2 --record: exit $rc, expected 0: $out"
echo "$out" | grep -q '"verdict":"REVISE"' || fail "B: round 2 inside the diff is not REVISE: $out"
out="$(gate plan.md)"
rc=$?
[ "$rc" -eq 1 ] || fail "B: the bare gate exit $rc, expected 1: $out"
echo "$out" | grep -q 'PLAN_GATE_OPEN_MAJOR' || fail "B: no PLAN_GATE_OPEN_MAJOR in: $out"
# ---- 8. snapshots stay in the git directory ----
st="$(git -C "$REPO" status --porcelain --untracked-files=all)" || fail "git status in the temp repo"
echo "$st" | grep -q 'plan-snapshots' && fail "git status lists a snapshot: $st"
[ "$st" = '?? plan.md' ] || fail "git status lists more than the plan: $st"
[ -f "$REPO/.git/voyage/plan-snapshots/$H1.md" ] || fail "no round-1 snapshot under .git/voyage/plan-snapshots/"
# ---- 9. round 3: a refused and a malformed lowering reach the gate as blockers ----
M9='Step 1 never names the exit code its verify command expects'
G9='The revert for step one is never run against a dirty tree'
printf '{"plan_critic":{"findings":[{"file":"plan.md","line":%s,"rule_key":"step-quality","severity":"minor","text":"%s"}]},"scope_guardian":{"findings":[{"file":"plan.md","line":%s,"rule_key":"step-quality","severity":"minor","lowered_from":"blocker","caught_by":"npm test","text":"%s"},{"file":"plan.md","line":%s,"rule_key":"gap","severity":"minor","lowered_from":"high","caught_by":"npm test","text":"%s"}]}}\n' \
"$L1" "$M9" "$L1" "$M9" "$LO" "$G9" > "$TMP/r3.json"
out="$(round "$TMP/r3.json")"
rc=$?
[ "$rc" -eq 0 ] || fail "9: round 3 --record: exit $rc, expected 0: $out"
echo "$out" | grep -q '"verdict":"BLOCK"' || fail "9: round 3 is not BLOCK: $out"
echo "$out" | grep -q '"blocker":2' || fail "9: round 3 does not count two blockers: $out"
out="$(gate plan.md)"
rc=$?
[ "$rc" -eq 1 ] || fail "9: the bare gate exit $rc, expected 1: $out"
echo "$out" | grep -q 'PLAN_GATE_OPEN_BLOCKER' || fail "9: no PLAN_GATE_OPEN_BLOCKER in: $out"
echo "e2e: ok"
exit 0

View file

@ -1,201 +0,0 @@
#!/usr/bin/env bash
# tests/e2e-fix-round-4-5a.sh
# End to end for fix round 4-5a (plan
# .claude/projects/2026-10-20-fix-round-serious-findings, End-to-end:): the
# shipped CLIs run the way the commands run them, in temp git repos.
# 1. The park tool (lib/util/park-attempt-files.mjs), on the diff the
# "Back to planning" line saves:
# a. a new file registered --intent-to-add is parked (exit 0, gone from
# the tree and the index, kept under .git/voyage/parked/);
# b. a coloured diff (--color=always, as color.diff=always gives it)
# still parks its new file;
# c. --cwd a subdirectory of the repo parks the root-relative path.
# 2. The verdict gate (lib/review/verdict-gate.mjs --json <review> --repo .):
# a review of HEAD (0), a later lessons-only commit (0), a later source
# commit (2, reason review-stale).
# 3. The arg parser (lib/parsers/arg-parser.mjs --command trekendsession --
# fix-header): no help flag, the slug is the first positional.
# 4. The gates shim (lib/util/autonomy-gate.mjs): `--gates closed` and a bare
# `--gates` both give next_state gates_on.
# Prints "e2e: ok" and exits 0, or names the failed check and exits 1.
# bash 3.2 compatible, ASCII only.
set -u
cd "$(dirname "$0")/.." || { echo "e2e: FAIL - cannot cd to the repo root" >&2; exit 1; }
ROOT="$(pwd)"
PARK="$ROOT/lib/util/park-attempt-files.mjs"
VERDICT="$ROOT/lib/review/verdict-gate.mjs"
ARGS="$ROOT/lib/parsers/arg-parser.mjs"
SHIM="$ROOT/lib/util/autonomy-gate.mjs"
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
TMP="$(mktemp -d "${TMPDIR:-/tmp}/e2e-fix-round-4-5a.XXXXXX")" || { echo "e2e: FAIL - mktemp" >&2; exit 1; }
trap 'rm -rf "$TMP"' EXIT
fail() {
echo "e2e: FAIL - $1" >&2
exit 1
}
mkdir -p "$TMP/hooks-none" || fail "mkdir hooks-none"
# newrepo <dir>: git init with no hooks, no signing, one identity.
newrepo() {
mkdir -p "$1" || fail "mkdir $1"
git -C "$1" init -q || fail "git init $1"
printf '[user]\n\tname = Test\n\temail = test@example.invalid\n[commit]\n\tgpgsign = false\n[core]\n\thooksPath = "%s"\n' \
"$TMP/hooks-none" >> "$1/.git/config" || fail "git config $1"
}
# jsonok <check> <js expression on j>: stdin is JSON; exit 0 when the expression is true.
jsonok() {
node -e 'let s="";process.stdin.on("data",(d)=>{s+=d;}).on("end",()=>{let j;try{j=JSON.parse(s);}catch(e){process.exit(3);}process.exit(Function("j","return ("+process.argv[1]+");")(j)?0:1);});' "$1"
}
# ---- 1. the park tool ----
P="$TMP/park"
newrepo "$P"
mkdir -p "$P/bin" "$P/tests" || fail "mkdir park dirs"
printf 'old\n' > "$P/bin/slug.mjs"
git -C "$P" add bin/slug.mjs || fail "git add bin/slug.mjs"
git -C "$P" commit -q -m "base" || fail "git commit base"
START="$(git -C "$P" rev-parse HEAD)" || fail "rev-parse start"
PARKED="$P/.git/voyage/parked"
# savediff <file> [color]: the "Back to planning" save line of commands/trekexecute.md;
# with "color", the same diff coloured (--color=always in place of --no-color).
savediff() {
color=--no-color
[ "${2:-}" = color ] && color=--color=always
git -C "$P" -c core.quotepath=off diff --no-ext-diff "$color" --no-renames --no-relative --src-prefix=a/ --dst-prefix=b/ "$START" > "$1" \
|| fail "git diff for $1"
}
# parked_ok <name> <attempt>: tests/<name> left the tree and the index and sits in the parked dir.
parked_ok() {
[ ! -e "$P/tests/$1" ] || fail "park $2: tests/$1 is still in the work tree"
if git -C "$P" ls-files --error-unmatch -- "tests/$1" >/dev/null 2>&1; then
fail "park $2: tests/$1 is still in the index"
fi
[ -f "$PARKED/step-1-attempt-$2/tests/$1" ] || fail "park $2: tests/$1 is not under the parked directory"
}
# 1a. an intent-to-add file
printf 'stale | cat\n' > "$P/tests/a.sh"
git -C "$P" add --intent-to-add -- tests/a.sh || fail "git add --intent-to-add tests/a.sh"
savediff "$TMP/park-1.diff"
grep -q '^new file mode' "$TMP/park-1.diff" || fail "park 1: the saved diff names no new file"
out="$(node "$PARK" --diff "$TMP/park-1.diff" --step 1 --attempt 1 --cwd "$P")" || fail "park 1 (intent-to-add) exited non-zero: $out"
parked_ok a.sh 1
# 1b. a coloured diff
printf 'stale\n' > "$P/tests/b.sh"
git -C "$P" add --intent-to-add -- tests/b.sh || fail "git add --intent-to-add tests/b.sh"
savediff "$TMP/park-2.diff" color
ESC="$(printf '\033')"
grep -q "$ESC" "$TMP/park-2.diff" || fail "park 2: control - the diff holds no colour code"
out="$(node "$PARK" --diff "$TMP/park-2.diff" --step 1 --attempt 2 --cwd "$P")" || fail "park 2 (coloured diff) exited non-zero: $out"
parked_ok b.sh 2
# 1c. from a subdirectory
printf 'stale\n' > "$P/tests/c.sh"
git -C "$P" add --intent-to-add -- tests/c.sh || fail "git add --intent-to-add tests/c.sh"
savediff "$TMP/park-3.diff"
out="$(node "$PARK" --diff "$TMP/park-3.diff" --step 1 --attempt 3 --cwd "$P/bin")" || fail "park 3 (--cwd bin) exited non-zero: $out"
parked_ok c.sh 3
[ "$(cat "$P/bin/slug.mjs")" = "old" ] || fail "park: a committed file was touched"
# ---- 2. the verdict gate with --repo ----
V="$TMP/verdict"
newrepo "$V"
mkdir -p "$TMP/project" || fail "mkdir project"
printf 'export const a = 1;\n' > "$V/a.mjs"
git -C "$V" add a.mjs || fail "git add a.mjs"
git -C "$V" commit -q -m "feat: the reviewed work" || fail "git commit reviewed"
REVIEWED="$(git -C "$V" rev-parse HEAD)" || fail "rev-parse reviewed"
REVIEW="$TMP/project/review.md"
cat > "$REVIEW" <<EOF
---
type: trekreview
review_version: "1.1"
task: "t"
slug: s
project_dir: .claude/projects/2026-10-23-s/
brief_path: .claude/projects/2026-10-23-s/brief.md
scope_sha_end: $REVIEWED
reviewed_files_count: 1
verdict: ALLOW
findings: []
---
# Review: t
## Executive Summary
x
## Coverage
| File | Treatment | Reason |
|---|---|---|
| a.mjs | summary-only | default |
## Remediation Summary
x
\`\`\`json
{
"verdict": "ALLOW",
"counts": { "BLOCKER": 0, "MAJOR": 0, "MINOR": 0, "SUGGESTION": 0 },
"allow_blocked_by": [],
"findings": []
}
\`\`\`
EOF
# gate: the merge lock line of commands/trekexecute.md, run in the repo being merged.
gate() {
(cd "$V" && node "$VERDICT" --json "$REVIEW" --repo .)
}
out="$(gate)" || fail "verdict 1: a review of HEAD did not exit 0: $out"
mkdir -p "$V/.claude/rules" || fail "mkdir .claude/rules"
# The block holds exactly BLOCK_HEADER (lib/review/recurrence.mjs); each \302\267 is the
# UTF-8 middle dot, written as octal escapes so this script stays ASCII.
printf '<!-- voyage:review-lessons:start -->\nEach line: a finding class /trekreview saw in two reviews, approved by the operator. Format: mistake \302\267 fix \302\267 how to check.\n<!-- voyage:review-lessons:end -->\n' \
> "$V/.claude/rules/voyage-review-lessons.md"
git -C "$V" add .claude/rules/voyage-review-lessons.md || fail "git add lessons"
git -C "$V" commit -q -m "docs(voyage): review lessons (round 1)" || fail "git commit lessons"
out="$(gate)" || fail "verdict 2: a later lessons-only commit did not exit 0: $out"
printf 'export const a = 2;\n' > "$V/a.mjs"
git -C "$V" add a.mjs || fail "git add a.mjs (later)"
git -C "$V" commit -q -m "feat: a later source change" || fail "git commit later"
out="$(gate)"
code=$?
[ "$code" -eq 2 ] || fail "verdict 3: a review of an older commit exited $code, not 2: $out"
printf '%s' "$out" | jsonok 'typeof j.reason === "string" && j.reason.indexOf("review-stale") === 0' \
|| fail "verdict 3: the reason does not start review-stale: $out"
# ---- 3. the arg parser for /trekendsession ----
out="$(node "$ARGS" --command trekendsession -- fix-header)" || fail "arg-parser on fix-header exited non-zero: $out"
printf '%s' "$out" | jsonok 'j.flags["--help"] === undefined && j.positional[0] === "fix-header"' \
|| fail "arg-parser: fix-header asked for help or is not the first positional: $out"
# ---- 4. the gates shim ----
out="$(node "$SHIM" --state idle --event start --gates closed)" || fail "shim --gates closed exited non-zero: $out"
printf '%s' "$out" | jsonok 'j.ok === true && j.next_state === "gates_on"' || fail "shim: --gates closed is not gates_on: $out"
out="$(node "$SHIM" --state idle --event start --gates)" || fail "shim bare --gates exited non-zero: $out"
printf '%s' "$out" | jsonok 'j.ok === true && j.next_state === "gates_on"' || fail "shim: a bare --gates is not gates_on: $out"
echo "e2e: ok"
exit 0

View file

@ -1,220 +0,0 @@
#!/usr/bin/env bash
# tests/e2e-fix-round-4-5b-part-2.sh
# End to end for fix round 4-5b, part 2 (plan
# .claude/projects/2026-10-27-fix-round-4-5b-part-2, End-to-end:, decision
# 88): the shipped products run the way a session runs them, in temp
# directories and temp git repos.
# 1. TOK: the arg parser (lib/parsers/arg-parser.mjs --command trekreview
# --stdin) reads `--project "my dir"/x --since "HEAD~1"` from a quoted
# heredoc, and its JSON keeps `--since` = `HEAD~1`.
# 2. HDG: the Bash hook (hooks/scripts/pre-bash-executor.mjs) with a Bash
# tool input on stdin:
# a. a one-line `grep` that mentions the parser's heredoc opener exits 0;
# b. a parser heredoc that closes early exits 2 (args-heredoc-closed-early);
# c. a quoted first mention hiding an unquoted parser heredoc exits 2
# (args-heredoc-closed-early).
# 3. CKI: `recurrence.mjs apply` in a repo whose `.claude` is a symlink to an
# in-repo directory: the lesson is written, `pointer` is `check-failed`,
# and CLAUDE.md is unchanged.
# DLC is prose (commands/trekexecute.md Sub-step G); it is checked by
# tests/lib/fix-round-4-5b-part-2-prose.test.mjs, not by this run.
# Prints one "ok - <case>" or "FAIL - <case>: <why>" line per case and exits 1
# when any case failed, else 0. Starts nothing but node and git (and the
# usual file tools). bash 3.2 compatible, ASCII only, no arrays.
set -u
cd "$(dirname "$0")/.." || { echo "FAIL - setup: cannot cd to the repo root"; exit 1; }
ROOT="$(pwd)"
ARGS="$ROOT/lib/parsers/arg-parser.mjs"
HOOK="$ROOT/hooks/scripts/pre-bash-executor.mjs"
REC="$ROOT/lib/review/recurrence.mjs"
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE CLAUDE_CODE_SESSION_ID
TMP="$(mktemp -d "${TMPDIR:-/tmp}/e2e-fix-round-4-5b-part-2.XXXXXX")" || { echo "FAIL - setup: mktemp"; exit 1; }
trap 'rm -rf "$TMP"' EXIT
TMP="$(cd "$TMP" && pwd -P)" || { echo "FAIL - setup: cannot enter the temp directory"; exit 1; }
mkdir -p "$TMP/hooks-none" "$TMP/home" || { echo "FAIL - setup: mkdir"; exit 1; }
: > "$TMP/empty-gitconfig" || { echo "FAIL - setup: empty git config"; exit 1; }
# The git environment is fixed here, so a global core.excludesFile, the temp
# directory's location or a translated git cannot change a result.
export HOME="$TMP/home"
export XDG_CONFIG_HOME="$TMP/home"
export GIT_CONFIG_GLOBAL="$TMP/empty-gitconfig"
export GIT_CONFIG_NOSYSTEM=1
export GIT_CEILING_DIRECTORIES="$TMP"
export LC_ALL=C
export LANG=C
unset LANGUAGE
FAILED=0
pass() { echo "ok - $1"; }
bad() { echo "FAIL - $1"; FAILED=$((FAILED + 1)); }
# newrepo <dir>: git init with no hooks, no signing, one repo-local identity.
newrepo() {
mkdir -p "$1" || return 1
git init -q "$1" || return 1
printf '[user]\n\tname = Test\n\temail = test@example.invalid\n[commit]\n\tgpgsign = false\n[core]\n\thooksPath = "%s"\n' \
"$TMP/hooks-none" >> "$1/.git/config"
}
# jsonok <js expression on j>: stdin is JSON; exit 0 when the expression is true.
jsonok() {
node -e 'let s="";process.stdin.on("data",(d)=>{s+=d;}).on("end",()=>{let j;try{j=JSON.parse(s);}catch(e){process.exit(3);}process.exit(Function("j","return ("+process.argv[1]+");")(j)?0:1);});' "$1"
}
# hookrun <out> <line>...: the Bash hook with a Bash tool input whose command
# is the given lines joined by newlines; stderr goes to <out>. Returns the
# hook's exit status.
hookrun() {
out="$1"
shift
node -e 'process.stdout.write(JSON.stringify({ tool_name: "Bash", tool_input: { command: process.argv.slice(1).join("\n") } }));' "$@" \
| node "$HOOK" > /dev/null 2> "$out"
}
D=VOYAGE_ARGS_EOF_7F3C9E
PARSER_LINE='node /plugin/lib/parsers/arg-parser.mjs --command trekendsession --stdin'
# ---- 1. TOK: the arg parser keeps a later flag ----
case_tok() {
name="arg-parser --stdin: a quoted value stops at the quote that starts --since's value"
W="$TMP/args"
mkdir -p "$W" || { bad "$name: mkdir"; return; }
( cd "$W" && node "$ARGS" --command trekreview --stdin > "$TMP/args.json" ) <<'VOYAGE_ARGS_EOF_7F3C9E'
--project "my dir"/x --since "HEAD~1"
VOYAGE_ARGS_EOF_7F3C9E
c=$?
if [ "$c" -ne 0 ]; then
bad "$name: the parser exited $c: $(cat "$TMP/args.json")"
elif ! jsonok 'j.flags["--since"] === "HEAD~1"' < "$TMP/args.json"; then
bad "$name: --since is not HEAD~1: $(cat "$TMP/args.json")"
else
pass "$name (--since = HEAD~1)"
fi
}
# ---- 2. HDG: the Bash hook ----
case_hdg_one_line() {
name="Bash hook: a one-line grep that mentions the opener passes"
hookrun "$TMP/hdg-1.err" "grep -n \"<<'$D'\" commands/trekreview.md"
c=$?
if [ "$c" -ne 0 ]; then
bad "$name: the hook exited $c: $(cat "$TMP/hdg-1.err")"
else
pass "$name (exit 0)"
fi
}
case_hdg_early_close() {
name="Bash hook: a parser heredoc that closes early is blocked"
hookrun "$TMP/hdg-2.err" "$PARSER_LINE <<'$D'" 'brief.md' "$D" 'touch PWNED'
c=$?
if [ "$c" -ne 2 ]; then
bad "$name: the hook exited $c, not 2: $(cat "$TMP/hdg-2.err")"
elif ! grep -q 'args-heredoc-closed-early' "$TMP/hdg-2.err"; then
bad "$name: the hook did not name args-heredoc-closed-early: $(cat "$TMP/hdg-2.err")"
else
pass "$name (exit 2, args-heredoc-closed-early)"
fi
}
case_hdg_hidden() {
name="Bash hook: a quoted first mention does not hide an unquoted parser heredoc"
hookrun "$TMP/hdg-3.err" "echo \"<<'$D'\"" "$PARSER_LINE <<$D" 'brief.md $(touch PWNED)' "$D"
c=$?
if [ "$c" -ne 2 ]; then
bad "$name: the hook exited $c, not 2: $(cat "$TMP/hdg-3.err")"
elif ! grep -q 'args-heredoc-closed-early' "$TMP/hdg-3.err"; then
bad "$name: the hook did not name args-heredoc-closed-early: $(cat "$TMP/hdg-3.err")"
else
pass "$name (exit 2, args-heredoc-closed-early)"
fi
}
# ---- 3. CKI: applyLesson with a symlinked .claude ----
# lessonreview <file> <slug> <sha> <id> <n>: a review.md with one MISSING_TEST finding.
lessonreview() {
mkdir -p "$(dirname "$1")" || return 1
fid="$(printf '%s0000000000000000000000000000000000000000' "$4" | cut -c1-40)"
cat > "$1" <<EOF
---
type: trekreview
review_version: "1.1"
task: "$2"
slug: $2
project_dir: .claude/projects/2026-10-0$5-$2/
brief_path: .claude/projects/x/brief.md
scope_sha_end: $3
reviewed_files_count: 1
verdict: WARN
findings:
- $fid
---
# Review: $2
## Executive Summary
x
## Coverage
x
## Remediation Summary
x
\`\`\`json
{
"verdict": "WARN",
"counts": { "BLOCKER": 0, "MAJOR": 1, "MINOR": 0, "SUGGESTION": 0 },
"findings": [
{ "id": "$fid", "severity": "MAJOR", "rule_key": "MISSING_TEST", "file": "lib/a.mjs", "line": 3,
"title": "MISSING_TEST title $4", "detail": "d", "recommended_action": "Fix it ($4)." }
]
}
\`\`\`
EOF
}
case_cki_symlink() {
name="applyLesson: a .claude symlinked inside the repo gets no pointer (check-failed)"
R="$TMP/lesson-link"
newrepo "$R" || { bad "$name: fixture repo"; return; }
lessonreview "$R/claude-data/projects/2026-10-01-one/review.md" one aaa a1 1 || { bad "$name: review one"; return; }
lessonreview "$R/claude-data/projects/2026-10-02-two/review.md" two bbb b1 2 || { bad "$name: review two"; return; }
( cd "$R" && ln -s claude-data .claude ) || { bad "$name: ln -s"; return; }
printf '# Project\n' > "$R/CLAUDE.md"
out="$(node "$REC" apply --repo "$R" --rule-key MISSING_TEST)"; c=$?
if [ "$c" -ne 0 ]; then
bad "$name: apply exited $c: $out"
elif ! printf '%s' "$out" | jsonok 'j.written === true && j.pointer === "check-failed" && /check-ignore/.test(String(j.pointer_note))'; then
bad "$name: the result is not written with pointer check-failed and a check-ignore note: $out"
elif [ "$(cat "$R/CLAUDE.md")" != "# Project" ]; then
bad "$name: CLAUDE.md was changed"
else
pass "$name"
fi
}
case_tok
case_hdg_one_line
case_hdg_early_close
case_hdg_hidden
case_cki_symlink
echo "e2e: DLC is prose; tests/lib/fix-round-4-5b-part-2-prose.test.mjs checks it, not this run"
if [ "$FAILED" -ne 0 ]; then
echo "e2e: $FAILED case(s) failed"
exit 1
fi
echo "e2e: ok"
exit 0

View file

@ -1,330 +0,0 @@
#!/usr/bin/env bash
# tests/e2e-fix-round-4-5b-part-3.sh
# End to end for fix round 4-5b, part 3 (plan
# .claude/projects/2026-10-28-fix-round-4-5b-part-3, End-to-end:, decision
# 89): the shipped products run the way a session runs them, in a temp
# directory.
# 1. CQW, the shipped way: the parser block of commands/trekreview.md
# (## Phase 1), with ${CLAUDE_PLUGIN_ROOT} and $ARGUMENTS filled in, passes
# the Bash hook, and /bin/bash runs it to JSON that keeps a value closed
# on its own line.
# 2. CQW through the parser CLI's --stdin: `"my dir "` pairs, and
# `--project "my dir"/x --since "HEAD~1"` keeps --since = HEAD~1.
# 3. PQO through the Bash hook (hooks/scripts/pre-bash-executor.mjs):
# partly quoted, $'...' and backslash-newline openers are blocked; a
# string-borne << hides no real opener; the shipped form with a typed
# `fix "<<" handling` argument and the agents/review-orchestrator.md
# block pass.
# 4. PQO premise: /bin/bash itself runs the line after the closing line of
# a partly quoted opener, so blocking it is not a false positive.
# 5. PQO fix rounds 1 and 2 of step 2, through the Bash hook: a
# continuation between a $ and its quote (at the word's start and inside
# it) is blocked; a partly quoted opener whose word ends in a CR (CRLF
# lines) or a VT, closed by the same line, is blocked when a line follows
# the closing line; with CRLF lines and the closing line last it passes.
# Prints one "ok - <case>" or "FAIL - <case>: <why>" line per case and exits 1
# when any case failed, else 0. Starts nothing but node and /bin/bash (and the
# usual file tools). bash 3.2 compatible, ASCII only, no arrays.
set -u
cd "$(dirname "$0")/.." || { echo "FAIL - setup: cannot cd to the repo root"; exit 1; }
ROOT="$(pwd)"
ARGS="$ROOT/lib/parsers/arg-parser.mjs"
HOOK="$ROOT/hooks/scripts/pre-bash-executor.mjs"
unset CLAUDE_CODE_SESSION_ID
TMP="$(mktemp -d "${TMPDIR:-/tmp}/e2e-fix-round-4-5b-part-3.XXXXXX")" || { echo "FAIL - setup: mktemp"; exit 1; }
trap 'rm -rf "$TMP"' EXIT
TMP="$(cd "$TMP" && pwd -P)" || { echo "FAIL - setup: cannot enter the temp directory"; exit 1; }
export LC_ALL=C
export LANG=C
FAILED=0
pass() { echo "ok - $1"; }
bad() { echo "FAIL - $1"; FAILED=$((FAILED + 1)); }
# jsonok <js expression on j>: stdin is JSON; exit 0 when the expression is true.
jsonok() {
node -e 'let s="";process.stdin.on("data",(d)=>{s+=d;}).on("end",()=>{let j;try{j=JSON.parse(s);}catch(e){process.exit(3);}process.exit(Function("j","return ("+process.argv[1]+");")(j)?0:1);});' "$1"
}
# hookrun <out> <line>...: the Bash hook with a Bash tool input whose command
# is the given lines joined by newlines; stderr goes to <out>. Returns the
# hook's exit status.
hookrun() {
out="$1"
shift
node -e 'process.stdout.write(JSON.stringify({ tool_name: "Bash", tool_input: { command: process.argv.slice(1).join("\n") } }));' "$@" \
| node "$HOOK" > /dev/null 2> "$out"
}
# hookfile <out> <file>: the Bash hook with the file's text as the command.
hookfile() {
node -e 'process.stdout.write(JSON.stringify({ tool_name: "Bash", tool_input: { command: require("fs").readFileSync(process.argv[1], "utf8") } }));' "$2" \
| node "$HOOK" > /dev/null 2> "$1"
}
D=VOYAGE_ARGS_EOF_7F3C9E
PARSER_LINE='node /plugin/lib/parsers/arg-parser.mjs --command trekendsession --stdin'
# The typed arguments: a quoted value closed on its own line.
TYPED="$(printf '%s\n%s' '--project "notes' '" --since HEAD')"
# ---- 1. CQW, the shipped way ----
case_cqw_shipped() {
name="trekreview Phase 1 block: passes the hook and keeps a value closed on its own line"
W="$TMP/shipped"
mkdir -p "$W" || { bad "$name: mkdir"; return; }
# The block as written, with ${CLAUDE_PLUGIN_ROOT} and $ARGUMENTS filled in
# the way Claude Code fills them.
node -e '
const fs = require("fs");
const [md, root, args] = process.argv.slice(1);
const text = fs.readFileSync(md, "utf8");
const s = text.indexOf("## Phase 1 ");
if (s === -1) process.exit(3);
const rest = text.slice(s);
const e = rest.search(/\n## /);
const phase = e === -1 ? rest : rest.slice(0, e);
const blocks = phase.split("\n```bash\n").slice(1).map((b) => b.split("\n```")[0]).filter((b) => b.includes("<<" + String.fromCharCode(39) + "VOYAGE_ARGS_EOF_7F3C9E" + String.fromCharCode(39)));
if (blocks.length !== 1) { console.error("expected one Phase 1 parser block, found " + blocks.length); process.exit(3); }
process.stdout.write(blocks[0].split("${CLAUDE_PLUGIN_ROOT}").join(root).split("$ARGUMENTS").join(args) + "\n");
' "$ROOT/commands/trekreview.md" "$ROOT" "$TYPED" > "$TMP/phase1.sh" \
|| { bad "$name: could not read the Phase 1 block"; return; }
hookfile "$TMP/shipped.err" "$TMP/phase1.sh"
c=$?
if [ "$c" -ne 0 ]; then
bad "$name: the hook exited $c: $(cat "$TMP/shipped.err")"
return
fi
( cd "$W" && /bin/bash "$TMP/phase1.sh" ) > "$TMP/shipped.json" 2> "$TMP/shipped.out"
c=$?
if [ "$c" -ne 0 ]; then
bad "$name: the block exited $c: $(cat "$TMP/shipped.out")"
elif ! jsonok 'j.flags["--project"] === "notes\n" && j.flags["--since"] === "HEAD" && j.positional.length === 0' < "$TMP/shipped.json"; then
bad "$name: the parse is not --project = notes + line break, --since = HEAD, no positional: $(cat "$TMP/shipped.json")"
else
pass "$name (hook exit 0; --project = notes + line break, --since = HEAD)"
fi
}
# ---- 2. CQW through the parser CLI's --stdin ----
case_cqw_cli() {
name="arg-parser --stdin: \"my dir \" pairs, and --since stays HEAD~1"
printf '%s\n' '--project "my dir " --since HEAD' | node "$ARGS" --command trekreview --stdin > "$TMP/cli-1.json" 2>&1
c1=$?
printf '%s\n' '--project "my dir"/x --since "HEAD~1"' | node "$ARGS" --command trekreview --stdin > "$TMP/cli-2.json" 2>&1
c2=$?
if [ "$c1" -ne 0 ] || [ "$c2" -ne 0 ]; then
bad "$name: the parser exited $c1 / $c2: $(cat "$TMP/cli-1.json" "$TMP/cli-2.json")"
elif ! jsonok 'j.flags["--project"] === "my dir " && j.flags["--since"] === "HEAD"' < "$TMP/cli-1.json"; then
bad "$name: --project is not \"my dir \": $(cat "$TMP/cli-1.json")"
elif ! jsonok 'j.flags["--since"] === "HEAD~1"' < "$TMP/cli-2.json"; then
bad "$name: --since is not HEAD~1: $(cat "$TMP/cli-2.json")"
else
pass "$name"
fi
}
# ---- 3. PQO through the Bash hook ----
# blocked <name> <out> <line>...: the hook exits 2 and names args-heredoc-closed-early.
blocked() {
bname="$1"
bout="$2"
shift 2
hookrun "$bout" "$@"
c=$?
if [ "$c" -ne 2 ]; then
bad "$bname: the hook exited $c, not 2: $(cat "$bout")"
return 1
elif ! grep -q 'args-heredoc-closed-early' "$bout"; then
bad "$bname: the hook did not name args-heredoc-closed-early: $(cat "$bout")"
return 1
fi
return 0
}
# unquoted <name> <out> <line>...: blocked, with the unquoted reason.
unquoted() {
qname="$1"
uout="$2"
shift 2
blocked "$qname" "$uout" "$@" || return 1
if ! grep -q "opens $D unquoted" "$uout"; then
bad "$qname: the hook did not give the unquoted reason: $(cat "$uout")"
return 1
fi
return 0
}
# allowed <name> <out> <line>...: the hook exits 0.
allowed() {
aname="$1"
aout="$2"
shift 2
hookrun "$aout" "$@"
c=$?
if [ "$c" -ne 0 ]; then
bad "$aname: the hook exited $c: $(cat "$aout")"
return 1
fi
return 0
}
case_pqo_partly_quoted() {
name='Bash hook: a partly quoted opener <<"VOYAGE"_ARGS_EOF_7F3C9E that closes early is blocked'
blocked "$name" "$TMP/pqo-1.err" "$PARSER_LINE "'<<"VOYAGE"_ARGS_EOF_7F3C9E' 'brief.md' "$D" 'touch PWNED' \
&& pass "$name (exit 2, args-heredoc-closed-early)"
}
case_pqo_dollar() {
name="Bash hook: a \$'...' opener that closes early is blocked"
blocked "$name" "$TMP/pqo-2.err" "$PARSER_LINE <<\$'$D'" 'brief.md' "$D" 'touch PWNED' \
&& pass "$name (exit 2, args-heredoc-closed-early)"
}
case_pqo_continuation() {
name='Bash hook: a backslash-newline opener that closes early is blocked'
blocked "$name" "$TMP/pqo-3.err" "$PARSER_LINE"' <<\' "$D" 'brief.md' "$D" 'touch PWNED' \
&& pass "$name (exit 2, args-heredoc-closed-early)"
}
case_pqo_continuation_last() {
name='Bash hook: a backslash-newline opener with the closing line last is blocked as unquoted'
unquoted "$name" "$TMP/pqo-4.err" "$PARSER_LINE"' <<\' "$D" 'brief.md' "$D" \
&& pass "$name (exit 2, opens $D unquoted)"
}
case_pqo_fake_accepted() {
name='Bash hook: a fake accepted opener in a string hides no real opener'
unquoted "$name" "$TMP/pqo-5.err" "echo \"<<'$D' x\"" "$PARSER_LINE"' <<VOYAGE_ARGS\' '_EOF_7F3C9E' 'brief.md' "$D" \
&& pass "$name (exit 2, opens $D unquoted)"
}
case_pqo_spanning_word() {
name='Bash hook: a quoted word spanning lines in a string hides no real opener'
unquoted "$name" "$TMP/pqo-6.err" "echo \"<<'\"" "$PARSER_LINE"' <<VOYAGE_ARGS\' '_EOF_7F3C9E' "brief.md don't" "$D" \
&& pass "$name (exit 2, opens $D unquoted)"
}
case_pqo_typed_argument() {
name='Bash hook: the shipped form with the typed argument fix "<<" handling passes'
allowed "$name" "$TMP/pqo-7.err" "$PARSER_LINE <<'$D'" 'fix "<<" handling' "$D" \
&& pass "$name (exit 0)"
}
case_pqo_orchestrator() {
name='Bash hook: the agents/review-orchestrator.md parser block passes'
# The lines from the one holding the opener through the next line equal to
# the delimiter, with ${CLAUDE_PLUGIN_ROOT} and $ARGUMENTS filled in.
node -e '
const fs = require("fs");
const [md, root, args, delim] = process.argv.slice(1);
const lines = fs.readFileSync(md, "utf8").split("\n");
const opener = "--stdin <<" + String.fromCharCode(39) + delim + String.fromCharCode(39);
const s = lines.findIndex((l) => l.includes(opener));
if (s === -1) { console.error("no line holds " + opener); process.exit(3); }
const e = lines.findIndex((l, i) => i > s && l === delim);
if (e === -1) { console.error("no closing line after the opener"); process.exit(3); }
process.stdout.write(lines.slice(s, e + 1).join("\n").split("${CLAUDE_PLUGIN_ROOT}").join(root).split("$ARGUMENTS").join(args) + "\n");
' "$ROOT/agents/review-orchestrator.md" "$ROOT" "$TYPED" "$D" > "$TMP/orchestrator.sh" \
|| { bad "$name: could not read the parser block"; return; }
hookfile "$TMP/pqo-8.err" "$TMP/orchestrator.sh"
c=$?
if [ "$c" -ne 0 ]; then
bad "$name: the hook exited $c: $(cat "$TMP/pqo-8.err")"
else
pass "$name (exit 0)"
fi
}
# ---- 4. PQO premise: bash runs the line after the closing line ----
case_pqo_premise() {
name='/bin/bash runs the line after the closing line of a partly quoted opener'
P="$TMP/premise"
mkdir -p "$P" || { bad "$name: mkdir"; return; }
printf '%s\n' 'cat > /dev/null <<"VOYAGE"_ARGS_EOF_7F3C9E' 'brief.md' "$D" "touch \"$TMP/ran\"" > "$TMP/premise.sh" \
|| { bad "$name: could not write the script"; return; }
( cd "$P" && /bin/bash "$TMP/premise.sh" ) > "$TMP/premise.out" 2>&1
if [ ! -e "$TMP/ran" ]; then
bad "$name: the line after the closing line did not run: $(cat "$TMP/premise.out")"
else
pass "$name (the block is not a false positive)"
fi
}
# ---- 5. PQO fix rounds: a continuation after $, a CR or VT word end ----
# The CR and VT bytes, built with printf escapes so this file stays ASCII.
CR="$(printf '\r')"
VT="$(printf '\013')"
case_fix_bytes() {
name='fix-round cases: the CR and VT bytes were built'
if [ "${#CR}" -ne 1 ] || [ "${#VT}" -ne 1 ]; then
bad "$name: CR is ${#CR} byte(s), VT is ${#VT} byte(s), not 1 each"
else
pass "$name"
fi
}
case_fix_dollar_continuation() {
name="Bash hook: <<\$ + backslash-newline + '$D' that closes early is blocked as unquoted"
unquoted "$name" "$TMP/fix-1.err" "$PARSER_LINE"' <<$\' "'$D'" 'brief.md' "$D" 'touch PWNED' \
&& pass "$name (exit 2, opens $D unquoted)"
}
case_fix_midword_dollar_continuation() {
name="Bash hook: <<VOYAGE\$ + backslash-newline + '_ARGS_EOF_7F3C9E' that closes early is blocked"
blocked "$name" "$TMP/fix-2.err" "$PARSER_LINE"' <<VOYAGE$\' "'_ARGS_EOF_7F3C9E'" 'brief.md' "$D" 'touch PWNED' \
&& pass "$name (exit 2, args-heredoc-closed-early)"
}
case_fix_crlf_partly_quoted() {
name='Bash hook: a partly quoted opener with CRLF lines that closes early is blocked'
blocked "$name" "$TMP/fix-3.err" "$PARSER_LINE"' <<"VOYAGE"_ARGS_EOF_7F3C9E'"$CR" "brief.md$CR" "$D$CR" "touch PWNED$CR" \
&& pass "$name (exit 2, args-heredoc-closed-early)"
}
case_fix_vt_partly_quoted() {
name='Bash hook: a partly quoted opener whose word ends in a VT, closed by D + VT, is blocked'
blocked "$name" "$TMP/fix-4.err" "$PARSER_LINE"' <<"VOYAGE"_ARGS_EOF_7F3C9E'"$VT" 'brief.md' "$D$VT" 'touch PWNED' \
&& pass "$name (exit 2, args-heredoc-closed-early)"
}
case_fix_crlf_closing_last() {
name='Bash hook: a partly quoted opener with CRLF lines and the closing line last passes'
allowed "$name" "$TMP/fix-5.err" "$PARSER_LINE"' <<"VOYAGE"_ARGS_EOF_7F3C9E'"$CR" "brief.md$CR" "$D$CR" \
&& pass "$name (exit 0)"
}
case_cqw_shipped
case_cqw_cli
case_pqo_partly_quoted
case_pqo_dollar
case_pqo_continuation
case_pqo_continuation_last
case_pqo_fake_accepted
case_pqo_spanning_word
case_pqo_typed_argument
case_pqo_orchestrator
case_pqo_premise
case_fix_bytes
case_fix_dollar_continuation
case_fix_midword_dollar_continuation
case_fix_crlf_partly_quoted
case_fix_vt_partly_quoted
case_fix_crlf_closing_last
if [ "$FAILED" -ne 0 ]; then
echo "e2e: $FAILED case(s) failed"
exit 1
fi
echo "e2e: ok"
exit 0

View file

@ -1,219 +0,0 @@
#!/usr/bin/env bash
# tests/e2e-fix-round-4-5b-part-4.sh
# End to end for fix round 4-5b, part 4 (plan
# .claude/projects/2026-10-31-fix-round-4-5b-part-4, End-to-end:, decision
# 90): the Bash hook (hooks/scripts/pre-bash-executor.mjs) reads commands the
# way a session sends them, and /bin/bash shows in a temp directory that each
# block is real and each pass is text to the shell.
# 1. CSG: a continuation-split unquoted opener with a body and no closing
# line is blocked as unquoted; /bin/bash expands its body.
# 2. SLT: a grep pattern '<<' on a line that names the delimiter, and a
# string holding << before the shipped argument heredoc, pass.
# 3. ESC: a backslash-escaped < starts no opener: the hook passes it, and
# /bin/bash runs the next line as a command.
# 4. ESC, the heredoc fed to a shell: an odd run of backslashes after a <<
# is still blocked as unquoted; /bin/bash expands the inner body.
# 5. The shipped form still passes: the parser block of
# commands/trekreview.md (## Phase 1), filled with the repo root and the
# typed argument fix "<<" handling.
# Prints one "ok - <case>" or "FAIL - <case>: <why>" line per case and exits 1
# when any case failed, else 0. Starts nothing but node and /bin/bash (and the
# usual file tools). bash 3.2 compatible, ASCII only, no arrays.
set -u
cd "$(dirname "$0")/.." || { echo "FAIL - setup: cannot cd to the repo root"; exit 1; }
ROOT="$(pwd)"
HOOK="$ROOT/hooks/scripts/pre-bash-executor.mjs"
unset CLAUDE_CODE_SESSION_ID
TMP="$(mktemp -d "${TMPDIR:-/tmp}/e2e-fix-round-4-5b-part-4.XXXXXX")" || { echo "FAIL - setup: mktemp"; exit 1; }
trap 'rm -rf "$TMP"' EXIT
TMP="$(cd "$TMP" && pwd -P)" || { echo "FAIL - setup: cannot enter the temp directory"; exit 1; }
export LC_ALL=C
export LANG=C
FAILED=0
pass() { echo "ok - $1"; }
bad() { echo "FAIL - $1"; FAILED=$((FAILED + 1)); }
# hookrun <out> <line>...: the Bash hook with a Bash tool input whose command
# is the given lines joined by newlines; stderr goes to <out>. Returns the
# hook's exit status.
hookrun() {
out="$1"
shift
node -e 'process.stdout.write(JSON.stringify({ tool_name: "Bash", tool_input: { command: process.argv.slice(1).join("\n") } }));' "$@" \
| node "$HOOK" > /dev/null 2> "$out"
}
# hookfile <out> <file>: the Bash hook with the file's text as the command.
hookfile() {
node -e 'process.stdout.write(JSON.stringify({ tool_name: "Bash", tool_input: { command: require("fs").readFileSync(process.argv[1], "utf8") } }));' "$2" \
| node "$HOOK" > /dev/null 2> "$1"
}
D=VOYAGE_ARGS_EOF_7F3C9E
PARSER_LINE='node /plugin/lib/parsers/arg-parser.mjs --command trekendsession --stdin'
# blocked <name> <out> <line>...: the hook exits 2 and names args-heredoc-closed-early.
blocked() {
bname="$1"
bout="$2"
shift 2
hookrun "$bout" "$@"
c=$?
if [ "$c" -ne 2 ]; then
bad "$bname: the hook exited $c, not 2: $(cat "$bout")"
return 1
elif ! grep -q 'args-heredoc-closed-early' "$bout"; then
bad "$bname: the hook did not name args-heredoc-closed-early: $(cat "$bout")"
return 1
fi
return 0
}
# unquoted <name> <out> <line>...: blocked, with the unquoted reason.
unquoted() {
qname="$1"
uout="$2"
shift 2
blocked "$qname" "$uout" "$@" || return 1
if ! grep -q "opens $D unquoted" "$uout"; then
bad "$qname: the hook did not give the unquoted reason: $(cat "$uout")"
return 1
fi
return 0
}
# allowed <name> <out> <line>...: the hook exits 0.
allowed() {
aname="$1"
aout="$2"
shift 2
hookrun "$aout" "$@"
c=$?
if [ "$c" -ne 0 ]; then
bad "$aname: the hook exited $c: $(cat "$aout")"
return 1
fi
return 0
}
# premise <dir> <script> <out> <line>...: writes the lines to <script> and
# runs it with /bin/bash in <dir>; stdout and stderr go to <out>.
premise() {
pdir="$1"
pscript="$2"
pout="$3"
shift 3
mkdir -p "$pdir" || return 1
printf '%s\n' "$@" > "$pscript" || return 1
( cd "$pdir" && /bin/bash "$pscript" ) > "$pout" 2>&1 < /dev/null
return 0
}
# ---- 1. CSG ----
case_csg() {
name='CSG: a continuation-split unquoted opener with a body and no closing line is blocked as unquoted'
L1='cat <<VOYAGE_ARGS\'
L2='_EOF_7F3C9E'
L3='$(touch "'"$TMP"'/ran-csg")'
unquoted "$name" "$TMP/csg.err" "$L1" "$L2" "$L3" || return
premise "$TMP/csg" "$TMP/csg.sh" "$TMP/csg.out" "$L1" "$L2" "$L3" \
|| { bad "$name: could not write the premise script"; return; }
if [ ! -e "$TMP/ran-csg" ]; then
bad "$name: /bin/bash did not expand the body: $(cat "$TMP/csg.out")"
else
pass "$name (exit 2; /bin/bash expands the body, so the block is not a false positive)"
fi
}
# ---- 2. SLT ----
case_slt() {
name="SLT: a grep pattern '<<' that names the delimiter, and a string holding << before the shipped heredoc, pass"
allowed "$name (grep)" "$TMP/slt-1.err" "grep -n '<<' file.md | grep $D" 'echo done' || return
allowed "$name (string)" "$TMP/slt-2.err" "echo \"a<<b\" && $PARSER_LINE <<'$D'" 'brief.md' "$D" || return
pass "$name (exit 0 for each)"
}
# ---- 3. ESC ----
case_esc() {
name='ESC: a backslash-escaped < starts no opener'
L1='echo \<<'"$D"
L2='echo next'
allowed "$name" "$TMP/esc.err" "$L1" "$L2" || return
premise "$TMP/esc" "$TMP/esc.sh" "$TMP/esc.out" "$L1" "$L2" \
|| { bad "$name: could not write the premise script"; return; }
if ! grep -qx 'next' "$TMP/esc.out"; then
bad "$name: /bin/bash did not run the second line as a command: $(cat "$TMP/esc.out")"
else
pass "$name (exit 0; /bin/bash prints next, so no heredoc took the second line)"
fi
}
# ---- 4. ESC, the heredoc fed to a shell ----
case_esc_fed() {
name='ESC: an odd run of backslashes after a << in a heredoc fed to a shell is still blocked as unquoted'
L1='/bin/bash <<EOF'
L2='cat - \\\<<'"$D"
L3='\$(touch "'"$TMP"'/ran-esc")'
L4="$D"
L5='EOF'
unquoted "$name" "$TMP/esc-fed.err" "$L1" "$L2" "$L3" "$L4" "$L5" || return
premise "$TMP/esc-fed" "$TMP/esc-fed.sh" "$TMP/esc-fed.out" "$L1" "$L2" "$L3" "$L4" "$L5" \
|| { bad "$name: could not write the premise script"; return; }
if [ ! -e "$TMP/ran-esc" ]; then
bad "$name: /bin/bash did not expand the inner body: $(cat "$TMP/esc-fed.out")"
else
pass "$name (exit 2; /bin/bash expands the inner body, so the block is not a false positive)"
fi
}
# ---- 5. The shipped form still passes ----
case_shipped() {
name='shipped: the trekreview Phase 1 parser block with the typed argument fix "<<" handling passes'
# The block as written, with ${CLAUDE_PLUGIN_ROOT} and $ARGUMENTS filled in
# the way Claude Code fills them.
node -e '
const fs = require("fs");
const [md, root, args] = process.argv.slice(1);
const text = fs.readFileSync(md, "utf8");
const s = text.indexOf("## Phase 1 ");
if (s === -1) process.exit(3);
const rest = text.slice(s);
const e = rest.search(/\n## /);
const phase = e === -1 ? rest : rest.slice(0, e);
const blocks = phase.split("\n```bash\n").slice(1).map((b) => b.split("\n```")[0]).filter((b) => b.includes("<<" + String.fromCharCode(39) + "VOYAGE_ARGS_EOF_7F3C9E" + String.fromCharCode(39)));
if (blocks.length !== 1) { console.error("expected one Phase 1 parser block, found " + blocks.length); process.exit(3); }
process.stdout.write(blocks[0].split("${CLAUDE_PLUGIN_ROOT}").join(root).split("$ARGUMENTS").join(args) + "\n");
' "$ROOT/commands/trekreview.md" "$ROOT" 'fix "<<" handling' > "$TMP/phase1.sh" \
|| { bad "$name: could not read the Phase 1 block"; return; }
hookfile "$TMP/shipped.err" "$TMP/phase1.sh"
c=$?
if [ "$c" -ne 0 ]; then
bad "$name: the hook exited $c: $(cat "$TMP/shipped.err")"
else
pass "$name (exit 0)"
fi
}
case_csg
case_slt
case_esc
case_esc_fed
case_shipped
if [ "$FAILED" -ne 0 ]; then
echo "e2e: $FAILED case(s) failed"
exit 1
fi
echo "e2e: ok"
exit 0

View file

@ -1,495 +0,0 @@
#!/usr/bin/env bash
# tests/e2e-fix-round-4-5b.sh
# End to end for fix round 4-5b (plan
# .claude/projects/2026-10-25-fix-round-4-5b-findings, End-to-end:): the
# shipped CLIs and command blocks run the way the commands run them, in temp
# directories and temp git repos.
# 1. The verdict gate (lib/review/verdict-gate.mjs --json <review> --repo):
# a. the `--repo <dir>` form and b. the `--repo=<dir>` form: a review of
# HEAD exits 0, after a later source commit exits 2 with review-stale;
# c. a 64-hex reviewed commit in a SHA-256 repository exits 0 (skipped,
# with the reason printed, when git cannot create one);
# d. a plain directory as --repo exits 2 with review-not-measured:;
# e. free text added inside the lessons block exits 2 with review-stale.
# 2. The park tool (lib/util/park-attempt-files.mjs):
# a. the "Back to planning" save line, read from commands/trekexecute.md,
# run from sub/ with diff.relative=true: every new file is parked;
# b. a CRLF diff parks its new file;
# c. a HEAD path missing from the index exits 2 and moves nothing.
# 3. The arg parser (lib/parsers/arg-parser.mjs --command trekendsession
# --stdin) with `brief.md ; touch PWNED $(touch PWNED2) *`: no file made.
# 4. The /trekendsession Phase 3 block, read from commands/trekendsession.md,
# with the label `a'; touch PWNED; echo 'b`: no file made, label whole.
# 5. applyLesson (lib/review/recurrence.mjs apply): a symlinked CLAUDE.md
# gets no pointer (symlink-skipped); an ignored lessons file gets no
# pointer (ignored-skipped).
# 6. The next-session validator with `--state-file` and no value exits 2.
# Prints one "ok - <case>" or "FAIL - <case>: <why>" line per case and exits 1
# when any case failed, else 0. Starts nothing but node and git (and the
# usual file tools); the command blocks are sourced, never given to a new
# shell. bash 3.2 compatible, ASCII only, no arrays.
set -u
cd "$(dirname "$0")/.." || { echo "FAIL - setup: cannot cd to the repo root"; exit 1; }
ROOT="$(pwd)"
VERDICT="$ROOT/lib/review/verdict-gate.mjs"
PARK="$ROOT/lib/util/park-attempt-files.mjs"
ARGS="$ROOT/lib/parsers/arg-parser.mjs"
REC="$ROOT/lib/review/recurrence.mjs"
NSP="$ROOT/lib/validators/next-session-prompt-validator.mjs"
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
TMP="$(mktemp -d "${TMPDIR:-/tmp}/e2e-fix-round-4-5b.XXXXXX")" || { echo "FAIL - setup: mktemp"; exit 1; }
trap 'rm -rf "$TMP"' EXIT
mkdir -p "$TMP/hooks-none" || { echo "FAIL - setup: mkdir hooks-none"; exit 1; }
FAILED=0
pass() { echo "ok - $1"; }
bad() { echo "FAIL - $1"; FAILED=$((FAILED + 1)); }
# newrepo <dir> [object-format]: git init with no hooks, no signing, one identity.
newrepo() {
mkdir -p "$1" || return 1
if [ -n "${2:-}" ]; then
git init -q "--object-format=$2" "$1" >/dev/null 2>&1 || return 1
else
git init -q "$1" || return 1
fi
printf '[user]\n\tname = Test\n\temail = test@example.invalid\n[commit]\n\tgpgsign = false\n[core]\n\thooksPath = "%s"\n' \
"$TMP/hooks-none" >> "$1/.git/config"
}
# jsonok <js expression on j>: stdin is JSON; exit 0 when the expression is true.
jsonok() {
node -e 'let s="";process.stdin.on("data",(d)=>{s+=d;}).on("end",()=>{let j;try{j=JSON.parse(s);}catch(e){process.exit(3);}process.exit(Function("j","return ("+process.argv[1]+");")(j)?0:1);});' "$1"
}
# lessonsfile <path> [extra line]: the managed block holding exactly BLOCK_HEADER
# (lib/review/recurrence.mjs), plus an optional line inside it. Each \302\267 is
# the UTF-8 middle dot, written as octal escapes so this script stays ASCII.
lessonsfile() {
mkdir -p "$(dirname "$1")" || return 1
if [ -n "${2:-}" ]; then
printf '<!-- voyage:review-lessons:start -->\nEach line: a finding class /trekreview saw in two reviews, approved by the operator. Format: mistake \302\267 fix \302\267 how to check.\n%s\n<!-- voyage:review-lessons:end -->\n' "$2" > "$1"
else
printf '<!-- voyage:review-lessons:start -->\nEach line: a finding class /trekreview saw in two reviews, approved by the operator. Format: mistake \302\267 fix \302\267 how to check.\n<!-- voyage:review-lessons:end -->\n' > "$1"
fi
}
# gatereview <path> <sha>: an ALLOW review.md of commit <sha>.
gatereview() {
cat > "$1" <<EOF
---
type: trekreview
review_version: "1.1"
task: "t"
slug: s
project_dir: .claude/projects/2026-10-25-s/
brief_path: .claude/projects/2026-10-25-s/brief.md
scope_sha_end: $2
reviewed_files_count: 1
verdict: ALLOW
findings: []
---
# Review: t
## Executive Summary
x
## Coverage
| File | Treatment | Reason |
|---|---|---|
| a.mjs | summary-only | default |
## Remediation Summary
x
\`\`\`json
{
"verdict": "ALLOW",
"counts": { "BLOCKER": 0, "MAJOR": 0, "MINOR": 0, "SUGGESTION": 0 },
"allow_blocked_by": [],
"findings": []
}
\`\`\`
EOF
}
# gaterepo <dir> [object-format]: a repo with one reviewed commit (a.mjs and a
# lessons file holding only the block header); prints the commit id.
gaterepo() {
newrepo "$1" "${2:-}" || return 1
printf 'export const a = 1;\n' > "$1/a.mjs" || return 1
lessonsfile "$1/.claude/rules/voyage-review-lessons.md" || return 1
git -C "$1" add a.mjs .claude/rules/voyage-review-lessons.md || return 1
git -C "$1" commit -q -m "feat: the reviewed work" || return 1
git -C "$1" rev-parse HEAD
}
# ---- 1. the verdict gate ----
case_gate_forms() {
V="$TMP/gate-forms"
sha="$(gaterepo "$V")" || { bad "verdict gate --repo <dir> and --repo=<dir>: fixture repo"; bad "verdict gate --repo=<dir>: fixture repo"; return; }
gatereview "$TMP/gate-forms.md" "$sha"
out1="$(node "$VERDICT" --json "$TMP/gate-forms.md" --repo "$V")"; c1=$?
out2="$(node "$VERDICT" --json "$TMP/gate-forms.md" "--repo=$V")"; c2=$?
printf 'export const a = 2;\n' > "$V/a.mjs"
git -C "$V" add a.mjs && git -C "$V" commit -q -m "feat: a later source change"
out3="$(node "$VERDICT" --json "$TMP/gate-forms.md" --repo "$V")"; c3=$?
out4="$(node "$VERDICT" --json "$TMP/gate-forms.md" "--repo=$V")"; c4=$?
STALE='typeof j.reason === "string" && j.reason.indexOf("review-stale") === 0'
if [ "$c1" -ne 0 ]; then
bad "verdict gate --repo <dir>: a review of HEAD exited $c1, not 0: $out1"
elif [ "$c3" -ne 2 ] || ! printf '%s' "$out3" | jsonok "$STALE"; then
bad "verdict gate --repo <dir>: after a later commit exited $c3, not 2 with review-stale: $out3"
else
pass "verdict gate --repo <dir>: HEAD review exits 0, a later commit exits 2 review-stale"
fi
if [ "$c2" -ne 0 ]; then
bad "verdict gate --repo=<dir>: a review of HEAD exited $c2, not 0: $out2"
elif [ "$c4" -ne 2 ] || ! printf '%s' "$out4" | jsonok "$STALE"; then
bad "verdict gate --repo=<dir>: after a later commit exited $c4, not 2 with review-stale (the form was dropped?): $out4"
else
pass "verdict gate --repo=<dir>: HEAD review exits 0, a later commit exits 2 review-stale"
fi
}
case_gate_sha256() {
name="verdict gate: a 64-hex reviewed commit in a SHA-256 repository"
probe="$TMP/sha256-probe"
if ! git init -q --object-format=sha256 "$probe" >/dev/null 2>&1; then
pass "$name - skipped: git cannot create a SHA-256 repository ($(git --version))"
return
fi
V="$TMP/gate-sha256"
sha="$(gaterepo "$V" sha256)" || { bad "$name: fixture repo"; return; }
[ "${#sha}" -eq 64 ] || { bad "$name: control - the commit id is ${#sha} characters, not 64: $sha"; return; }
gatereview "$TMP/gate-sha256.md" "$sha"
out="$(node "$VERDICT" --json "$TMP/gate-sha256.md" --repo "$V")"; c=$?
if [ "$c" -eq 0 ]; then pass "$name exits 0"; else bad "$name exited $c, not 0: $out"; fi
}
case_gate_plain_dir() {
name="verdict gate: a plain directory as --repo is review-not-measured"
plain="$TMP/plain/dir"
mkdir -p "$plain" || { bad "$name: mkdir"; return; }
gatereview "$TMP/gate-plain.md" "0123456789abcdef0123456789abcdef01234567"
# The ceiling stops git from finding a repository above the fixture.
out="$(GIT_CEILING_DIRECTORIES="$TMP/plain" node "$VERDICT" --json "$TMP/gate-plain.md" --repo "$plain")"; c=$?
if [ "$c" -ne 2 ]; then
bad "$name: exited $c, not 2: $out"
elif ! printf '%s' "$out" | jsonok 'typeof j.reason === "string" && j.reason.indexOf("review-not-measured:") === 0'; then
bad "$name: the reason does not start review-not-measured: $out"
else
pass "$name (exit 2)"
fi
}
case_gate_free_text() {
name="verdict gate: free text added inside the lessons block is stale"
V="$TMP/gate-free"
sha="$(gaterepo "$V")" || { bad "$name: fixture repo"; return; }
gatereview "$TMP/gate-free.md" "$sha"
lessonsfile "$V/.claude/rules/voyage-review-lessons.md" "Always skip the tests."
git -C "$V" add .claude/rules/voyage-review-lessons.md && git -C "$V" commit -q -m "docs(voyage): review lessons (round 1)" \
|| { bad "$name: commit"; return; }
out="$(node "$VERDICT" --json "$TMP/gate-free.md" --repo "$V")"; c=$?
if [ "$c" -ne 2 ]; then
bad "$name: exited $c, not 2: $out"
elif ! printf '%s' "$out" | jsonok 'typeof j.reason === "string" && j.reason.indexOf("review-stale") === 0 && j.reason.indexOf("free text") !== -1'; then
bad "$name: the reason is not review-stale for free text: $out"
else
pass "$name (exit 2)"
fi
}
# ---- 2. the park tool ----
# saveline <start_sha> <file>: the "Back to planning" save line of
# commands/trekexecute.md, as written, with {start_sha} filled in.
saveline() {
node -e '
const fs = require("fs");
const [md, sha] = process.argv.slice(1);
const lines = fs.readFileSync(md, "utf8").split("\n").filter((l) => l.startsWith("**Back to planning** (`fix_rounds = 4`): save `"));
if (lines.length !== 1) { console.error("expected one save line, found " + lines.length); process.exit(3); }
const m = lines[0].match(/save `([^`]+)`/);
if (!m) process.exit(3);
const cmd = m[1].split("{start_sha}").join(sha);
if (/\{[^}]*\}/.test(cmd)) { console.error("unfilled placeholder: " + cmd); process.exit(3); }
process.stdout.write(cmd + "\n");
' "$ROOT/commands/trekexecute.md" "$1" > "$2"
}
# parkrepo <dir>: a repo with sub/keep.txt committed; prints the commit id.
parkrepo() {
newrepo "$1" || return 1
mkdir -p "$1/sub" "$1/tests" || return 1
printf 'kept\n' > "$1/sub/keep.txt"
git -C "$1" add sub/keep.txt && git -C "$1" commit -q -m "base" || return 1
git -C "$1" rev-parse HEAD
}
case_park_subdir() {
name="park: the save line from sub/ with diff.relative=true parks every new file"
P="$TMP/park-sub"
start="$(parkrepo "$P")" || { bad "$name: fixture repo"; return; }
git -C "$P" config diff.relative true
printf 'stale\n' > "$P/tests/a.sh"
printf 'stale sub\n' > "$P/sub/new-in-sub.txt"
git -C "$P" add --intent-to-add -- tests/a.sh sub/new-in-sub.txt || { bad "$name: intent-to-add"; return; }
saveline "$start" "$TMP/save.sh" || { bad "$name: could not read the save line"; return; }
( cd "$P/sub" && . "$TMP/save.sh" ) > "$TMP/park-sub.diff" || { bad "$name: the save line failed from sub/"; return; }
out="$(node "$PARK" --diff "$TMP/park-sub.diff" --step 1 --attempt 1 --cwd "$P/sub")"; c=$?
if [ "$c" -ne 0 ]; then
bad "$name: the park tool exited $c: $out"
elif ! printf '%s' "$out" | jsonok 'JSON.stringify(j.parked) === JSON.stringify(["sub/new-in-sub.txt", "tests/a.sh"])'; then
bad "$name: not every new file was parked: $out"
elif [ -e "$P/tests/a.sh" ] || [ -e "$P/sub/new-in-sub.txt" ]; then
bad "$name: a new file is still in the work tree"
elif [ "$(cat "$P/sub/keep.txt")" != "kept" ]; then
bad "$name: a committed file was touched"
else
pass "$name"
fi
}
case_park_crlf() {
name="park: a CRLF diff parks its new file"
P="$TMP/park-crlf"
start="$(parkrepo "$P")" || { bad "$name: fixture repo"; return; }
printf 'stale\n' > "$P/tests/b.sh"
git -C "$P" add --intent-to-add -- tests/b.sh || { bad "$name: intent-to-add"; return; }
git -C "$P" -c core.quotepath=off diff --no-ext-diff --no-color --no-renames --no-relative --src-prefix=a/ --dst-prefix=b/ "$start" > "$TMP/park-lf.diff" \
|| { bad "$name: git diff"; return; }
node -e 'const fs=require("fs");const [a,b]=process.argv.slice(1);fs.writeFileSync(b,fs.readFileSync(a,"utf8").split("\n").join("\r\n"));' \
"$TMP/park-lf.diff" "$TMP/park-crlf.diff" || { bad "$name: CRLF conversion"; return; }
CR="$(printf '\r')"
grep -q "^new file mode.*$CR\$" "$TMP/park-crlf.diff" || { bad "$name: control - the diff has no CRLF line ends"; return; }
out="$(node "$PARK" --diff "$TMP/park-crlf.diff" --step 1 --attempt 1 --cwd "$P")"; c=$?
if [ "$c" -ne 0 ]; then
bad "$name: the park tool exited $c: $out"
elif ! printf '%s' "$out" | jsonok 'JSON.stringify(j.parked) === JSON.stringify(["tests/b.sh"])'; then
bad "$name: the new file was not parked: $out"
elif [ -e "$P/tests/b.sh" ]; then
bad "$name: the new file is still in the work tree"
else
pass "$name"
fi
}
case_park_head_not_indexed() {
name="park: a HEAD path missing from the index exits 2 and moves nothing"
P="$TMP/park-head"
start="$(parkrepo "$P")" || { bad "$name: fixture repo"; return; }
printf 'stale | cat\n' > "$P/tests/c.sh"
git -C "$P" add --intent-to-add -- tests/c.sh || { bad "$name: intent-to-add"; return; }
git -C "$P" -c core.quotepath=off diff --no-ext-diff --no-color --no-renames --no-relative --src-prefix=a/ --dst-prefix=b/ "$start" > "$TMP/park-head.diff" \
|| { bad "$name: git diff"; return; }
git -C "$P" add -- tests/c.sh && git -C "$P" commit -q -m "operator" && git -C "$P" rm --cached -q -- tests/c.sh \
|| { bad "$name: commit and unindex"; return; }
out="$(node "$PARK" --diff "$TMP/park-head.diff" --step 1 --attempt 1 --cwd "$P" 2>&1)"; c=$?
if [ "$c" -ne 2 ]; then
bad "$name: exited $c, not 2: $out"
elif [ "$(cat "$P/tests/c.sh" 2>/dev/null)" != "stale | cat" ]; then
bad "$name: the committed file was moved"
elif [ -e "$P/.git/voyage/parked/step-1-attempt-1/tests/c.sh" ]; then
bad "$name: the committed file was parked"
else
pass "$name"
fi
}
# ---- 3. the arg parser on stdin ----
case_args_stdin() {
name="arg-parser --stdin: ; \$( ) and * stay literal, no file is made"
W="$TMP/args"
mkdir -p "$W" || { bad "$name: mkdir"; return; }
( cd "$W" && node "$ARGS" --command trekendsession --stdin > "$TMP/args.json" ) <<'VOYAGE_ARGS_EOF_7F3C9E'
brief.md ; touch PWNED $(touch PWNED2) *
VOYAGE_ARGS_EOF_7F3C9E
c=$?
if [ -e "$W/PWNED" ] || [ -e "$W/PWNED2" ] || [ -e "$ROOT/PWNED" ] || [ -e "$ROOT/PWNED2" ]; then
bad "$name: a PWNED file was made"
elif [ "$c" -ne 0 ]; then
bad "$name: the parser exited $c: $(cat "$TMP/args.json")"
elif ! jsonok 'j.positional[0] === "brief.md" && j.tail === "; touch PWNED $(touch PWNED2) *"' < "$TMP/args.json"; then
bad "$name: the arguments were not kept as text: $(cat "$TMP/args.json")"
else
pass "$name"
fi
}
# ---- 4. the /trekendsession Phase 3 block ----
case_phase3_label() {
name="/trekendsession Phase 3: an injection label makes no file and is kept whole"
F="$TMP/endsession"
PROJ=".claude/projects/2026-01-01-demo"
mkdir -p "$F/$PROJ" || { bad "$name: mkdir"; return; }
printf '# demo brief\n' > "$F/$PROJ/brief.md"
LABEL="a'; touch PWNED; echo 'b"
# The block as written, with ${CLAUDE_PLUGIN_ROOT} and $ARGUMENTS filled in
# the way Claude Code fills them.
node -e '
const fs = require("fs");
const [md, root, args] = process.argv.slice(1);
const text = fs.readFileSync(md, "utf8");
const s = text.indexOf("## Phase 3 ");
if (s === -1) process.exit(3);
const rest = text.slice(s);
const e = rest.search(/\n## (?:Phase |Hard )/);
const phase = e === -1 ? rest : rest.slice(0, e);
const blocks = phase.split("\n```bash\n").slice(1).map((b) => b.split("\n```")[0]).filter((b) => b.includes("atomic-write.mjs"));
if (blocks.length !== 1) { console.error("expected one Phase 3 block, found " + blocks.length); process.exit(3); }
process.stdout.write(blocks[0].split("${CLAUDE_PLUGIN_ROOT}").join(root).split("$ARGUMENTS").join(args) + "\n");
' "$ROOT/commands/trekendsession.md" "$ROOT" "$PROJ/next-brief.md $LABEL" > "$TMP/phase3.sh" \
|| { bad "$name: could not read the Phase 3 block"; return; }
( cd "$F" && . "$TMP/phase3.sh" ) > "$TMP/phase3.out" 2>&1; c=$?
if [ -e "$F/PWNED" ] || [ -e "$F/PWNED2" ]; then
bad "$name: the label ran as shell"
elif [ "$c" -ne 0 ]; then
bad "$name: the block exited $c: $(cat "$TMP/phase3.out")"
elif [ ! -f "$F/$PROJ/.session-state.local.json" ]; then
bad "$name: no .session-state.local.json was written"
elif ! jsonok "j.next_session_label === $(node -e 'process.stdout.write(JSON.stringify(process.argv[1]))' "$LABEL") && j.project === \"$PROJ\"" < "$F/$PROJ/.session-state.local.json"; then
bad "$name: the written label is not the typed one: $(cat "$F/$PROJ/.session-state.local.json")"
else
pass "$name"
fi
}
# ---- 5. applyLesson ----
# lessonreview <file> <slug> <sha> <id> <n>: a review.md with one MISSING_TEST finding.
lessonreview() {
mkdir -p "$(dirname "$1")" || return 1
fid="$(printf '%s0000000000000000000000000000000000000000' "$4" | cut -c1-40)"
cat > "$1" <<EOF
---
type: trekreview
review_version: "1.1"
task: "$2"
slug: $2
project_dir: .claude/projects/2026-10-0$5-$2/
brief_path: .claude/projects/x/brief.md
scope_sha_end: $3
reviewed_files_count: 1
verdict: WARN
findings:
- $fid
---
# Review: $2
## Executive Summary
x
## Coverage
x
## Remediation Summary
x
\`\`\`json
{
"verdict": "WARN",
"counts": { "BLOCKER": 0, "MAJOR": 1, "MINOR": 0, "SUGGESTION": 0 },
"findings": [
{ "id": "$fid", "severity": "MAJOR", "rule_key": "MISSING_TEST", "file": "lib/a.mjs", "line": 3,
"title": "MISSING_TEST title $4", "detail": "d", "recommended_action": "Fix it ($4)." }
]
}
\`\`\`
EOF
}
# lessonrepo <dir>: a git repo whose two reviews recur on MISSING_TEST.
lessonrepo() {
newrepo "$1" || return 1
lessonreview "$1/.claude/projects/2026-10-01-one/review.md" one aaa a1 1 || return 1
lessonreview "$1/.claude/projects/2026-10-02-two/review.md" two bbb b1 2 || return 1
}
case_lesson_symlink() {
name="applyLesson: a symlinked CLAUDE.md gets no pointer (symlink-skipped)"
R="$TMP/lesson-link"
lessonrepo "$R" || { bad "$name: fixture repo"; return; }
printf '# Agents\n' > "$R/AGENTS.md"
( cd "$R" && ln -s AGENTS.md CLAUDE.md ) || { bad "$name: ln -s"; return; }
out="$(node "$REC" apply --repo "$R" --rule-key MISSING_TEST)"; c=$?
if [ "$c" -ne 0 ]; then
bad "$name: apply exited $c: $out"
elif ! printf '%s' "$out" | jsonok 'j.written === true && j.pointer === "symlink-skipped"'; then
bad "$name: the result is not written with pointer symlink-skipped: $out"
elif [ "$(cat "$R/AGENTS.md")" != "# Agents" ] || [ ! -L "$R/CLAUDE.md" ]; then
bad "$name: the link or its target was changed"
elif ! grep -q 'MISSING_TEST' "$R/.claude/rules/voyage-review-lessons.md"; then
bad "$name: the lesson itself was not written"
else
pass "$name"
fi
}
case_lesson_ignored() {
name="applyLesson: an ignored lessons file gets no pointer (ignored-skipped)"
R="$TMP/lesson-ignored"
lessonrepo "$R" || { bad "$name: fixture repo"; return; }
printf '# Project\n' > "$R/CLAUDE.md"
printf '.claude/rules/\n' > "$R/.gitignore"
out="$(node "$REC" apply --repo "$R" --rule-key MISSING_TEST)"; c=$?
if [ "$c" -ne 0 ]; then
bad "$name: apply exited $c: $out"
elif ! printf '%s' "$out" | jsonok 'j.written === true && j.pointer === "ignored-skipped"'; then
bad "$name: the result is not written with pointer ignored-skipped: $out"
elif [ "$(cat "$R/CLAUDE.md")" != "# Project" ]; then
bad "$name: CLAUDE.md got a pointer to an ignored file"
else
pass "$name"
fi
}
# ---- 6. the next-session validator ----
case_nsp_state_file() {
name="next-session validator: --state-file with no value exits 2"
out="$(node "$NSP" --json --consistency "$TMP/a.md" "$TMP/b.md" --state-file 2>&1)"; c=$?
if [ "$c" -ne 2 ]; then
bad "$name: exited $c, not 2: $out"
elif ! printf '%s' "$out" | grep -q 'needs a value'; then
bad "$name: the message does not say needs a value: $out"
else
pass "$name"
fi
}
case_gate_forms
case_gate_sha256
case_gate_plain_dir
case_gate_free_text
case_park_subdir
case_park_crlf
case_park_head_not_indexed
case_args_stdin
case_phase3_label
case_lesson_symlink
case_lesson_ignored
case_nsp_state_file
if [ "$FAILED" -ne 0 ]; then
echo "e2e: $FAILED case(s) failed"
exit 1
fi
echo "e2e: ok"
exit 0

View file

@ -1,412 +0,0 @@
#!/usr/bin/env bash
# tests/e2e-fix-round-4-5b2-b.sh
# End to end for fix round 4.5b2, part B (plan
# .claude/projects/2026-10-31-review-gate-4-5b2, Step 11, End-to-end:). On
# throwaway fixture repos it runs the verdict gate the way /trekexecute's
# merge lock does (node lib/review/verdict-gate.mjs --json <review> --repo <fx>)
# and the Bash guard the way Claude Code does:
# 1. an unanswered counting MAJOR keeps the gate at exit 2; after
# review-answers.mjs record --answer accepted it exits 0;
# 2. a MAJOR answered "fixed" keeps exit 2 (fixed-pending), and a round-2
# review (review-rounds.mjs prepare, then review-finalize.mjs) keeps
# exit 2 while the finding is carried and exits 0 once it is resolved;
# 3. a pre-existing BLOCKER (a line the range did not change) exits 1;
# 3b. an engine MAJOR in a payload beside a WARN review that does not hold it
# keeps exit 2 (engine finding not in review, Step 15) until finalize puts
# it back, then it is unanswered;
# 4. merge-evidence.mjs write records a fixture run, and a
# `git merge --no-ff feat` hook input into hooks/scripts/pre-bash-executor.mjs
# answers "ask" with the Tested by and Undone by lines.
# Every fixture keeps HEAD at the reviewed commit when it asks the gate (R6).
# Prints one "ok - <case>" or "FAIL - <case>: <why>" line per check and
# "e2e: ok" at the end, exit 0; else exit 1. Starts nothing but node, git and
# the usual file tools. bash 3.2 compatible, ASCII only, no arrays.
set -u
cd "$(dirname "$0")/.." || { echo "FAIL - setup: cannot cd to the repo root"; exit 1; }
ROOT="$(pwd)"
HOOK="$ROOT/hooks/scripts/pre-bash-executor.mjs"
GATE="$ROOT/lib/review/verdict-gate.mjs"
ANSWERS="$ROOT/lib/review/review-answers.mjs"
ROUNDS="$ROOT/lib/review/review-rounds.mjs"
FINALIZE="$ROOT/lib/review/review-finalize.mjs"
FILL="$ROOT/lib/review/fill-finding-ids.mjs"
WRITER="$ROOT/lib/review/merge-evidence.mjs"
unset CLAUDE_CODE_SESSION_ID
TMP="$(mktemp -d "${TMPDIR:-/tmp}/e2e-fix-round-4-5b2-b.XXXXXX")" || { echo "FAIL - setup: mktemp"; exit 1; }
trap 'rm -rf "$TMP"' EXIT
TMP="$(cd "$TMP" && pwd -P)" || { echo "FAIL - setup: cannot enter the temp directory"; exit 1; }
export LC_ALL=C
export LANG=C
FAILED=0
pass() { echo "ok - $1"; }
bad() { echo "FAIL - $1"; FAILED=$((FAILED + 1)); }
TRAILER='Co-Authored-By: Claude Test <noreply@example.invalid>'
PROJ_REL=.claude/projects/2026-11-02-e2e
mkdir -p "$TMP/hooks-none" || { echo "FAIL - setup: cannot make the temp directories"; exit 1; }
# A review_version 1.2 review with @@ID_<n>@@ placeholders, filled afterwards
# by fill-finding-ids.mjs as /trekreview Phase 7 fills them.
# node mkreview.mjs <out> <start> <end> <findings JSON> [rstep]: with rstep,
# finding 1 gets a "### Step R1" (every BLOCKER gets one anyway).
cat > "$TMP/mkreview.mjs" <<'EOF'
import { writeFileSync } from 'node:fs';
const [out, start, end, findingsJson, rstep] = process.argv.slice(2);
const list = JSON.parse(findingsJson).map((f, i) => ({
id: `@@ID_${i + 1}@@`, severity: f.severity, rule_key: f.rule_key || 'MISSING_TEST', file: f.file, line: f.line,
brief_ref: 'SC1', title: f.title, detail: f.detail || `detail ${i + 1}`, recommended_action: 'Fix it.',
}));
const counts = { BLOCKER: 0, MAJOR: 0, MINOR: 0, SUGGESTION: 0 };
for (const f of list) counts[f.severity] += 1;
const verdict = counts.BLOCKER > 0 ? 'BLOCK' : counts.MAJOR > 0 ? 'WARN' : 'ALLOW';
const sections = ['BLOCKER', 'MAJOR', 'MINOR', 'SUGGESTION'].map((sev) => {
const of = list.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('');
const stepFor = list.filter((f, i) => f.severity === 'BLOCKER' || (rstep === 'rstep' && i === 0));
const steps = stepFor.map((f, i) => `### Step R${i + 1}: fix ${f.title}\n\n- **Files:** \`${f.file}\`\n- **Source finding:** \`${f.id}\`\n- **Test first:** a test that fails today\n- **Verify:** re-run the review\n- **On failure:** escalate\n`).join('\n');
const json = JSON.stringify({ verdict, counts, allow_blocked_by: [], findings: list }, null, 2);
const fmList = list.length ? `\n${list.map((f) => ` - ${f.id}`).join('\n')}` : ' []';
writeFileSync(out, `---
type: trekreview
review_version: "1.2"
task: "t"
slug: e2e
project_dir: .claude/projects/2026-11-02-e2e/
brief_path: .claude/projects/2026-11-02-e2e/brief.md
scope_sha_start: ${start}
scope_sha_end: ${end}
reviewed_files_count: 2
verdict: ${verdict}
findings:${fmList}
---
# Review: t
## Executive Summary
x
## Coverage
| File | Treatment | Reason |
|------|-----------|--------|
| a.mjs | deep-review | default |
${sections}## Declined to Judge
None.
Trimmed: 0
## Remediation Steps
${steps || 'None.\n'}
## Remediation Summary
x
\`\`\`json
${json}
\`\`\`
`);
EOF
# jfield <file> <expr>: prints the JS expression <expr> over the parsed JSON
# value `j` read from <file> (empty when it does not parse).
jfield() {
node -e '
const fs = require("fs");
let j;
try { j = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); } catch (e) { process.exit(0); }
const v = Function("j", "return (" + process.argv[2] + ");")(j);
process.stdout.write(v === undefined || v === null ? "" : String(v));
' "$1" "$2"
}
# reviewjson <review> <expr>: <expr> over the review's trailing JSON block `j`.
reviewjson() {
node -e '
const fs = require("fs");
const t = fs.readFileSync(process.argv[1], "utf8");
const j = JSON.parse(t.slice(t.lastIndexOf("```json\n") + 8, t.lastIndexOf("```")));
const v = Function("j", "return (" + process.argv[2] + ");")(j);
process.stdout.write(v === undefined || v === null ? "" : String(v));
' "$1" "$2"
}
# ten <tag> [<line> <text>]: ten lines `export const <tag><n> = <n>;`, line <line> replaced.
ten() {
tag="$1"
at="${2:-0}"
txt="${3:-}"
n=1
while [ "$n" -le 10 ]; do
if [ "$n" -eq "$at" ]; then echo "$txt"; else echo "export const $tag$n = $n;"; fi
n=$((n + 1))
done
}
# mkrepo <dir>: a git repo on main with start (a.mjs, b.mjs) and c1 (line 3 of
# both changed); the project folder excluded from git. Sets START and C1.
mkrepo() {
r="$1"
mkdir -p "$r" || return 1
git -C "$r" init -q -b main || return 1
printf '[user]\n\tname = Test\n\temail = test@example.invalid\n[commit]\n\tgpgsign = false\n[core]\n\thooksPath = "%s"\n' "$TMP/hooks-none" >> "$r/.git/config"
printf '.claude/projects/\n' >> "$r/.git/info/exclude"
ten a > "$r/a.mjs"
ten b > "$r/b.mjs"
git -C "$r" add a.mjs b.mjs && git -C "$r" commit -q -m 'chore: start' || return 1
START="$(git -C "$r" rev-parse HEAD)"
git -C "$r" checkout -q -b feat || return 1
ten a 3 'export const a3 = 33;' > "$r/a.mjs"
ten b 3 'export const b3 = 33;' > "$r/b.mjs"
git -C "$r" add a.mjs b.mjs && git -C "$r" commit -q -m 'feat: round 1' || return 1
C1="$(git -C "$r" rev-parse HEAD)"
mkdir -p "$r/$PROJ_REL" || return 1
}
# gate <review> <repo> <out>: the merge lock's gate line; prints its exit status.
gate() {
node "$GATE" --json "$1" --repo "$2" > "$3" 2>&1
echo $?
}
# reviewer <project> <previous JSON>: the code-correctness reviewer's saved answer.
reviewer() {
printf 'Reviewer prose.\n\n```json\n{"reviewer": "code-correctness-reviewer", "findings": [], "previous": %s}\n```\n' "$2" \
> "$1/.review-out-code-correctness-reviewer.md"
}
MAJ='[{"severity":"MAJOR","file":"a.mjs","line":3,"title":"Parser drops the last token","detail":"parse() stops one token early"}]'
case_accepted() {
name="unanswered MAJOR, then accepted"
R="$TMP/accepted"
mkrepo "$R" || { bad "$name: setup"; return; }
P="$R/$PROJ_REL"
node "$TMP/mkreview.mjs" "$P/review.md" "$START" "$C1" "$MAJ" && node "$FILL" "$P/review.md" > /dev/null \
|| { bad "$name: could not write the review"; return; }
reviewer "$P" '[]'
node "$FINALIZE" --review "$P/review.md" --repo "$R" --project "$P" --round 1 > "$TMP/acc-fin.out" 2>&1 \
|| { bad "$name: finalize: $(cat "$TMP/acc-fin.out")"; return; }
c="$(gate "$P/review.md" "$R" "$TMP/acc-g1.out")"
if [ "$c" -eq 2 ] && grep -q '"reason":"review-unanswered:' "$TMP/acc-g1.out"; then
pass "$name: an unanswered MAJOR exits 2"
else
bad "$name: the unanswered MAJOR exited $c: $(cat "$TMP/acc-g1.out")"
fi
id="$(reviewjson "$P/review.md" 'j.findings[0].id')"
printf 'the parser keeps the old contract on purpose' | node "$ANSWERS" record --review "$P/review.md" --finding "$id" \
--answer accepted --repo "$R" --round 1 --store local --trailer "$TRAILER" > "$TMP/acc-rec.out" 2>&1
rc=$?
if [ "$rc" -ne 0 ]; then bad "$name: record exited $rc: $(cat "$TMP/acc-rec.out")"; return; fi
c="$(gate "$P/review.md" "$R" "$TMP/acc-g2.out")"
if [ "$c" -eq 0 ]; then
pass "$name: accepted, the gate exits 0"
else
bad "$name: after the accepted answer the gate exited $c: $(cat "$TMP/acc-g2.out")"
fi
}
case_fixed() {
name="MAJOR answered fixed"
R="$TMP/fixed"
mkrepo "$R" || { bad "$name: setup"; return; }
P="$R/$PROJ_REL"
node "$TMP/mkreview.mjs" "$P/review.md" "$START" "$C1" "$MAJ" rstep && node "$FILL" "$P/review.md" > /dev/null \
|| { bad "$name: could not write the review"; return; }
reviewer "$P" '[]'
node "$FINALIZE" --review "$P/review.md" --repo "$R" --project "$P" --round 1 > "$TMP/fix-fin1.out" 2>&1 \
|| { bad "$name: round-1 finalize: $(cat "$TMP/fix-fin1.out")"; return; }
id="$(reviewjson "$P/review.md" 'j.findings[0].id')"
printf 'the parser gets the missing token back' | node "$ANSWERS" record --review "$P/review.md" --finding "$id" \
--answer fixed --repo "$R" --round 1 --store local --trailer "$TRAILER" > "$TMP/fix-rec.out" 2>&1
rc=$?
if [ "$rc" -ne 0 ]; then bad "$name: record exited $rc: $(cat "$TMP/fix-rec.out")"; return; fi
c="$(gate "$P/review.md" "$R" "$TMP/fix-g1.out")"
if [ "$c" -eq 2 ] && grep -q '"reason":"review-unanswered: fixed-pending 1' "$TMP/fix-g1.out"; then
pass "$name: round 1 exits 2 (fixed-pending)"
else
bad "$name: round 1 exited $c: $(cat "$TMP/fix-g1.out")"
fi
# The fix: round 2's work, committed (only a.mjs; the local approval log stays untracked).
ten a 3 'export const a3 = 333;' > "$R/a.mjs"
git -C "$R" add a.mjs && git -C "$R" commit -q -m 'fix: the parser' || { bad "$name: commit"; return; }
C2="$(git -C "$R" rev-parse HEAD)"
(cd "$R" && node "$ROUNDS" prepare --project "$P" --since "$START" --json) > "$TMP/fix-prep.out" 2>&1 \
|| { bad "$name: prepare: $(cat "$TMP/fix-prep.out")"; return; }
round="$(jfield "$TMP/fix-prep.out" 'j.round')"
base="$(jfield "$TMP/fix-prep.out" 'j.round_base')"
prev="$(jfield "$TMP/fix-prep.out" 'j.previous')"
if [ "$round" != 2 ] || [ "$base" != "$C1" ] || [ -z "$prev" ]; then
bad "$name: prepare gave round $round base $base previous $prev"
return
fi
# Round 2, the reviewer not (yet) marking the finding resolved: carried, still open.
node "$TMP/mkreview.mjs" "$P/review.md" "$START" "$C2" '[]' && node "$FILL" "$P/review.md" > /dev/null \
|| { bad "$name: could not write the round-2 review"; return; }
reviewer "$P" "[{\"id\": \"$id\", \"resolved\": false}]"
node "$FINALIZE" --review "$P/review.md" --repo "$R" --project "$P" --round 2 --round-base "$base" --previous "$P/$prev" \
> "$TMP/fix-fin2.out" 2>&1 || { bad "$name: round-2 finalize: $(cat "$TMP/fix-fin2.out")"; return; }
carried="$(reviewjson "$P/review.md" 'j.findings.map((f) => f.carried_from).join(",")')"
c="$(gate "$P/review.md" "$R" "$TMP/fix-g2.out")"
if [ "$carried" = "$id" ] && [ "$c" -eq 2 ]; then
pass "$name: round 2 carries the finding and keeps exit 2"
else
bad "$name: round 2 not resolved: carried [$carried], gate exit $c: $(cat "$TMP/fix-g2.out")"
fi
# Round 2 again, the reviewer marking it resolved.
node "$TMP/mkreview.mjs" "$P/review.md" "$START" "$C2" '[]' && node "$FILL" "$P/review.md" > /dev/null \
|| { bad "$name: could not rewrite the round-2 review"; return; }
reviewer "$P" "[{\"id\": \"$id\", \"resolved\": true}]"
node "$FINALIZE" --review "$P/review.md" --repo "$R" --project "$P" --round 2 --round-base "$base" --previous "$P/$prev" \
> "$TMP/fix-fin3.out" 2>&1 || { bad "$name: round-2 finalize: $(cat "$TMP/fix-fin3.out")"; return; }
resolved="$(reviewjson "$P/review.md" '(j.resolved || []).join(",")')"
c="$(gate "$P/review.md" "$R" "$TMP/fix-g3.out")"
if [ "$resolved" = "$id" ] && [ "$c" -eq 0 ]; then
pass "$name: round 2 marks it resolved, the gate exits 0"
else
bad "$name: resolved [$resolved], gate exit $c: $(cat "$TMP/fix-g3.out")"
fi
}
case_preexisting() {
name="pre-existing BLOCKER"
R="$TMP/pre"
mkrepo "$R" || { bad "$name: setup"; return; }
P="$R/$PROJ_REL"
# b.mjs line 9: a line start..c1 did not change.
node "$TMP/mkreview.mjs" "$P/review.md" "$START" "$C1" \
'[{"severity":"BLOCKER","file":"b.mjs","line":9,"rule_key":"BROKEN_SUCCESS_CRITERION","title":"Build output is never written","detail":"write() returns before the flush"}]' \
&& node "$FILL" "$P/review.md" > /dev/null || { bad "$name: could not write the review"; return; }
reviewer "$P" '[]'
node "$FINALIZE" --review "$P/review.md" --repo "$R" --project "$P" --round 1 > "$TMP/pre-fin.out" 2>&1 \
|| { bad "$name: finalize: $(cat "$TMP/pre-fin.out")"; return; }
mark="$(reviewjson "$P/review.md" 'j.findings[0].pre_existing')"
c="$(gate "$P/review.md" "$R" "$TMP/pre-g.out")"
if [ "$mark" = true ] && [ "$c" -eq 1 ]; then
pass "$name: marked pre-existing, the gate exits 1"
else
bad "$name: pre_existing [$mark], gate exit $c: $(cat "$TMP/pre-g.out")"
fi
}
case_dropped_engine() {
name="dropped engine finding"
R="$TMP/dropped"
mkrepo "$R" || { bad "$name: setup"; return; }
P="$R/$PROJ_REL"
# A WARN review: its one MAJOR is answered accepted, so the gate passes it alone.
node "$TMP/mkreview.mjs" "$P/review.md" "$START" "$C1" "$MAJ" && node "$FILL" "$P/review.md" > /dev/null \
|| { bad "$name: could not write the review"; return; }
id="$(reviewjson "$P/review.md" 'j.findings[0].id')"
printf 'the parser keeps the old contract on purpose' | node "$ANSWERS" record --review "$P/review.md" --finding "$id" \
--answer accepted --repo "$R" --round 1 --store local --trailer "$TRAILER" > "$TMP/drop-rec.out" 2>&1
rc=$?
if [ "$rc" -ne 0 ]; then bad "$name: record exited $rc: $(cat "$TMP/drop-rec.out")"; return; fi
c="$(gate "$P/review.md" "$R" "$TMP/drop-g0.out")"
if [ "$c" -ne 0 ]; then bad "$name: the answered WARN alone exited $c: $(cat "$TMP/drop-g0.out")"; return; fi
# Phase 4.7's payload: a /code-review MAJOR on b.mjs line 3 that the review does not hold; finalize not run.
printf 'a.mjs\nb.mjs\n' > "$TMP/drop-scope.txt"
printf '{"engine": "code-review", "findings": [{"file": "b.mjs", "line": 3, "summary": "The counter overflows past 2^31", "failure_scenario": "a long run wraps the count"}]}\n' \
> "$TMP/drop-engine.json"
node "$ROOT/lib/review/engine-findings.mjs" --scope "$TMP/drop-scope.txt" "$TMP/drop-engine.json" \
> "$P/.review-engine-payload-code-review.json" 2> "$TMP/drop-eng.err" \
|| { bad "$name: engine-findings: $(cat "$TMP/drop-eng.err")"; return; }
c="$(gate "$P/review.md" "$R" "$TMP/drop-g1.out")"
if [ "$c" -eq 2 ] && grep -q 'engine finding not in review' "$TMP/drop-g1.out"; then
pass "$name: not finalized, the gate exits 2 (engine finding not in review)"
else
bad "$name: the dropped engine MAJOR exited $c: $(cat "$TMP/drop-g1.out")"
fi
reviewer "$P" '[]'
node "$FINALIZE" --review "$P/review.md" --repo "$R" --project "$P" --round 1 > "$TMP/drop-fin.out" 2>&1 \
|| { bad "$name: finalize: $(cat "$TMP/drop-fin.out")"; return; }
c="$(gate "$P/review.md" "$R" "$TMP/drop-g2.out")"
if [ "$c" -eq 2 ] && grep -q '"reason":"review-unanswered:' "$TMP/drop-g2.out"; then
pass "$name: finalized, the put-back MAJOR is unanswered"
else
bad "$name: after finalize the gate exited $c: $(cat "$TMP/drop-g2.out")"
fi
}
case_guard() {
name="merge question"
R="$TMP/merge"
mkdir -p "$R" || { bad "$name: setup"; return; }
git -C "$R" init -q -b main || { bad "$name: git init"; return; }
printf '[user]\n\tname = Test\n\temail = test@example.invalid\n[commit]\n\tgpgsign = false\n[core]\n\thooksPath = "%s"\n' "$TMP/hooks-none" >> "$R/.git/config"
ten a > "$R/a.mjs"
git -C "$R" add a.mjs && git -C "$R" commit -q -m 'chore: start' || { bad "$name: commit"; return; }
MAIN_SHA="$(git -C "$R" rev-parse HEAD)"
git -C "$R" checkout -q -b feat || { bad "$name: branch"; return; }
ten a 3 'export const a3 = 33;' > "$R/a.mjs"
git -C "$R" add a.mjs && git -C "$R" commit -q -m 'feat: the work' || { bad "$name: commit"; return; }
FEAT="$(git -C "$R" rev-parse HEAD)"
# The run's artifacts, outside the repo.
P="$TMP/merge-project"
mkdir -p "$P" || { bad "$name: setup"; return; }
printf '# Plan\n\n### Step 1: End to end\n\n- **End-to-end:** `/bin/bash tests/e2e-demo.sh` -> expected: `e2e: ok`, exit 0.\n\n## Doors\n\n- two-way: a revert restores the old parser\n' > "$P/plan.md"
printf '{"schema_version": "1", "status": "completed", "end_to_end": "verified", "plan_verification": {"status": "passed", "summary": {"total": 3, "passed": 3, "failed": 0, "blocked": 0, "unrunnable": 0}}}\n' > "$P/progress.json"
node "$TMP/mkreview.mjs" "$P/review.md" "$MAIN_SHA" "$FEAT" '[]' && node "$FILL" "$P/review.md" > /dev/null \
|| { bad "$name: could not write the review"; return; }
# HEAD at the reviewed commit while the writer asks the verdict gate.
node "$WRITER" write --repo "$R" --branch feat --project "$P" --json > "$TMP/ev.out" 2>&1
rc=$?
written="$(jfield "$TMP/ev.out" 'j.written')"
path="$(jfield "$TMP/ev.out" 'j.path')"
if [ "$rc" -eq 0 ] && [ "$written" = true ] && [ "$path" = "$R/.git/voyage/merge-evidence/$FEAT.json" ] && [ -f "$path" ]; then
pass "$name: the evidence file is written under the git common dir"
else
bad "$name: the writer exited $rc: $(cat "$TMP/ev.out")"
fi
if [ -z "$(git -C "$R" status --porcelain --untracked-files=all)" ]; then
pass "$name: git status shows nothing new"
else
bad "$name: git status: $(git -C "$R" status --porcelain --untracked-files=all)"
fi
git -C "$R" checkout -q main || { bad "$name: checkout main"; return; }
node -e 'process.stdout.write(JSON.stringify({ hook_event_name: "PreToolUse", tool_name: "Bash", cwd: process.argv[1], tool_input: { command: "git merge --no-ff feat" } }));' "$R" \
| node "$HOOK" > "$TMP/hook.out" 2> "$TMP/hook.err"
rc=$?
decision="$(jfield "$TMP/hook.out" 'j.hookSpecificOutput.permissionDecision')"
reason="$(jfield "$TMP/hook.out" 'j.hookSpecificOutput.permissionDecisionReason')"
sha8="$(printf '%s' "$FEAT" | cut -c1-8)"
tested="Tested by: End-to-end \`/bin/bash tests/e2e-demo.sh\` exit 0; Verification 3/3; review ALLOW at $sha8."
undone='Undone by: `git revert -m 1 <merge>` (two-way).'
if [ "$rc" -eq 0 ] && [ "$decision" = ask ]; then
pass "$name: the guard asks"
else
bad "$name: the guard exited $rc with decision [$decision]: $(cat "$TMP/hook.out" "$TMP/hook.err")"
fi
case "$reason" in
*"$tested"*) pass "$name: the Tested by line" ;;
*) bad "$name: no Tested by line [$tested] in: $reason" ;;
esac
case "$reason" in
*"$undone"*) pass "$name: the Undone by line" ;;
*) bad "$name: no Undone by line [$undone] in: $reason" ;;
esac
}
case_accepted
case_fixed
case_preexisting
case_dropped_engine
case_guard
if [ "$FAILED" -ne 0 ]; then
echo "e2e: $FAILED case(s) failed"
exit 1
fi
echo "e2e: ok"
exit 0

View file

@ -1,351 +0,0 @@
#!/usr/bin/env bash
# tests/e2e-fix-round-4-5b2-c.sh
# End to end for fix round 4.5b2, part C (plan
# .claude/plans/trekplan-2026-11-03-review-gate-4-5b2-fixes.md, Step 6,
# End-to-end:). On throwaway fixture repos it writes run evidence the way
# /trekexecute Phase 8 does (node lib/review/merge-evidence.mjs write) and
# feeds hooks/scripts/pre-bash-executor.mjs Bash inputs the way Claude Code does:
# 1. `git merge --no-ff feat -m "merge: trekexecute demo"` on main
# (/trekexecute's own order) asks with feat's Tested by line;
# 2. `git merge --no-ff feat && git merge --no-ff other` asks with the line
# that says the run evidence is not shown, and no `Tested by: End-to-end`;
# 3. `git merge --cleanup strip --no-ff feat` asks with feat's Tested by line;
# 4. `git merge --no-ff -m "Merge run branch" other` with evidence only for
# feat asks and never shows feat's evidence;
# 5. a tag named `feat` next to the branch: the writer refuses, and the
# question shows no run evidence;
# 6. a write from a second worktree whose HEAD lags the branch: the question
# reads `review not measured`.
# Prints one "ok - <case>" or "FAIL - <case>: <why>" line per case and
# "e2e: ok" at the end, exit 0; else exit 1. Starts nothing but node, git and
# the usual file tools. bash 3.2 compatible, ASCII only, no arrays.
set -u
cd "$(dirname "$0")/.." || { echo "FAIL - setup: cannot cd to the repo root"; exit 1; }
ROOT="$(pwd)"
HOOK="$ROOT/hooks/scripts/pre-bash-executor.mjs"
FILL="$ROOT/lib/review/fill-finding-ids.mjs"
WRITER="$ROOT/lib/review/merge-evidence.mjs"
unset CLAUDE_CODE_SESSION_ID
TMP="$(mktemp -d "${TMPDIR:-/tmp}/e2e-fix-round-4-5b2-c.XXXXXX")" || { echo "FAIL - setup: mktemp"; exit 1; }
trap 'rm -rf "$TMP"' EXIT
TMP="$(cd "$TMP" && pwd -P)" || { echo "FAIL - setup: cannot enter the temp directory"; exit 1; }
export LC_ALL=C
export LANG=C
FAILED=0
pass() { echo "ok - $1"; }
bad() { echo "FAIL - $1"; FAILED=$((FAILED + 1)); }
mkdir -p "$TMP/hooks-none" || { echo "FAIL - setup: cannot make the temp directories"; exit 1; }
# A review_version 1.2 ALLOW review with no findings of <start>..<end>
# (shape copied from tests/e2e-fix-round-4-5b2-b.sh mkreview.mjs).
# node mkreview.mjs <out> <start> <end>
cat > "$TMP/mkreview.mjs" <<'EOF'
import { writeFileSync } from 'node:fs';
const [out, start, end] = process.argv.slice(2);
const json = JSON.stringify({ verdict: 'ALLOW', counts: { BLOCKER: 0, MAJOR: 0, MINOR: 0, SUGGESTION: 0 }, allow_blocked_by: [], findings: [] }, null, 2);
writeFileSync(out, `---
type: trekreview
review_version: "1.2"
task: "t"
slug: e2e
project_dir: .claude/projects/2026-11-03-e2e/
brief_path: .claude/projects/2026-11-03-e2e/brief.md
scope_sha_start: ${start}
scope_sha_end: ${end}
reviewed_files_count: 1
verdict: ALLOW
findings: []
---
# Review: t
## Executive Summary
x
## Coverage
| File | Treatment | Reason |
|------|-----------|--------|
| a.mjs | deep-review | default |
## Declined to Judge
None.
Trimmed: 0
## Remediation Steps
None.
## Remediation Summary
x
\`\`\`json
${json}
\`\`\`
`);
EOF
# jfield <file> <expr>: prints the JS expression <expr> over the parsed JSON
# value `j` read from <file> (empty when it does not parse).
# (Copied from tests/e2e-fix-round-4-5b2-b.sh.)
jfield() {
node -e '
const fs = require("fs");
let j;
try { j = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); } catch (e) { process.exit(0); }
const v = Function("j", "return (" + process.argv[2] + ");")(j);
process.stdout.write(v === undefined || v === null ? "" : String(v));
' "$1" "$2"
}
# mkrepo <dir>: a git repo on main (a.mjs) with two branches one commit ahead
# each, feat (b.mjs) and other (c.mjs), checked out on main. Sets MAIN_SHA,
# FEAT and OTHER.
mkrepo() {
r="$1"
mkdir -p "$r" || return 1
git -C "$r" init -q -b main || return 1
printf '[user]\n\tname = Test\n\temail = test@example.invalid\n[commit]\n\tgpgsign = false\n[core]\n\thooksPath = "%s"\n' "$TMP/hooks-none" >> "$r/.git/config"
printf 'export const a = 1;\n' > "$r/a.mjs"
git -C "$r" add a.mjs && git -C "$r" commit -q -m 'chore: start' || return 1
MAIN_SHA="$(git -C "$r" rev-parse HEAD)"
git -C "$r" checkout -q -b feat || return 1
printf 'export const b = 1;\n' > "$r/b.mjs"
git -C "$r" add b.mjs && git -C "$r" commit -q -m 'feat: the work' || return 1
FEAT="$(git -C "$r" rev-parse HEAD)"
git -C "$r" checkout -q main || return 1
git -C "$r" checkout -q -b other || return 1
printf 'export const c = 1;\n' > "$r/c.mjs"
git -C "$r" add c.mjs && git -C "$r" commit -q -m 'feat: the other work' || return 1
OTHER="$(git -C "$r" rev-parse HEAD)"
git -C "$r" checkout -q main || return 1
}
# mkproject <dir> <start> <end>: a run's artifacts (plan, progress, ALLOW
# review of <start>..<end>) outside the repo.
mkproject() {
mkdir -p "$1" || return 1
printf '# Plan\n\n### Step 1: End to end\n\n- **End-to-end:** `/bin/bash tests/e2e-demo.sh` -> expected: `e2e: ok`, exit 0.\n\n## Doors\n\n- two-way: a revert restores the old parser\n' > "$1/plan.md"
printf '{"schema_version": "1", "status": "completed", "end_to_end": "verified", "plan_verification": {"status": "passed", "summary": {"total": 3, "passed": 3, "failed": 0, "blocked": 0, "unrunnable": 0}}}\n' > "$1/progress.json"
node "$TMP/mkreview.mjs" "$1/review.md" "$2" "$3" && node "$FILL" "$1/review.md" > /dev/null
}
# writeev <repo> <branch> <project> <out>: the writer's CLI run from <repo>;
# prints its exit status.
writeev() {
node "$WRITER" write --repo "$1" --branch "$2" --project "$3" --json > "$4" 2>&1
echo $?
}
# record <repo> <branch> <sha> <label>: check out <branch> (HEAD at the
# reviewed commit, as Phase 8 runs), write its evidence, back to main.
# Returns 1 with the reason in WHY when the file is not written.
record() {
WHY=""
mkproject "$TMP/$4-project" "$MAIN_SHA" "$3" || { WHY="could not write the project"; return 1; }
git -C "$1" checkout -q "$2" || { WHY="checkout $2"; return 1; }
rc="$(writeev "$1" "$2" "$TMP/$4-project" "$TMP/$4-ev.out")"
git -C "$1" checkout -q main || { WHY="checkout main"; return 1; }
if [ "$rc" -ne 0 ] || [ "$(jfield "$TMP/$4-ev.out" 'j.written')" != true ] || [ ! -f "$1/.git/voyage/merge-evidence/$3.json" ]; then
WHY="the writer exited $rc: $(cat "$TMP/$4-ev.out")"
return 1
fi
return 0
}
# askhook <repo> <command> <label>: the guard on a Bash call in <repo>; sets
# DECISION and REASON (and HOOK_RC).
askhook() {
node -e 'process.stdout.write(JSON.stringify({ hook_event_name: "PreToolUse", tool_name: "Bash", cwd: process.argv[1], tool_input: { command: process.argv[2] } }));' "$1" "$2" \
| node "$HOOK" > "$TMP/$3-hook.out" 2> "$TMP/$3-hook.err"
HOOK_RC=$?
DECISION="$(jfield "$TMP/$3-hook.out" 'j.hookSpecificOutput.permissionDecision')"
REASON="$(jfield "$TMP/$3-hook.out" 'j.hookSpecificOutput.permissionDecisionReason')"
}
# tested <sha>: the Tested by line the writer's evidence gives for <sha>.
tested() {
printf 'Tested by: End-to-end `/bin/bash tests/e2e-demo.sh` exit 0; Verification 3/3; review ALLOW at %s.' "$(printf '%s' "$1" | cut -c1-8)"
}
UNDONE='Undone by: `git revert -m 1 <merge>` (two-way).'
NOT_SHOWN="Tested by: not shown (this command merges 2 branches into main; merge one at a time to see each run's evidence).
Undone by: not shown."
NO_EVIDENCE='Tested by: no run evidence.
Undone by: no run evidence.'
# The shared fixture of cases 1-3: evidence for both feat and other.
R="$TMP/both"
BOTH_OK=1
if ! mkrepo "$R"; then
BOTH_OK=0
BOTH_WHY="setup"
elif ! record "$R" feat "$FEAT" both-feat; then
BOTH_OK=0
BOTH_WHY="feat: $WHY"
elif ! record "$R" other "$OTHER" both-other; then
BOTH_OK=0
BOTH_WHY="other: $WHY"
fi
BOTH_FEAT="$FEAT"
BOTH_OTHER="$OTHER"
case_order() {
name="the trekexecute merge order shows feat's Tested by line"
if [ "$BOTH_OK" -ne 1 ]; then bad "$name: $BOTH_WHY"; return; fi
askhook "$R" 'git merge --no-ff feat -m "merge: trekexecute demo"' order
want="$(tested "$BOTH_FEAT")
$UNDONE"
if [ "$HOOK_RC" -ne 0 ] || [ "$DECISION" != ask ]; then
bad "$name: the guard exited $HOOK_RC with decision [$DECISION]: $(cat "$TMP/order-hook.out" "$TMP/order-hook.err")"
return
fi
case "$REASON" in
*"$want") pass "$name" ;;
*) bad "$name: the reason does not end with [$want]: $REASON" ;;
esac
}
case_two() {
name="two merges in one command say the run evidence is not shown"
if [ "$BOTH_OK" -ne 1 ]; then bad "$name: $BOTH_WHY"; return; fi
askhook "$R" 'git merge --no-ff feat && git merge --no-ff other' two
if [ "$HOOK_RC" -ne 0 ] || [ "$DECISION" != ask ]; then
bad "$name: the guard exited $HOOK_RC with decision [$DECISION]: $(cat "$TMP/two-hook.out" "$TMP/two-hook.err")"
return
fi
case "$REASON" in
*'Tested by: End-to-end'*) bad "$name: a Tested by: End-to-end line is shown: $REASON"; return ;;
esac
case "$REASON" in
*"$NOT_SHOWN"*) pass "$name" ;;
*) bad "$name: no not-shown line [$NOT_SHOWN] in: $REASON" ;;
esac
}
case_cleanup() {
name="git merge --cleanup strip shows feat's Tested by line"
if [ "$BOTH_OK" -ne 1 ]; then bad "$name: $BOTH_WHY"; return; fi
askhook "$R" 'git merge --cleanup strip --no-ff feat' cleanup
want="$(tested "$BOTH_FEAT")
$UNDONE"
if [ "$HOOK_RC" -ne 0 ] || [ "$DECISION" != ask ]; then
bad "$name: the guard exited $HOOK_RC with decision [$DECISION]: $(cat "$TMP/cleanup-hook.out" "$TMP/cleanup-hook.err")"
return
fi
case "$REASON" in
*"$want"*) pass "$name" ;;
*) bad "$name: no [$want] in: $REASON" ;;
esac
}
case_message() {
name="a quoted -m before another branch never shows feat's evidence"
R4="$TMP/message"
mkrepo "$R4" || { bad "$name: setup"; return; }
record "$R4" feat "$FEAT" message-feat || { bad "$name: $WHY"; return; }
if [ -e "$R4/.git/voyage/merge-evidence/$OTHER.json" ]; then bad "$name: Control: other has evidence"; return; fi
askhook "$R4" 'git merge --no-ff -m "Merge run branch" other' message
if [ "$HOOK_RC" -ne 0 ] || [ "$DECISION" != ask ]; then
bad "$name: the guard exited $HOOK_RC with decision [$DECISION]: $(cat "$TMP/message-hook.out" "$TMP/message-hook.err")"
return
fi
case "$REASON" in
*"$(tested "$FEAT")"*) bad "$name: feat's Tested by line is shown: $REASON" ;;
*'Tested by: End-to-end'*) bad "$name: a Tested by: End-to-end line is shown, other has no evidence: $REASON" ;;
*'Tested by:'*) pass "$name" ;;
*) bad "$name: no Tested by line at all: $REASON" ;;
esac
}
case_tag() {
name="a tag named feat: the writer refuses and the question shows no run evidence"
R5="$TMP/tag"
mkrepo "$R5" || { bad "$name: setup"; return; }
# Evidence written while the name was a branch only.
record "$R5" feat "$FEAT" tag-feat || { bad "$name: Control: $WHY"; return; }
git -C "$R5" update-ref refs/tags/feat "$MAIN_SHA" || { bad "$name: update-ref"; return; }
git -C "$R5" checkout -q feat 2> "$TMP/tag-checkout.err" || { bad "$name: checkout feat: $(cat "$TMP/tag-checkout.err")"; return; }
rc="$(writeev "$R5" feat "$TMP/tag-feat-project" "$TMP/tag-ev2.out")"
git -C "$R5" checkout -q main || { bad "$name: checkout main"; return; }
written="$(jfield "$TMP/tag-ev2.out" 'j.written')"
why="$(jfield "$TMP/tag-ev2.out" 'j.reason')"
case "$why" in
*'branch name feat is ambiguous'*) ;;
*) bad "$name: the writer exited $rc, written [$written]: $(cat "$TMP/tag-ev2.out")"; return ;;
esac
if [ "$rc" -ne 1 ] || [ "$written" != false ]; then
bad "$name: the writer exited $rc, written [$written]: $(cat "$TMP/tag-ev2.out")"
return
fi
askhook "$R5" 'git merge --no-ff feat' tag
if [ "$HOOK_RC" -ne 0 ] || [ "$DECISION" != ask ]; then
bad "$name: the guard exited $HOOK_RC with decision [$DECISION]: $(cat "$TMP/tag-hook.out" "$TMP/tag-hook.err")"
return
fi
case "$REASON" in
*'Tested by: End-to-end'*) bad "$name: a Tested by: End-to-end line is shown: $REASON" ;;
*"$NO_EVIDENCE"*) pass "$name" ;;
*) bad "$name: no [$NO_EVIDENCE] in: $REASON" ;;
esac
}
case_worktree() {
name="a write from a worktree whose HEAD lags the branch reads review not measured"
R6="$TMP/wt"
mkrepo "$R6" || { bad "$name: setup"; return; }
REVIEWED="$FEAT"
mkproject "$TMP/wt-project" "$MAIN_SHA" "$REVIEWED" || { bad "$name: could not write the project"; return; }
git -C "$R6" worktree add -q --detach "$TMP/wt-second" "$REVIEWED" || { bad "$name: worktree add"; return; }
# The branch moves on in the first worktree.
git -C "$R6" checkout -q feat || { bad "$name: checkout feat"; return; }
printf 'export const b = 2;\n' > "$R6/b.mjs"
git -C "$R6" add b.mjs && git -C "$R6" commit -q -m 'feat: more' || { bad "$name: commit"; return; }
HEAD2="$(git -C "$R6" rev-parse HEAD)"
git -C "$R6" checkout -q main || { bad "$name: checkout main"; return; }
if [ "$(git -C "$TMP/wt-second" rev-parse HEAD)" != "$REVIEWED" ] || [ "$HEAD2" = "$REVIEWED" ]; then
bad "$name: Control: the second worktree's HEAD does not lag the branch"
return
fi
rc="$(writeev "$TMP/wt-second" feat "$TMP/wt-project" "$TMP/wt-ev.out")"
if [ "$rc" -ne 0 ] || [ "$(jfield "$TMP/wt-ev.out" 'j.written')" != true ] || [ ! -f "$R6/.git/voyage/merge-evidence/$HEAD2.json" ]; then
bad "$name: the writer exited $rc: $(cat "$TMP/wt-ev.out")"
return
fi
askhook "$R6" 'git merge --no-ff feat' wt
sha8="$(printf '%s' "$REVIEWED" | cut -c1-8)"
if [ "$HOOK_RC" -ne 0 ] || [ "$DECISION" != ask ]; then
bad "$name: the guard exited $HOOK_RC with decision [$DECISION]: $(cat "$TMP/wt-hook.out" "$TMP/wt-hook.err")"
return
fi
case "$REASON" in
*'review ALLOW'*) bad "$name: a review verdict is shown: $REASON" ;;
*"review not measured at $sha8."*) pass "$name" ;;
*) bad "$name: no [review not measured at $sha8.] in: $REASON" ;;
esac
}
case_order
case_two
case_cleanup
case_message
case_tag
case_worktree
if [ "$FAILED" -ne 0 ]; then
echo "e2e: $FAILED case(s) failed"
exit 1
fi
echo "e2e: ok"
exit 0

102
tests/fixtures/plan-run-c.md vendored Normal file
View file

@ -0,0 +1,102 @@
# Test plan
> **Plan quality: A** (92/100) — APPROVE
>
> Generated by trekplan v6.1.1 on 2026-10-06 — `plan_version: 1.9` · `ceremony: architectural`
## Context
Why: the brief asks for a parser CLI.
## Design
### Architecture
Two modules: a parser and a CLI on top of it.
### Data Flow
CLI argv → parser → result JSON on stdout.
### Interfaces
`parse(text) → {ok, items}`
### Data Models
`Item = {name: string, n: number}`
### Error Handling
Unreadable input → exit 2; invalid input → exit 1.
### Module Map
```mermaid
graph TD
cli[src/cli.mjs] --> parser[src/parser.mjs]
parser --> util[src/util.mjs]
```
### Architecture Rules
| Rule | Enforced by | Proof |
|------|-------------|-------|
| The parser never imports the CLI | checked by `node_modules/.bin/depcruise --config .dependency-cruiser.cjs src` | Step 1 |
| Prefer pure functions in the parser | advisory — `.claude/rules/parser.md` | — |
### Requirements Check
No conflicts or gaps found between the brief's requirements.
## Implementation Plan
### Step 1: Build module 1
- **Files:** `src/mod1.mjs`
- **Changes:** Add `run1()` to `src/mod1.mjs`. (new file)
- **Test first:**
- File: `tests/mod1.test.mjs` (new)
- Verifies: run1() returns the value the next step reads
- Pattern: `tests/lib/plan-schema.test.mjs`
- **Verify:** `node --test tests/mod1.test.mjs` → expected: pass
- **On failure:** revert — `git checkout -- src/mod1.mjs`
- **Checkpoint:** `git commit -m "feat(x): step 1"`
- **Manifest:**
```yaml
manifest:
expected_paths:
- src/mod1.mjs
- tests/mod1.test.mjs
min_file_count: 2
commit_message_pattern: "^feat\\(x\\):"
bash_syntax_check: []
forbidden_paths: []
must_contain: []
```
### Step 2: Build module 2
- **Files:** `src/mod2.mjs`
- **Changes:** Add `run2()` to `src/mod2.mjs`. (new file)
- **Test first:**
- File: `tests/mod2.test.mjs` (new)
- Verifies: run2() returns the value the next step reads
- Pattern: `tests/lib/plan-schema.test.mjs`
- **End-to-end:** `bash tests/e2e.sh` → expected: exit 0 and the CLI prints a result
- **Verify:** `node --test tests/mod2.test.mjs` → expected: pass
- **On failure:** revert — `git checkout -- src/mod2.mjs`
- **Checkpoint:** `git commit -m "feat(x): step 2"`
- **Manifest:**
```yaml
manifest:
expected_paths:
- src/mod2.mjs
- tests/mod2.test.mjs
min_file_count: 2
commit_message_pattern: "^feat\\(x\\):"
bash_syntax_check: []
forbidden_paths: []
must_contain: []
```
## Test Strategy
- **Framework:** node:test
## Verification
- [ ] `npm test` → expected: exit 0
- [ ] `bash tests/e2e.sh` → expected: exit 0

View file

@ -1,8 +1,8 @@
#!/usr/bin/env node
// tests/helpers/red-judge.mjs
// The judge of the red script of fix round 4-5a (tests/fix-round-4-5a-red.sh).
// The judge of the shared red script (tests/helpers/red-round.sh).
// It reads the JSON lines tests/helpers/red-reporter.mjs wrote for a run at
// ab6b32c and decides whether every id of the round was seen red there: each
// a round's start point and decides whether every id of the round was seen red there: each
// tagged test (a name that starts with "[id]") failed on an assertion.
//
// node tests/helpers/red-judge.mjs --ids <file> --results <jsonl>

View file

@ -1,6 +1,6 @@
// tests/helpers/red-reporter.mjs
// A node:test custom reporter for the red script of fix round 4-5a
// (tests/fix-round-4-5a-red.sh). It writes one JSON line per test:pass and
// A node:test custom reporter for the shared red script
// (tests/helpers/red-round.sh). It writes one JSON line per test:pass and
// test:fail event, for tests/helpers/red-judge.mjs to read:
// {name, file, nesting, ok, skip, todo, code, cname, failureType}
// `code` and `cname` come from the error's cause (the error node:test reports

View file

@ -1,6 +1,6 @@
// tests/hooks/bash-guard-reader-pins.test.mjs
// Fix round 4.5b, part 4 (decision 90): part 3's five fix-round reader cases
// (tests/e2e-fix-round-4-5b-part-3.sh:277-305) as pins under npm test, so a
// (from part 3's end-to-end script, since removed) as pins under npm test, so a
// later edit of the args-heredoc guard in hooks/scripts/pre-bash-executor.mjs
// cannot drop them unseen. Untagged: they pass at 591f3c4, and no red
// wrapper lists this file.

View file

@ -9,10 +9,13 @@
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFileSync } from 'node:fs';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { runHook } from '../helpers/hook-helper.mjs';
import { childEnv } from '../helpers/child-env.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
@ -418,6 +421,33 @@ test('[ARG] every shipped parser heredoc block passes the guard', async () => {
}
});
/** The /trekreview Phase 1 parser block as Claude Code runs it, with `args` in place of $ARGUMENTS. */
function trekreviewParserBlock(args) {
const blocks = sectionBashBlocks('commands/trekreview.md', '## Phase 1 ').filter((b) => b.includes(`<<'${ARGS_DELIM}'`));
assert.equal(blocks.length, 1, `commands/trekreview.md ## Phase 1 must hold one parser heredoc block; found ${blocks.length}`);
return `${blocks[0].split('${CLAUDE_PLUGIN_ROOT}').join(ROOT).split('$ARGUMENTS').join(args)}\n`;
}
test('[ARG] the shipped trekreview Phase 1 block passes the guard, and /bin/bash runs it to JSON that keeps a value closed on its own line', async () => {
// A quoted value closed on its own line: the typed text holds a line break.
const command = trekreviewParserBlock('--project "notes\n" --since HEAD');
await expectArgsAllowed(command);
const cwd = mkdtempSync(join(tmpdir(), 'bash-guard-shipped-'));
try {
const r = spawnSync('/bin/bash', ['-c', command], { cwd, encoding: 'utf8', env: childEnv() });
assert.equal(r.status, 0, `the block exited ${r.status}: ${r.stdout}${r.stderr}`);
const j = JSON.parse(r.stdout);
assert.equal(j.flags['--project'], 'notes\n', `--project is not notes + line break: ${r.stdout}`);
assert.equal(j.flags['--since'], 'HEAD', `--since is not HEAD: ${r.stdout}`);
assert.deepEqual(j.positional, []);
} finally { rmSync(cwd, { recursive: true, force: true }); }
});
test('[ARG] a typed argument holding << passes the guard: the parser heredoc form and the shipped trekreview Phase 1 block', async () => {
await expectArgsAllowed(`${PARSER_LINE} <<'${ARGS_DELIM}'\nfix "<<" handling\n${ARGS_DELIM}`);
await expectArgsAllowed(trekreviewParserBlock('fix "<<" handling'));
});
// HDG (fix round 4.5b, part 2) -- bash reads a heredoc body only from the
// lines after the opener's line. A command that only mentions the opener, with
// no non-blank line after it, has nothing that can close early and nothing to

View file

@ -252,6 +252,20 @@ test('«høres bra ut», «gjør som du vil» and an empty answer are not approv
assert.equal(isExplicitApproval('høres bra ut').reason, 'hollow');
});
test('«Approve (Recommended)», the label AskUserQuestion recommends, is the explicit approval it labels', async () => {
const { isExplicitApproval } = await load();
for (const a of ['Approve (Recommended)', 'Approve (recommended)', 'Godkjenn (anbefalt)', 'Approve (Recommended).']) {
assert.equal(isExplicitApproval(a).approved, true, a);
}
});
test('the (Recommended) suffix does not turn a hollow answer into a yes', async () => {
const { isExplicitApproval } = await load();
for (const a of ['Sounds good (Recommended)', 'Høres bra ut (anbefalt)', '(Recommended)', 'Revise (Recommended)']) {
assert.equal(isExplicitApproval(a).approved, false, a);
}
});
test('the answer CLI: exit 0 only for an explicit approval', () => {
const d = tmp();
try {

View file

@ -289,3 +289,12 @@ test('[M10] an inner apostrophe stays', async () => {
assert.equal(typeof mod.parseArgs, 'function', 'arg-parser.mjs exports parseArgs');
assert.equal(mod.parseArgs(`brief.md Fix the user's login`, 'trekendsession').tail, "Fix the user's login");
});
test('trekreview --answers: the parser accepts it as a boolean, and the usage line names it', () => {
const r = parseArgs('--answers --project x', 'trekreview');
assert.equal(r.flags['--answers'], true, `--answers is a trekreview boolean: ${JSON.stringify(r)}`);
assert.deepEqual(r.unknown, [], `--answers is not unknown: ${JSON.stringify(r)}`);
const usage = runStdin('trekreview', '--answers\n');
assert.equal(usage.status, 1, usage.stdout + usage.stderr);
assert.match(usage.stderr, /Usage: \/trekreview --project <dir> [^\n]*\[--answers\]/, `the usage line names --answers: ${usage.stderr}`);
});

View file

@ -5,7 +5,8 @@
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { execFileSync } from 'node:child_process';
import { execFileSync, spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { transition, isTerminal, STATES, EVENTS } from '../../lib/util/autonomy-gate.mjs';
@ -206,3 +207,65 @@ test('[#1] sibling: --gates=<value> is read as --gates <value>; an unknown or em
assert.match(parsed.error, /usage: --gates takes true, false, closed or open/);
}
});
// ---- the four commands' --gates rule and shim line ----------------------------
// Each of the four commands holds one rule: the next token is the flag's value
// only when it is true, false, closed or open; the shim maps it, run once; a
// broken shim falls back to the same mapping in prose and the run goes on.
const ROOT = join(HERE, '..', '..');
const GATES_COMMANDS = ['trekbrief', 'trekresearch', 'trekplan', 'trekexecute'];
const GATES_RULE = [
'`--gates` takes the next token as its value only when it is `true`, `false`, `closed` or `open` (strip both); any other next token is not its value (strip only the flag).',
'Run the shim once to map the value, bare when there is no value:',
'`next_state: gates_on` sets `gates_mode = true`, `auto_running` sets it false, and nothing else reads the state.',
'On exit 1 or no JSON (a broken shim; the four values cannot cause it), print the error in one line and map in prose instead: bare, `true`, `closed` → true; `false`, `open` → false. The run goes on either way.',
];
const commandText = (cmd) => readFileSync(join(ROOT, 'commands', `${cmd}.md`), 'utf8');
/** Whitespace (line breaks too) collapsed to one space. */
const flat = (s) => s.replace(/\s+/g, ' ').trim();
/** The command's one shim line, `[value]` filled in with `value` (left out when ''). */
function gatesShim(cmd, value) {
const hits = commandText(cmd).split('\n')
.filter((l) => /^\s*node \$\{CLAUDE_PLUGIN_ROOT\}\/lib\/util\/autonomy-gate\.mjs\b/.test(l) && l.includes('--event start'));
assert.equal(hits.length, 1, `${cmd}: expected exactly one autonomy-gate.mjs --event start line; found ${hits.length}`);
const l = hits[0].trim();
assert.match(l, / --state idle --event start --gates \[value\]$/, `${cmd}: the shim line must end "--gates [value]"`);
return l.replace(' [value]', value ? ` ${value}` : '');
}
const shimCache = new Map();
/** Run a shim line from the prose with the plugin root filled in; its `next_state`. Each distinct line runs once. */
function shimNext(cmdLine) {
if (!shimCache.has(cmdLine)) {
const r = spawnSync('bash', ['-c', cmdLine.split('${CLAUDE_PLUGIN_ROOT}').join(ROOT)], { cwd: ROOT, encoding: 'utf8' });
assert.equal(r.status, 0, `shim exited ${r.status}: ${r.stdout}${r.stderr}`);
shimCache.set(cmdLine, JSON.parse(r.stdout).next_state);
}
return shimCache.get(cmdLine);
}
for (const cmd of GATES_COMMANDS) {
test(`[#1] commands/${cmd}.md holds the shared --gates rule and a shim line that maps closed to gates_on`, () => {
const text = commandText(cmd);
const f = flat(text);
for (const s of GATES_RULE) assert.ok(f.includes(s), `${cmd}: missing the shared --gates sentence: ${s}`);
assert.doesNotMatch(text, /--state X --event Y/, `${cmd}: the placeholder shim line is still there`);
assert.doesNotMatch(f, /consumed by the autonomy-gate/, `${cmd}: still says the flag is consumed by the state machine`);
assert.equal(shimNext(gatesShim(cmd, 'closed')), 'gates_on');
});
}
// `/trekplan --gates --project d` and `/trekexecute --gates plan.md` are a bare
// --gates (the next token is not a value), so the shim runs bare.
test('every command\'s shim line maps a bare --gates to gates_on and open to auto_running', () => {
for (const cmd of GATES_COMMANDS) {
assert.equal(shimNext(gatesShim(cmd, '')), 'gates_on', `${cmd}: bare --gates`);
assert.equal(shimNext(gatesShim(cmd, 'open')), 'auto_running', `${cmd}: --gates open`);
}
});

View file

@ -593,3 +593,27 @@ test('[ORD] the title classes are placed in alphabetical order: cA, fB, cB and a
verdict: 'WARN',
});
});
// ---- an out-of-range /code-review finding does not lock the verdict -----------
const outOfScope = (over) => ({
file: 'src/util.mjs', line: 4, engine: 'code-review',
summary: 'A two-step detour where one split/filter/join would do',
failure_scenario: 'slug("a--b") walks the string twice',
...over,
});
test('runContract — a small /code-review finding outside the exact range it was given is declined, and does not hold ALLOW back', () => {
const out = outOfScope({ rule_key: 'CODE_REVIEW_IMPROVEMENT', severity: 'MINOR', title: 'Simplify the helper' });
const c = runContract([], { engineOutOfScope: [out] });
assert.equal(c.verdict, 'ALLOW', JSON.stringify(c.allow_blocked_by));
assert.equal(c.declined_to_judge.length, 1);
assert.equal(c.declined_to_judge[0].reason, 'engine:out-of-scope');
});
test('runContract — a MAJOR /code-review defect cited at an unchanged file still forbids ALLOW (a cross-file break is cited there)', () => {
const out = outOfScope({ file: 'tests/cli.test.mjs', line: 1, rule_key: 'CODE_REVIEW_DEFECT', severity: 'MAJOR', title: 'No CLI test for --sep' });
const c = runContract([], { engineOutOfScope: [out] });
assert.notEqual(c.verdict, 'ALLOW');
assert.equal(c.declined_to_judge.length, 1);
});

View file

@ -13,7 +13,7 @@ import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import {
@ -428,7 +428,7 @@ const PLAN_ARTIFACTS = [
'templates/plan-template.md',
'tests/fixtures/plan-verification-fails.md',
'tests/fixtures/plan-verification-passes.md',
'tests/synthetic/plan-run-C.md',
'tests/fixtures/plan-run-c.md',
];
test("the repo's own plan artifacts each declare at least one criterion", () => {
@ -823,3 +823,91 @@ test('round trip: the template with its placeholders filled in yields RUNNABLE c
const results = runCriteria(criteria, { cwd: ROOT });
assert.ok(results.every((r) => r.status === 'passed'), JSON.stringify(results.map((r) => r.status)));
});
// --- NODE_TEST_CONTEXT never reaches a criterion's command -------------------
test('runCriteria: a nested `node --test` on a failing test is red even when NODE_TEST_CONTEXT is set', () => {
const dir = mkdtempSync(join(tmpdir(), 'criteria-ctx-'));
const saved = process.env.NODE_TEST_CONTEXT;
try {
mkdirSync(join(dir, 'tests'));
writeFileSync(join(dir, 'tests', 'fail.test.mjs'), [
"import { test } from 'node:test';",
"import { strict as assert } from 'node:assert';",
"test('fails', () => { assert.equal(1, 2); });",
'',
].join('\n'));
process.env.NODE_TEST_CONTEXT = 'child-v8';
const [r] = runCriteria([{ label: 'C1', text: 'x', command: 'node --test tests/fail.test.mjs' }], { cwd: dir });
assert.equal(r.status, 'failed', JSON.stringify(r));
assert.notEqual(r.exitCode, 0);
} finally {
if (saved === undefined) delete process.env.NODE_TEST_CONTEXT;
else process.env.NODE_TEST_CONTEXT = saved;
rmSync(dir, { recursive: true, force: true });
}
});
// --- sub-points belong to their criterion -------------------------------------
function briefWith(sections) {
return [
'---', 'type: trekbrief', 'brief_version: "2.2"', 'task: t', 'slug: s',
'research_topics: 0', 'research_status: skipped', 'framing: new-direction', 'phase_signals_partial: true', '---', '',
'## TL;DR', '', 'A toy.', '', '## Intent', '', 'Why.', '', '## Goal', '', 'What.', '',
...sections,
].join('\n');
}
test('parseSuccessCriteria: indented sub-points are part of their criterion, not criteria of their own', () => {
const sc = parseSuccessCriteria(briefWith([
'## Success Criteria', '',
'- `npm test` exits 0.',
'- The separator is honoured:',
' - `-` joins words',
' - `_` joins words',
' - `.` joins words',
'- `bash tests/e2e.sh` exits 0.', '',
]));
assert.deepEqual(sc.map((c) => c.label), ['SC1', 'SC2', 'SC3']);
assert.equal(sc[1].subpoints, 3);
assert.equal(sc[2].command, 'bash tests/e2e.sh');
});
test('parseSuccessCriteria: a sub-bullet that carries its own command stays a criterion; only command-less sub-points fold', () => {
const sc = parseSuccessCriteria(briefWith(['## Success Criteria', '',
'- Behaviour:', ' - `npm test` exits 0', ' - `bash tests/e2e.sh` exits 0', ' - reads well', '']));
assert.deepEqual(sc.map((c) => c.command), [null, 'npm test', 'bash tests/e2e.sh']);
assert.equal(sc[0].subpoints, 1);
});
test('parseSuccessCriteria: an indented first bullet does not turn later top-level bullets into sub-points', () => {
const sc = parseSuccessCriteria(briefWith(['## Success Criteria', '',
' - The first, indented by accident.', '- The second:', ' - a sub-point of the second', '- `npm test` exits 0.', '']));
assert.equal(sc.length, 3, JSON.stringify(sc.map((c) => c.text)));
assert.equal(sc[1].subpoints, 1);
});
// --- formatCriteriaEvidence on a PASS row --------------------------------------
const evidenceRep = (results) => ({
source: 'brief.md',
heading: '## Success Criteria',
error: null,
results,
summary: { passed: 1, failed: 0, blocked: 0, unrunnable: 0, total: 1, ok: true },
});
test('formatCriteriaEvidence: shows the first line of output on a passed criterion', () => {
const block = formatCriteriaEvidence(evidenceRep([
{ label: 'SC1', text: 't', command: 'npm test', status: 'passed', exitCode: 0, output: '\n# tests 12\n# pass 12\n' },
]));
assert.match(block, /\| SC1 \| `npm test` \| PASS \| exit 0 \| # tests 12 \|/);
});
test('formatCriteriaEvidence: a passed criterion with no output still says "—"', () => {
const block = formatCriteriaEvidence(evidenceRep([
{ label: 'SC1', text: 't', command: 'npm test', status: 'passed', exitCode: 0, output: '' },
]));
assert.match(block, /\| SC1 \| `npm test` \| PASS \| exit 0 \| — \|/);
});

View file

@ -2211,3 +2211,22 @@ test('effort floor: profiles.md names the four retrieval agents as high', () =>
assert.match(bullet, /`high`/);
assert.doesNotMatch(bullet, /`medium`/);
});
// The resolver returns `effort` only when the brief's phase signal sets one;
// no command says it always does.
const RESOLVER_COMMANDS = ['trekplan', 'trekexecute', 'trekresearch', 'trekreview'];
test('resolver: no command says the resolver always returns {effort, model, source}', () => {
for (const f of readdirSync(join(ROOT, 'commands')).filter((x) => x.endsWith('.md'))) {
assert.doesNotMatch(read(`commands/${f}`), /\{effort, model, source\}/, f);
}
});
test('resolver: each resolver-using command says `effort` comes only from the brief signal, else standard', () => {
for (const c of RESOLVER_COMMANDS) {
const t = read(`commands/${c}.md`);
const hits = t.match(/\{model, source\}[^\n]*\n?[^\n]*effort[^\n]*only when the brief's phase signal sets one/g) ?? [];
assert.ok(hits.length >= 1, `${c}: no statement that effort is only present with a brief signal`);
assert.match(t, /without it[^\n]*\n?[^\n]*`?standard`?/i, c);
}
});

View file

@ -5,9 +5,15 @@
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { toReviewerPayload, ENGINES } from '../../lib/review/engine-findings.mjs';
import { runContract } from '../../lib/review/coordinator-contract.mjs';
import { validateFindings } from '../../lib/review/findings-schema.mjs';
import { getRule } from '../../lib/review/rule-catalogue.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
test('engine-findings — a /code-review finding becomes a MAJOR CODE_REVIEW_DEFECT', () => {
const r = toReviewerPayload({ engine: 'code-review', findings: [
@ -109,3 +115,57 @@ test('engine-findings — an engine that was expected but never reported forbids
assert.equal(result.verdict, 'WARN');
assert.deepEqual(result.missing_reviewers, ['claude-security-review']);
});
const engineFinding = (extra = {}) => ({
file: 'src/slug.mjs', line: 9,
summary: 'A two-step detour where one split/filter/join would do',
failure_scenario: 'slug("a--b") walks the string twice',
...extra,
});
// ---- an improvement from /code-review is not a MAJOR defect -------------------
test('engine-findings — a /code-review simplification or efficiency finding is MINOR, a correctness finding stays MAJOR', () => {
const r = toReviewerPayload({ engine: 'code-review', findings: [
engineFinding({ category: 'simplification' }),
engineFinding({ line: 10, category: 'efficiency' }),
engineFinding({ line: 11, category: 'correctness' }),
engineFinding({ line: 12 }),
] });
assert.equal(r.ok, true, r.errors.join('; '));
const sev = r.payload.findings.map((f) => f.severity);
assert.deepEqual(sev, ['MINOR', 'MINOR', 'MAJOR', 'MAJOR']);
for (const f of r.payload.findings) assert.equal(f.severity, getRule(f.rule_key).severity, 'severity is fixed by rule_key');
});
test('engine-findings — /security-review keeps its tier whatever the category', () => {
const r = toReviewerPayload({ engine: 'security-review', findings: [engineFinding({ category: 'simplification' })] });
assert.equal(r.payload.findings[0].severity, 'BLOCKER');
});
test('engine-findings — a `reuse` finding is an improvement too', () => {
const r = toReviewerPayload({ engine: 'code-review', findings: [engineFinding({ category: 'Reuse ' })] });
assert.equal(r.payload.findings[0].severity, 'MINOR');
});
// ---- recommended_action is a remedy, not the failure scenario ----------------
test('engine-findings — the engine\'s own fix text becomes the recommended action', () => {
const r = toReviewerPayload({ engine: 'code-review', findings: [engineFinding({ fix: 'Use one split/filter/join pass' })] });
assert.equal(r.payload.findings[0].recommended_action, 'Use one split/filter/join pass');
});
test('engine-findings — without a fix text the action is a step: reproduce the scenario in a test, then change the location', () => {
const r = toReviewerPayload({ engine: 'code-review', findings: [engineFinding()] });
const a = r.payload.findings[0].recommended_action;
assert.doesNotMatch(a, /so this no longer happens/);
assert.match(a, /^Write a test that reproduces/);
assert.match(a, /src\/slug\.mjs:9/);
});
test('engine-findings — /trekreview asks the transcription for the engine\'s fix text and forbids inventing one', () => {
const md = readFileSync(join(ROOT, 'commands', 'trekreview.md'), 'utf8');
const p = md.slice(md.indexOf('## Phase 4.7'), md.indexOf('## Phase 5'));
assert.match(p, /the\s+fix\s+the\s+engine\s+proposes\s+\(`fix`\)/);
assert.match(p, /never\s+write\s+one\s+the\s+engine\s+did\s+not\s+give/);
});

View file

@ -244,3 +244,21 @@ test('every borrowing in the executor is credited', () => {
}
assert.match(credits, /MIT/);
});
// --- red proof: --plan/--step, and a tests-only step goes up to the plan ------
test('red proof: the executor runs red-proof with --plan/--step and sends a tests_only not-red-at-base up to the plan', () => {
const ex = read(CMD);
assert.match(ex, /red-proof\.mjs --base \{start_sha\} --cmd "\{Verify command\}" --plan "\{plan_path\}" --step \{N\}[^\n]*--json/);
assert.match(ex, /`not-red-at-base`, `tests_only: true`[^\n]*straight up to the plan \(Sub-step F, `fix_rounds = 4`, stop class 4\)/);
// The helper's gate, as the executor describes it, carries the same flags.
assert.match(ex, /red-proof\.mjs --red-only --base \{start_sha\} [^\n]*--plan "\{plan_path\}" --step \{N\}/);
// The other not-red-at-base keeps its fix round.
assert.match(ex, /`not-red-at-base`, otherwise[^\n]*finding for Sub-step F/);
});
test('red proof: the step helper passes --plan/--step to its red gate and does not rewrite a tests_only test', () => {
const h = read(AGENT);
assert.match(h, /red-proof\.mjs --red-only [^\n]*--plan "\{plan\}" --step \{step\}/);
assert.match(h, /`tests_only: true`[\s\S]{0,300}do not rewrite/i);
});

View file

@ -1,5 +1,10 @@
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
validateFindings,
extractFindingsBlock,
@ -7,6 +12,8 @@ import {
FINDING_REQUIRED_FIELDS,
} from '../../lib/review/findings-schema.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
// ---- helpers ----------------------------------------------------------------
function validFinding(over = {}) {
@ -242,3 +249,90 @@ test('validateReviewerOutput — well-formed JSON but schema-invalid surfaces sc
assert.ok(codes(r).includes('FINDING_MISSING_RULE_KEY'),
'parse and schema validation must flow through the same result');
});
// ---- a file:line cited in a reviewer's prose is checked against the file ------
function repoWith33() {
const dir = mkdtempSync(join(tmpdir(), 'voyage-prose-cite-'));
mkdirSync(join(dir, 'tests'));
writeFileSync(join(dir, 'tests', 'x.sh'), Array.from({ length: 33 }, (_, i) => `echo ${i + 1}`).join('\n') + '\n');
return dir;
}
const proseFinding = (over = {}) => ({
id: '0123456789abcdef0123456789abcdef01234567',
severity: 'MAJOR', rule_key: 'UNIMPLEMENTED_CRITERION', file: 'tests/x.sh', line: 3,
title: 'A title', detail: 'A detail.', recommended_action: 'Do one thing.', ...over,
});
test('validateFindings — a finding whose prose cites `tests/x.sh:119` in a 33-line file is refused', () => {
const dir = repoWith33();
try {
const r = validateFindings(
{ reviewer: 'code-correctness-reviewer', findings: [proseFinding({ detail: 'The check at `tests/x.sh:119` relies on git.' })] },
{ repoRoot: dir },
);
assert.equal(r.valid, false);
const e = r.errors.find((x) => x.code === 'FINDING_PROSE_LINE_OUT_OF_RANGE');
assert.ok(e, JSON.stringify(r.errors));
assert.match(e.message, /tests\/x\.sh:119/);
assert.match(e.message, /33 lines/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('validateReviewerOutput — the same citation in prose outside the JSON block is a warning, never silent', () => {
const dir = repoWith33();
try {
const raw = [
'### Risk noted but not raised as a finding',
'',
'`tests/x.sh:119` relies on `git` and on a fixed commit.',
'',
'```json',
JSON.stringify({ reviewer: 'code-correctness-reviewer', findings: [] }),
'```',
].join('\n');
const r = validateReviewerOutput(raw, { repoRoot: dir });
assert.equal(r.valid, true, JSON.stringify(r.errors));
const w = r.warnings.find((x) => x.code === 'FINDING_PROSE_LINE_OUT_OF_RANGE');
assert.ok(w, JSON.stringify(r.warnings));
assert.match(w.message, /reviewer prose cites `?tests\/x\.sh:119/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('validateFindings — a line that exists, a range ending in the file, a file absent from the tree and no repo root all pass', () => {
const dir = repoWith33();
try {
const ok = (detail, opts = { repoRoot: dir }) =>
validateFindings({ reviewer: 'r', findings: [proseFinding({ detail })] }, opts).valid;
assert.equal(ok('See tests/x.sh:33 and tests/x.sh:10-12.'), true);
assert.equal(ok('See src/absent.mjs:900.'), true);
assert.equal(ok('See tests/x.sh:119.', {}), true);
assert.equal(ok('See tests/x.sh:30-40.'), false, 'a range that runs past the end is out of range');
assert.equal(ok('Served on http://localhost:8080 and example.com:443.'), true);
assert.equal(ok('On Windows: C:\\work\\tests\\x.sh:119 and tests\\x.sh:119.'), true, 'a Windows path is not the root\'s file');
assert.equal(ok('See tests/x.sh:30\u201440.'), false, 'an em-dash range is read as a range');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('findings-schema CLI — what the check is given decides: the whole answer warns on a prose citation, the block alone cannot', () => {
const dir = mkdtempSync(join(tmpdir(), 'findings-schema-cli-'));
try {
mkdirSync(join(dir, 'src'));
writeFileSync(join(dir, 'src', 'a.mjs'), 'export const a = 1;\n');
const block = '```json\n{"reviewer":"code-correctness-reviewer","findings":[]}\n```\n';
writeFileSync(join(dir, 'whole.md'), `The bug is at \`src/a.mjs:40\`.\n\n${block}`);
writeFileSync(join(dir, 'block.md'), block);
const cli = (f) => spawnSync(process.execPath,
[join(ROOT, 'lib', 'review', 'findings-schema.mjs'), '--json', '--repo-root', dir, join(dir, f)], { encoding: 'utf8' });
const whole = JSON.parse(cli('whole.md').stdout);
assert.deepEqual(whole.warnings.map((w) => w.code), ['FINDING_PROSE_LINE_OUT_OF_RANGE']);
assert.deepEqual(JSON.parse(cli('block.md').stdout).warnings, []);
} finally { rmSync(dir, { recursive: true, force: true }); }
});

File diff suppressed because it is too large Load diff

View file

@ -1,122 +0,0 @@
// tests/lib/fix-round-4-5b-part-2-scripts.test.mjs
// The form of fix round 4-5b part 2's three shell scripts (plan
// .claude/projects/2026-10-27-fix-round-4-5b-part-2, Step 5h, decision 88):
// the red wrapper, the regression script and the end-to-end run. Untagged on
// purpose and listed in no red wrapper's FILES: these checks are not a part-2
// fix, and they fail at no start point.
// - each script is ASCII only, passes /bin/bash -n and uses no bash 4 form;
// - the red wrapper hands the shared red script exactly one --other-ids,
// whose words are the 4-5a IDS= words followed by part 1's, and --ids
// TOK HDG CKI DLC KI (run from a temp root next to a stub red-round.sh
// that prints its arguments, the method of tests/lib/red-script.test.mjs);
// - with the 4-5a or the part-1 wrapper lacking its IDS= line, the red
// wrapper exits 2 with a setup failure and never runs the shared script.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { childEnv } from '../helpers/child-env.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const RED = 'tests/fix-round-4-5b-part-2-red.sh';
const REGRESSION = 'tests/fix-round-4-5b-part-2-regression.sh';
const E2E = 'tests/e2e-fix-round-4-5b-part-2.sh';
const WRAP_A = 'tests/fix-round-4-5a-red.sh';
const WRAP_B = 'tests/fix-round-4-5b-red.sh';
const PART2_IDS = ['TOK', 'HDG', 'CKI', 'DLC', 'KI'];
/** A repo file's bytes; the file must exist. */
function bytes(rel) {
assert.ok(existsSync(join(ROOT, rel)), `missing file ${rel}`);
return readFileSync(join(ROOT, rel));
}
/** The words of a wrapper's one IDS='...' line. */
function idsOf(rel) {
const lines = bytes(rel).toString('utf8').split('\n').filter((l) => l.startsWith('IDS='));
assert.equal(lines.length, 1, `expected one IDS= line in ${rel}; found ${lines.length}`);
const m = lines[0].match(/^IDS='([^']*)'$/);
assert.ok(m, `the IDS= line of ${rel} is not one single-quoted list: ${lines[0]}`);
return m[1].split(/\s+/).filter(Boolean);
}
test('part-2 scripts: each is ASCII only, passes /bin/bash -n and uses no bash 4 form', () => {
for (const rel of [RED, REGRESSION, E2E]) {
const buf = bytes(rel);
assert.equal(buf.findIndex((b) => b > 127), -1, `${rel} is not ASCII only`);
const r = spawnSync('/bin/bash', ['-n', join(ROOT, rel)], { encoding: 'utf8', env: childEnv() });
assert.equal(r.status, 0, `${rel} does not pass /bin/bash -n: ${r.stderr}`);
const text = buf.toString('utf8');
for (const bash4 of [/\bdeclare -A\b/, /\bmapfile\b/, /\breadarray\b/, /\|&/, /^\s*[A-Za-z_]+=\(/m,
/\$\{[A-Za-z_]+,,\}/, /\$\{[A-Za-z_]+\^\^\}/]) {
assert.doesNotMatch(text, bash4, `${rel} uses ${bash4}, which bash 3.2 lacks`);
}
}
});
/** A stand-in for the shared script: one ARG: line per argument, exit 0. */
const STUB = `#!/bin/bash
for a in "$@"; do printf 'ARG:%s\\n' "$a"; done
exit 0
`;
/** A temp root with the part-2 wrapper, both older wrappers and the stub as tests/helpers/red-round.sh. */
function stubRoot(base) {
const dir = join(base, 'root');
mkdirSync(join(dir, 'tests', 'helpers'), { recursive: true });
for (const rel of [RED, WRAP_A, WRAP_B]) writeFileSync(join(dir, rel), bytes(rel));
writeFileSync(join(dir, 'tests', 'helpers', 'red-round.sh'), STUB, { mode: 0o755 });
return dir;
}
/** The part-2 wrapper run from a temp root: status, output and the arguments the stub saw. */
function runWrapper(dir) {
const r = spawnSync('/bin/bash', [join(dir, RED)], { cwd: tmpdir(), encoding: 'utf8', timeout: 30000, env: childEnv() });
const argv = (r.stdout || '').split('\n').filter((l) => l.startsWith('ARG:')).map((l) => l.slice(4));
return { status: r.status, out: `${r.stdout}${r.stderr}`, stderr: r.stderr || '', argv };
}
test('part-2 scripts: the red wrapper passes one --other-ids, the 4-5a ids then part 1\'s, and its own five ids', () => {
const text = bytes(RED).toString('utf8');
const execLines = text.split('\n').filter((l) => /^exec /.test(l));
assert.equal(execLines.length, 1, `${RED} must hold one exec line; found ${execLines.length}`);
assert.equal(execLines[0].split('--other-ids').length - 1, 1, `${RED}: the exec line must pass --other-ids exactly once: ${execLines[0]}`);
const base = mkdtempSync(join(tmpdir(), 'part2-red-wrap-'));
try {
const r = runWrapper(stubRoot(base));
assert.equal(r.status, 0, `${RED} did not run the shared script:\n${r.out}`);
const at = r.argv.map((a, i) => (a === '--other-ids' ? i : -1)).filter((i) => i >= 0);
assert.equal(at.length, 1, `${RED} handed the shared script --other-ids ${at.length} times:\n${r.out}`);
const other = (r.argv[at[0] + 1] || '').split(/\s+/).filter(Boolean);
assert.deepEqual(other, [...idsOf(WRAP_A), ...idsOf(WRAP_B)],
`${RED}: --other-ids is not the IDS= words of ${WRAP_A} followed by those of ${WRAP_B}\n${r.out}`);
const idsAt = r.argv.indexOf('--ids');
assert.ok(idsAt >= 0, `${RED} passed no --ids:\n${r.out}`);
assert.deepEqual(r.argv[idsAt + 1].split(/\s+/).filter(Boolean), PART2_IDS, `${RED}: --ids is not ${PART2_IDS.join(' ')}\n${r.out}`);
} finally {
rmSync(base, { recursive: true, force: true });
}
});
test('part-2 scripts: the red wrapper exits 2 with a setup failure when an older wrapper has no IDS= line', () => {
for (const missing of [WRAP_A, WRAP_B]) {
const base = mkdtempSync(join(tmpdir(), 'part2-red-noids-'));
try {
const dir = stubRoot(base);
const text = bytes(missing).toString('utf8');
writeFileSync(join(dir, missing), text.split('\n').filter((l) => !l.startsWith('IDS=')).join('\n'));
const r = runWrapper(dir);
assert.equal(r.status, 2, `${RED}: ${missing} without an IDS= line is not exit 2\n${r.out}`);
assert.match(r.stderr, /setup failure/, `${RED}: no setup failure line when ${missing} has no IDS= line\n${r.out}`);
assert.deepEqual(r.argv, [], `${RED} ran the shared script though ${missing} has no IDS= line\n${r.out}`);
} finally {
rmSync(base, { recursive: true, force: true });
}
}
});

View file

@ -1,124 +0,0 @@
// tests/lib/fix-round-4-5b-part-3-scripts.test.mjs
// The form of fix round 4-5b part 3's three shell scripts (plan
// .claude/projects/2026-10-28-fix-round-4-5b-part-3, Step 4c): the red
// wrapper, the regression script and the end-to-end run. Untagged on purpose
// and listed in no red wrapper's FILES: these checks are not a part-3 fix.
// - each script exists, is ASCII only, passes /bin/bash -n and uses no
// bash 4 form;
// - the red wrapper hands the shared red script exactly one --other-ids,
// whose words are the 4-5a IDS= words, then part 1's, then part 2's, and
// --ids CQW PQO KI3 (run from a temp root next to a stub red-round.sh
// that prints its arguments, the method of tests/lib/red-script.test.mjs);
// - with the 4-5a, the part-1 or the part-2 wrapper lacking its IDS= line,
// the red wrapper exits 2 with a setup failure and never runs the shared
// script.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { childEnv } from '../helpers/child-env.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const RED = 'tests/fix-round-4-5b-part-3-red.sh';
const REGRESSION = 'tests/fix-round-4-5b-part-3-regression.sh';
const E2E = 'tests/e2e-fix-round-4-5b-part-3.sh';
const WRAP_A = 'tests/fix-round-4-5a-red.sh';
const WRAP_B = 'tests/fix-round-4-5b-red.sh';
const WRAP_C = 'tests/fix-round-4-5b-part-2-red.sh';
const PART3_IDS = ['CQW', 'PQO', 'KI3'];
/** A repo file's bytes; the file must exist. */
function bytes(rel) {
assert.ok(existsSync(join(ROOT, rel)), `missing file ${rel}`);
return readFileSync(join(ROOT, rel));
}
/** The words of a wrapper's one IDS='...' line. */
function idsOf(rel) {
const lines = bytes(rel).toString('utf8').split('\n').filter((l) => l.startsWith('IDS='));
assert.equal(lines.length, 1, `expected one IDS= line in ${rel}; found ${lines.length}`);
const m = lines[0].match(/^IDS='([^']*)'$/);
assert.ok(m, `the IDS= line of ${rel} is not one single-quoted list: ${lines[0]}`);
return m[1].split(/\s+/).filter(Boolean);
}
test('part-3 scripts: each exists, is ASCII only, passes /bin/bash -n and uses no bash 4 form', () => {
for (const rel of [RED, REGRESSION, E2E]) {
const buf = bytes(rel);
assert.equal(buf.findIndex((b) => b > 127), -1, `${rel} is not ASCII only`);
const r = spawnSync('/bin/bash', ['-n', join(ROOT, rel)], { encoding: 'utf8', env: childEnv() });
assert.equal(r.status, 0, `${rel} does not pass /bin/bash -n: ${r.stderr}`);
const text = buf.toString('utf8');
for (const bash4 of [/\bdeclare -A\b/, /\bmapfile\b/, /\breadarray\b/, /\|&/, /^\s*[A-Za-z_]+=\(/m,
/\$\{[A-Za-z_]+,,\}/, /\$\{[A-Za-z_]+\^\^\}/]) {
assert.doesNotMatch(text, bash4, `${rel} uses ${bash4}, which bash 3.2 lacks`);
}
}
});
/** A stand-in for the shared script: one ARG: line per argument, exit 0. */
const STUB = `#!/bin/bash
for a in "$@"; do printf 'ARG:%s\\n' "$a"; done
exit 0
`;
/** A temp root with the part-3 wrapper, the three older wrappers and the stub as tests/helpers/red-round.sh. */
function stubRoot(base) {
const dir = join(base, 'root');
mkdirSync(join(dir, 'tests', 'helpers'), { recursive: true });
for (const rel of [RED, WRAP_A, WRAP_B, WRAP_C]) writeFileSync(join(dir, rel), bytes(rel));
writeFileSync(join(dir, 'tests', 'helpers', 'red-round.sh'), STUB, { mode: 0o755 });
return dir;
}
/** The part-3 wrapper run from a temp root: status, output and the arguments the stub saw. */
function runWrapper(dir) {
const r = spawnSync('/bin/bash', [join(dir, RED)], { cwd: tmpdir(), encoding: 'utf8', timeout: 30000, env: childEnv() });
const argv = (r.stdout || '').split('\n').filter((l) => l.startsWith('ARG:')).map((l) => l.slice(4));
return { status: r.status, out: `${r.stdout}${r.stderr}`, stderr: r.stderr || '', argv };
}
test('part-3 scripts: the red wrapper passes one --other-ids, the 4-5a, part-1 and part-2 ids in order, and its own three ids', () => {
const text = bytes(RED).toString('utf8');
const execLines = text.split('\n').filter((l) => /^exec /.test(l));
assert.equal(execLines.length, 1, `${RED} must hold one exec line; found ${execLines.length}`);
assert.equal(execLines[0].split('--other-ids').length - 1, 1, `${RED}: the exec line must pass --other-ids exactly once: ${execLines[0]}`);
const base = mkdtempSync(join(tmpdir(), 'part3-red-wrap-'));
try {
const r = runWrapper(stubRoot(base));
assert.equal(r.status, 0, `${RED} did not run the shared script:\n${r.out}`);
const at = r.argv.map((a, i) => (a === '--other-ids' ? i : -1)).filter((i) => i >= 0);
assert.equal(at.length, 1, `${RED} handed the shared script --other-ids ${at.length} times:\n${r.out}`);
const other = (r.argv[at[0] + 1] || '').split(/\s+/).filter(Boolean);
assert.deepEqual(other, [...idsOf(WRAP_A), ...idsOf(WRAP_B), ...idsOf(WRAP_C)],
`${RED}: --other-ids is not the IDS= words of ${WRAP_A}, then ${WRAP_B}, then ${WRAP_C}\n${r.out}`);
const idsAt = r.argv.indexOf('--ids');
assert.ok(idsAt >= 0, `${RED} passed no --ids:\n${r.out}`);
assert.deepEqual(r.argv[idsAt + 1].split(/\s+/).filter(Boolean), PART3_IDS, `${RED}: --ids is not ${PART3_IDS.join(' ')}\n${r.out}`);
} finally {
rmSync(base, { recursive: true, force: true });
}
});
test('part-3 scripts: the red wrapper exits 2 with a setup failure when an older wrapper has no IDS= line', () => {
for (const missing of [WRAP_A, WRAP_B, WRAP_C]) {
const base = mkdtempSync(join(tmpdir(), 'part3-red-noids-'));
try {
const dir = stubRoot(base);
const text = bytes(missing).toString('utf8');
writeFileSync(join(dir, missing), text.split('\n').filter((l) => !l.startsWith('IDS=')).join('\n'));
const r = runWrapper(dir);
assert.equal(r.status, 2, `${RED}: ${missing} without an IDS= line is not exit 2\n${r.out}`);
assert.match(r.stderr, /setup failure/, `${RED}: no setup failure line when ${missing} has no IDS= line\n${r.out}`);
assert.deepEqual(r.argv, [], `${RED} ran the shared script though ${missing} has no IDS= line\n${r.out}`);
} finally {
rmSync(base, { recursive: true, force: true });
}
}
});

View file

@ -1,149 +0,0 @@
// tests/lib/fix-round-4-5b-part-4-prose.test.mjs
// Prose pins for fix round 4-5b, part 4 (plan
// .claude/projects/2026-10-31-fix-round-4-5b-part-4, decision 90): the fixes
// that are text a session reads. The tag in brackets is the part-4 id from the
// round's brief; the tagged test fails on an assertion at 591f3c4, part 3's end
// point. The keep-behaviour checks sit inside the tagged test as "Control:"
// assertions.
// KI4 the CHANGELOG lists part 4's fixes (CSG, SLT, ESC, CTB) under ### Fixed,
// its door under ### Doors, the review's other findings under
// ### Known issues, and the TOK headline no longer claims `never`.
// NVR the [TOK] test title and part 2's end-to-end case name no longer claim
// `never`.
// No static import of a new export: the file must load at 591f3c4.
// Fix the SOURCE a pin guards, not the pin.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const readCache = new Map();
/** A repo file's text; the file must exist. Read from disk once per run. */
function read(rel) {
if (!readCache.has(rel)) {
assert.ok(existsSync(join(ROOT, rel)), `missing file ${rel}`);
readCache.set(rel, readFileSync(join(ROOT, rel), 'utf8'));
}
return readCache.get(rel);
}
/** Whitespace (line breaks too) collapsed to one space. */
const flat = (s) => s.replace(/\s+/g, ' ').trim();
// --- KI4: the round's CHANGELOG entry ----------------------------------------------
// Part 4 stands in the same Unreleased section as parts 1 to 3. The section is
// found by its title, by the rule tests/lib/fix-round-4-5b-part-3-prose.test.mjs
// uses (copied, not imported): `## Unreleased — <title>` before the release,
// `## vX.Y.Z — <date> — <title>` after it, exactly once, up to the next `## `.
const CHANGELOG_TITLE = "The annotating skill, the run coupling and P10's model-free part";
const CHANGELOG_HEADING = new RegExp(`^## (?:Unreleased|v\\d+\\.\\d+\\.\\d+ — \\d{4}-\\d{2}-\\d{2}) — ${CHANGELOG_TITLE.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`);
/** The lines of the round's CHANGELOG section, heading excluded. */
function changelogSection() {
const lines = read('CHANGELOG.md').split('\n');
const at = [];
lines.forEach((l, i) => { if (CHANGELOG_HEADING.test(l.replace(/\s+$/, ''))) at.push(i); });
assert.equal(at.length, 1, `CHANGELOG.md must hold the "${CHANGELOG_TITLE}" section exactly once (found ${at.length})`);
let end = lines.length;
for (let i = at[0] + 1; i < lines.length; i++) if (/^## /.test(lines[i])) { end = i; break; }
return lines.slice(at[0] + 1, end);
}
/** The bullets of one `### ` subsection of the round's CHANGELOG section, each flattened to one line. */
function changelogBullets(heading) {
const lines = changelogSection();
const s = lines.findIndex((l) => l.replace(/\s+$/, '') === heading);
assert.ok(s !== -1, `the "${CHANGELOG_TITLE}" section of CHANGELOG.md must have "${heading}"`);
const out = [];
for (const l of lines.slice(s + 1)) {
if (/^### /.test(l)) break;
if (/^- /.test(l)) out.push(l);
else if (out.length && l.trim() !== '' && /^\s/.test(l)) out[out.length - 1] += ` ${l}`;
else if (l.trim() !== '') out.push('');
}
return out.filter((b) => b !== '').map(flat);
}
// The review's four MAJOR findings part 4 fixes, with their tags, and the
// review's other findings it lists as open; the ids are the review's, written
// out here.
const FIXED = [['(CSG)', '3a34adbe'], ['(SLT)', 'ad8ad828'], ['(ESC)', '797784e0'], ['(CTB)', '1658fc64']];
const OPEN_IDS = ['4a880f70', 'fb643fb0', '73eed642', '6f09b931', '161545f6', 'fe3fe55b'];
const NEVER = /\bnever\b/i;
test('[KI4] the CHANGELOG lists part 4\'s four fixes, the Doors line and a TOK headline without never', () => {
const fixed = changelogBullets('### Fixed');
// Red: one ### Fixed bullet per part-4 tag, each naming its review finding.
for (const [tag, id] of FIXED) {
const hits = fixed.filter((b) => b.includes(tag) && b.includes(id));
assert.equal(hits.length, 1, `### Fixed must hold exactly one bullet marked ${tag} naming ${id}; found ${hits.length}`);
}
// Red: a door for the round.
const doors = changelogBullets('### Doors').filter((b) => b.includes('decision 90'));
assert.equal(doors.length, 1, `### Doors must hold one bullet naming decision 90; found ${doors.length}`);
// Control: exactly one Fixed bullet is marked (TOK).
const tok = fixed.filter((b) => b.includes('(TOK)'));
assert.equal(tok.length, 1, `Control: ### Fixed must hold exactly one bullet marked (TOK); found ${tok.length}`);
// Red: its bold headline no longer claims `never`.
const head = /^- \*\*(.+?)\*\*/.exec(tok[0]);
assert.ok(head, `the ### Fixed bullet marked (TOK) must open with a bold headline: ${tok[0]}`);
assert.ok(!NEVER.test(head[1]), `the (TOK) headline must not hold the word never: ${head[1]}`);
// Control: no fixed finding is listed as open.
const known = changelogBullets('### Known issues');
for (const [, id] of FIXED) {
assert.deepEqual(known.filter((b) => b.includes(id)), [], `Control: the fixed finding ${id} must not stand under ### Known issues`);
}
});
test('[KI4] the CHANGELOG lists the review\'s other findings as known issues', () => {
const known = changelogBullets('### Known issues');
// Red: each open finding has exactly one Known-issues bullet, and that bullet names decision 90.
for (const id of OPEN_IDS) {
const hits = known.filter((b) => b.includes(id));
assert.equal(hits.length, 1, `### Known issues must hold exactly one bullet naming ${id}; found ${hits.length}`);
assert.ok(hits[0].includes('decision 90'), `the Known-issues bullet naming ${id} does not say decision 90: ${hits[0]}`);
}
// Red: the tokenizer bullet names review finding 564d6dff.
const tok = known.filter((b) => b.includes("'90s"));
assert.equal(tok.length, 1, `### Known issues must hold exactly one bullet holding '90s; found ${tok.length}`);
assert.ok(tok[0].includes('564d6dff'), `the tokenizer Known-issues bullet must name review finding 564d6dff: ${tok[0]}`);
// Control: the tokenizer bullet keeps what part 3 pinned.
for (const part of ['say "hi"', 'x --since ', '2026-10-27', '2026-10-28']) {
assert.ok(tok[0].includes(part), `Control: the tokenizer Known-issues bullet must hold ${JSON.stringify(part)}: ${tok[0]}`);
}
for (const gone of ['"a "', 'accepted these']) {
assert.ok(!tok[0].includes(gone), `Control: the tokenizer Known-issues bullet must not hold ${JSON.stringify(gone)}: ${tok[0]}`);
}
});
// --- NVR: the [TOK] test title and part 2's case name ------------------------------
test('[NVR] the [TOK] test title and part 2\'s case name no longer claim never', () => {
// The one test title in the tokenizer tests that starts with [TOK], read as text.
const titles = [...read('tests/lib/arg-parser-tokenize.test.mjs').matchAll(/test\('\[TOK\] ([^']*)'/g)].map((m) => m[1]);
assert.equal(titles.length, 1, `tests/lib/arg-parser-tokenize.test.mjs must hold exactly one test title starting with [TOK]; found ${titles.length}`);
assert.ok(!NEVER.test(titles[0]), `the [TOK] test title must not hold the word never: ${titles[0]}`);
// The first name=" line after `case_tok() {` in part 2's end-to-end script.
const lines = read('tests/e2e-fix-round-4-5b-part-2.sh').split('\n');
const starts = lines.map((l, i) => (l.trim() === 'case_tok() {' ? i : -1)).filter((i) => i !== -1);
assert.equal(starts.length, 1, `tests/e2e-fix-round-4-5b-part-2.sh must define case_tok() exactly once; found ${starts.length}`);
const names = lines.slice(starts[0] + 1).filter((l) => /^\s*name="/.test(l));
assert.ok(names.length >= 1, 'case_tok() in tests/e2e-fix-round-4-5b-part-2.sh must set name=');
assert.ok(!NEVER.test(names[0]), `the name= line of case_tok() must not hold the word never: ${names[0]}`);
});

View file

@ -1,139 +0,0 @@
// tests/lib/fix-round-4-5b-part-4-scripts.test.mjs
// The form of fix round 4-5b part 4's shell scripts and mutation module (plan
// .claude/projects/2026-10-31-fix-round-4-5b-part-4, Step 4d): the red
// wrapper, the regression script, the end-to-end run and the mutations.
// Untagged on purpose and listed in no red wrapper's FILES: these checks are
// not a part-4 fix.
// - each script exists, is ASCII only, passes /bin/bash -n and uses no
// bash 4 form;
// - the red wrapper hands the shared red script exactly one --other-ids,
// whose words are the 4-5a IDS= words, then part 1's, then part 2's, then
// part 3's, and --ids CSG SLT ESC CTB KI4 NVR (run from a temp root next
// to a stub red-round.sh that prints its arguments, the method of
// tests/lib/red-script.test.mjs);
// - with the 4-5a, the part-1, the part-2 or the part-3 wrapper lacking its
// IDS= line, the red wrapper exits 2 with a setup failure and never runs
// the shared script;
// - red-mutate.mjs list prints exactly the three part-4 mutations.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { childEnv } from '../helpers/child-env.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const RED = 'tests/fix-round-4-5b-part-4-red.sh';
const REGRESSION = 'tests/fix-round-4-5b-part-4-regression.sh';
const E2E = 'tests/e2e-fix-round-4-5b-part-4.sh';
const MUTATIONS = 'tests/fix-round-4-5b-part-4-mutations.mjs';
const WRAP_A = 'tests/fix-round-4-5a-red.sh';
const WRAP_B = 'tests/fix-round-4-5b-red.sh';
const WRAP_C = 'tests/fix-round-4-5b-part-2-red.sh';
const WRAP_D = 'tests/fix-round-4-5b-part-3-red.sh';
const PART4_IDS = ['CSG', 'SLT', 'ESC', 'CTB', 'KI4', 'NVR'];
/** A repo file's bytes; the file must exist. */
function bytes(rel) {
assert.ok(existsSync(join(ROOT, rel)), `missing file ${rel}`);
return readFileSync(join(ROOT, rel));
}
/** The words of a wrapper's one IDS='...' line. */
function idsOf(rel) {
const lines = bytes(rel).toString('utf8').split('\n').filter((l) => l.startsWith('IDS='));
assert.equal(lines.length, 1, `expected one IDS= line in ${rel}; found ${lines.length}`);
const m = lines[0].match(/^IDS='([^']*)'$/);
assert.ok(m, `the IDS= line of ${rel} is not one single-quoted list: ${lines[0]}`);
return m[1].split(/\s+/).filter(Boolean);
}
test('part-4 scripts: each exists, is ASCII only, passes /bin/bash -n and uses no bash 4 form', () => {
for (const rel of [RED, REGRESSION, E2E]) {
const buf = bytes(rel);
assert.equal(buf.findIndex((b) => b > 127), -1, `${rel} is not ASCII only`);
const r = spawnSync('/bin/bash', ['-n', join(ROOT, rel)], { encoding: 'utf8', env: childEnv() });
assert.equal(r.status, 0, `${rel} does not pass /bin/bash -n: ${r.stderr}`);
const text = buf.toString('utf8');
for (const bash4 of [/\bdeclare -A\b/, /\bmapfile\b/, /\breadarray\b/, /\|&/, /^\s*[A-Za-z_]+=\(/m,
/\$\{[A-Za-z_]+,,\}/, /\$\{[A-Za-z_]+\^\^\}/]) {
assert.doesNotMatch(text, bash4, `${rel} uses ${bash4}, which bash 3.2 lacks`);
}
}
});
/** A stand-in for the shared script: one ARG: line per argument, exit 0. */
const STUB = `#!/bin/bash
for a in "$@"; do printf 'ARG:%s\\n' "$a"; done
exit 0
`;
/** A temp root with the part-4 wrapper, the four older wrappers and the stub as tests/helpers/red-round.sh. */
function stubRoot(base) {
const dir = join(base, 'root');
mkdirSync(join(dir, 'tests', 'helpers'), { recursive: true });
for (const rel of [RED, WRAP_A, WRAP_B, WRAP_C, WRAP_D]) writeFileSync(join(dir, rel), bytes(rel));
writeFileSync(join(dir, 'tests', 'helpers', 'red-round.sh'), STUB, { mode: 0o755 });
return dir;
}
/** The part-4 wrapper run from a temp root: status, output and the arguments the stub saw. */
function runWrapper(dir) {
const r = spawnSync('/bin/bash', [join(dir, RED)], { cwd: tmpdir(), encoding: 'utf8', timeout: 30000, env: childEnv() });
const argv = (r.stdout || '').split('\n').filter((l) => l.startsWith('ARG:')).map((l) => l.slice(4));
return { status: r.status, out: `${r.stdout}${r.stderr}`, stderr: r.stderr || '', argv };
}
test('part-4 scripts: the red wrapper passes one --other-ids, the 4-5a, part-1, part-2 and part-3 ids in order, and its own six ids', () => {
const text = bytes(RED).toString('utf8');
const execLines = text.split('\n').filter((l) => /^exec /.test(l));
assert.equal(execLines.length, 1, `${RED} must hold one exec line; found ${execLines.length}`);
assert.equal(execLines[0].split('--other-ids').length - 1, 1, `${RED}: the exec line must pass --other-ids exactly once: ${execLines[0]}`);
const base = mkdtempSync(join(tmpdir(), 'part4-red-wrap-'));
try {
const r = runWrapper(stubRoot(base));
assert.equal(r.status, 0, `${RED} did not run the shared script:\n${r.out}`);
const at = r.argv.map((a, i) => (a === '--other-ids' ? i : -1)).filter((i) => i >= 0);
assert.equal(at.length, 1, `${RED} handed the shared script --other-ids ${at.length} times:\n${r.out}`);
const other = (r.argv[at[0] + 1] || '').split(/\s+/).filter(Boolean);
assert.deepEqual(other, [...idsOf(WRAP_A), ...idsOf(WRAP_B), ...idsOf(WRAP_C), ...idsOf(WRAP_D)],
`${RED}: --other-ids is not the IDS= words of ${WRAP_A}, then ${WRAP_B}, then ${WRAP_C}, then ${WRAP_D}\n${r.out}`);
const idsAt = r.argv.indexOf('--ids');
assert.ok(idsAt >= 0, `${RED} passed no --ids:\n${r.out}`);
assert.deepEqual(r.argv[idsAt + 1].split(/\s+/).filter(Boolean), PART4_IDS, `${RED}: --ids is not ${PART4_IDS.join(' ')}\n${r.out}`);
} finally {
rmSync(base, { recursive: true, force: true });
}
});
test('part-4 scripts: the red wrapper exits 2 with a setup failure when an older wrapper has no IDS= line', () => {
for (const missing of [WRAP_A, WRAP_B, WRAP_C, WRAP_D]) {
const base = mkdtempSync(join(tmpdir(), 'part4-red-noids-'));
try {
const dir = stubRoot(base);
const text = bytes(missing).toString('utf8');
writeFileSync(join(dir, missing), text.split('\n').filter((l) => !l.startsWith('IDS=')).join('\n'));
const r = runWrapper(dir);
assert.equal(r.status, 2, `${RED}: ${missing} without an IDS= line is not exit 2\n${r.out}`);
assert.match(r.stderr, /setup failure/, `${RED}: no setup failure line when ${missing} has no IDS= line\n${r.out}`);
assert.deepEqual(r.argv, [], `${RED} ran the shared script though ${missing} has no IDS= line\n${r.out}`);
} finally {
rmSync(base, { recursive: true, force: true });
}
}
});
test('part-4 scripts: red-mutate.mjs list prints exactly the three part-4 mutations', () => {
assert.ok(existsSync(join(ROOT, MUTATIONS)), `missing file ${MUTATIONS}`);
const r = spawnSync(process.execPath, [join(ROOT, 'tests', 'helpers', 'red-mutate.mjs'), 'list', MUTATIONS],
{ cwd: ROOT, encoding: 'utf8', timeout: 30000, env: childEnv() });
assert.equal(r.status, 0, `red-mutate.mjs list ${MUTATIONS} exited ${r.status}:\n${r.stdout}${r.stderr}`);
const lines = (r.stdout || '').split('\n').filter((l) => l !== '');
assert.deepEqual(lines, ['CTB-catch CTB', 'NVR-title NVR', 'NVR-e2e NVR'],
`red-mutate.mjs list ${MUTATIONS} does not print exactly the three part-4 mutations:\n${r.stdout}${r.stderr}`);
});

View file

@ -1,602 +0,0 @@
// tests/lib/fix-round-4-5b-prose.test.mjs
// Prose pins for fix round 4-5b (plan
// .claude/projects/2026-10-25-fix-round-4-5b-findings): the fixes that are
// text a session or a helper reads. The tags in brackets are 4.5b ids from the
// round's brief (for example `[M4]`, `[M11]`); each tagged test fails on an
// assertion at be0b631, the round's start point. A sibling's test is tagged
// `[<parent id>] sibling:`.
// M4 A git call the verdict gate cannot complete is reported as
// `review-not-measured:`, never as a stale review: Handover 6 names the
// prefix and lists review_version 1.1 as current, and /trekexecute's
// main-merge gate maps it to "not measured" with the merge locked.
// M11 Handover 6 states that only a full 40- or 64-hex reviewed commit is read.
// E22e The park tool parks every file new in the attempt's diff, untracked or
// staged but not in HEAD: its header and /trekexecute's resume paragraph
// no longer say "still untracked".
// M3 The "Back to planning" save line runs its git diff with --no-relative,
// so a session in a subdirectory with diff.relative on saves every file.
// M9 The review coordinator's Pass 2 text says what the lib does: a root
// escape or a negative line refutes a finding; any other line number
// alone never does, and a line past the end goes to `unverified`.
// M2 The four flag-table summaries of `--quick` (trekreview, orchestrator,
// command-modes, README) name the brief-conflict rule as the exception
// to the Pass 3 skip.
// K3 The orchestrator's 4-pass item 3 names the brief-conflict lowering as
// the exception to the catalogue reset.
// ARG /trekendsession and /trekcontinue Phase 0 and /trekreview Phase 1
// give $ARGUMENTS to the arg parser (`--stdin`) as the one body line of
// a quoted heredoc, never as shell words.
// M8 /trekcontinue and /trekendsession Phase 0 stop on the parser's exit 1
// with its `errors`, and stop when the parser prints no JSON.
// M10 /trekendsession Phase 2 reads the label from the parser's `tail`.
// M13 /trekbrief's research_topics = 0 exit runs Phase 7 with
// `auto_result: manual`.
// K2 /trekbrief's Step 6a "Cancel — do manual" row runs Phase 7 with
// `auto_result: cancelled`, like the sentence after the table.
// E14 /trekexecute keeps `run_seen` in the progress file (template, field
// table, Phase 7) and Phase 8 reads it from there, so the resume and
// multi-session paths report it; Handover 5 lists it as optional.
// DJ4 /trekexecute's Phase 7.2 step 3 commits only the review's paths git
// does not ignore, and says an ignored one is left uncommitted.
// E17a The two /trekresearch wave 2 sentences on outbound queries are pinned
// exactly (red under the E17a mutation).
// E17b The deep-research adapter says where wave 2's contrarian findings merge.
// Da The README deep-research parenthesis says "when the engine is active".
// E18a The F9 deep-review and summary-only rows are pinned exactly (red under
// the E18a mutation).
// Db No README line stars security-researcher (red under the Db mutation).
// M1 No test name in the 4.5a prose file starts with `found in execution:`;
// the tests f6bdc2d added carry their 4.5a parent tag (red under the
// M1 mutation, which puts the names back).
// CHANGELOG (the round's entry in the existing Unreleased section):
// K4 the siblings entry says the commands read only the two-token
// `--gates <value>` form and that `--gates=true` is the gates shim's own.
// M4 the merge-lock entry says a failed git call is reported as
// `review-not-measured:`, not `review-stale`.
// M2 ### Fixed records the brief-conflict exception in the four `--quick`
// summaries.
// ARG ### Fixed names the Bash-guard block on a parser heredoc that closes
// early (R6), and ### Changed says `~` and `$HOME` in the arguments are
// no longer expanded.
// No static import of a new export: the file must load at be0b631.
// Fix the SOURCE a pin guards, not the pin.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const readCache = new Map();
/** A repo file's text; the file must exist. Read from disk once per run. */
function read(rel) {
if (!readCache.has(rel)) {
assert.ok(existsSync(join(ROOT, rel)), `missing file ${rel}`);
readCache.set(rel, readFileSync(join(ROOT, rel), 'utf8'));
}
return readCache.get(rel);
}
/** Whitespace (line breaks too) collapsed to one space. */
const flat = (s) => s.replace(/\s+/g, ' ').trim();
/** The text from the line holding `start` up to (not including) the next line holding `stop`; both must exist. */
function between(rel, start, stop) {
const text = read(rel);
const s = text.indexOf(start);
assert.ok(s !== -1, `${rel} must hold "${start}"`);
const e = text.indexOf(stop, s + start.length);
assert.ok(e !== -1, `${rel} must hold "${stop}" after "${start}"`);
return text.slice(s, e);
}
/** Handover 6 of the handover contracts, up to Handover 7. */
const handover6 = () => between('docs/HANDOVER-CONTRACTS.md', '## Handover 6 ', '## Handover 7 ');
/** The one table row of Handover 6 whose first cell is `field`. */
function fieldRow(field) {
const rows = handover6().split('\n').filter((l) => l.startsWith(`| \`${field}\` |`));
assert.equal(rows.length, 1, `Handover 6 must hold exactly one \`${field}\` row; found ${rows.length}`);
return rows[0];
}
/** The verdict-gate paragraph of Handover 6 (the "Second consumer" one), flattened. */
function gateParagraph() {
const para = handover6().split('\n').filter((l) => l.startsWith('**Second consumer — the verdict gate'));
assert.equal(para.length, 1, `Handover 6 must hold one verdict-gate paragraph; found ${para.length}`);
return flat(para[0]);
}
/** Every sentence of `text` (flattened) that holds `needle`. */
function sentencesWith(text, needle) {
return flat(text).split(/(?<=\.)\s+(?=[A-Z`"*(])/).filter((s) => s.includes(needle));
}
// --- M4 ----------------------------------------------------------------------------
test('[M4] Handover 6 names the review-not-measured reason', () => {
const hits = sentencesWith(gateParagraph(), '`review-not-measured:`');
assert.ok(hits.length > 0, 'the verdict-gate paragraph of Handover 6 does not name the `review-not-measured:` reason prefix');
assert.ok(hits.some((s) => /\bgit\b/.test(s) && /never green|not measured/.test(s)),
`no sentence ties \`review-not-measured:\` to a failed git call read as not measured: ${hits.join(' | ')}`);
});
test('[M4] Handover 6 lists review_version 1.1 as current', () => {
const row = fieldRow('review_version');
assert.match(row, /"1\.2"`? \(current\)/, `the review_version row does not list 1.2 as current: ${row}`);
assert.doesNotMatch(row, /"1\.0"`? \(current\)/, `the review_version row still lists 1.0 as current: ${row}`);
});
test('[M4] the main merge gate maps review-not-measured to not measured', () => {
const gate = between('commands/trekexecute.md', '**1. The verdict.**', '**2. Open:');
const hits = sentencesWith(gate, '`review-not-measured:`');
assert.ok(hits.length > 0, 'the main-merge gate (Phase 8, 1. The verdict) does not name the `review-not-measured:` reason prefix');
const ok = hits.find((s) => {
const rest = s.split('`review-not-measured:`').join('');
return /"not measured"|\bnot measured\b/.test(rest) && /never green/.test(rest) && /merge stays (locked|closed)/.test(rest);
});
assert.ok(ok, `no sentence says a \`review-not-measured:\` reason is reported as "not measured", never green, with the merge locked: ${hits.join(' | ')}`);
});
// --- M11 ---------------------------------------------------------------------------
test('[M11] Handover 6 states the full 40- or 64-hex reviewed commit', () => {
const row = fieldRow('scope_sha_end');
assert.match(row, /full 40- or 64-hex/, `the scope_sha_end row does not state the full 40- or 64-hex commit id: ${row}`);
assert.match(gateParagraph(), /`scope_sha_end`[^.]*full 40- or 64-hex/,
'the verdict-gate paragraph of Handover 6 does not state that only a full 40- or 64-hex scope_sha_end is read');
});
// --- E22e --------------------------------------------------------------------------
/** The sentences of `text` (flattened) that say what the park tool moves or parks. */
function parkSentences(text) {
return flat(text).split(/(?<=\.)\s+(?=[A-Z`"*(])/).filter((s) => /\b(moves|parks)\b/.test(s) && /\bfile\b/.test(s));
}
test('[E22e] the park header says untracked or staged but not in HEAD', () => {
const src = read('lib/util/park-attempt-files.mjs');
const header = src.slice(0, src.indexOf('\nimport '));
assert.ok(header.length > 0, 'lib/util/park-attempt-files.mjs has no header before its imports');
const comment = header.split('\n').map((l) => l.replace(/^\/\/ ?/, '')).join('\n');
assert.doesNotMatch(flat(comment), /still untracked/, 'the park header still says the tool parks only files "still untracked"');
const hits = parkSentences(comment);
assert.ok(hits.some((s) => /new in the attempt's diff/.test(s) && /untracked, or staged but not in HEAD/.test(s)),
`no header sentence says every file new in the attempt's diff is parked, untracked, or staged but not in HEAD: ${hits.join(' | ')}`);
});
test('[E22e] the execute command says the same', () => {
const para = between('commands/trekexecute.md', 'A step at `fix_rounds` 4 on `--resume` was re-planned', 'Then launch a fresh');
assert.doesNotMatch(flat(para), /still untracked/, 'the /trekexecute resume paragraph still says the tool moves only files "still untracked"');
const hits = parkSentences(para);
assert.ok(hits.some((s) => /new in the attempt's diff/.test(s) && /untracked, or staged but not in HEAD/.test(s)),
`no /trekexecute sentence says every file new in the attempt's diff is parked, untracked, or staged but not in HEAD: ${hits.join(' | ')}`);
});
// --- M3 ----------------------------------------------------------------------------
test('[M3] the back-to-planning save line carries --no-relative', () => {
const lines = read('commands/trekexecute.md').split('\n').filter((l) => l.startsWith('**Back to planning** (`fix_rounds = 4`): save `'));
assert.equal(lines.length, 1, `expected one "Back to planning" save line; found ${lines.length}`);
const m = lines[0].match(/save `([^`]+)`/);
assert.ok(m, `no backticked command on the save line: ${lines[0]}`);
assert.match(m[1], /^git (?:-c \S+ )*diff (?:--\S+ )*--no-relative (?:--\S+ )*\{start_sha\}$/,
`the save line's git diff does not carry --no-relative: ${m[1]}`);
});
// --- M9 ----------------------------------------------------------------------------
test('[M9] the coordinator text does not say a line number alone never refutes', () => {
const coord = flat(read('agents/review-coordinator.md'));
assert.doesNotMatch(coord, /\bA line number alone never refutes/,
'agents/review-coordinator.md still says a line number alone never refutes, next to a negative line that does');
const p2 = between('agents/review-coordinator.md', '### Pass 2 — HubSpot Judge filters', '### Pass 3 — ');
assert.ok(sentencesWith(p2, 'a citation that escapes the repo root, or a negative line, refutes the finding').length > 0,
'Pass 2 no longer says a root escape or a negative line refutes the finding');
const hits = sentencesWith(p2, 'line number alone never refutes');
assert.ok(hits.some((s) => /\b[Aa]ny other line number alone never refutes\b/.test(s)
&& /`unverified` \(`accuracy:line-out-of-range`\)/.test(s)),
`no Pass 2 sentence says any other line number alone never refutes and goes to \`unverified\`: ${hits.join(' | ')}`);
});
// --- M2 ----------------------------------------------------------------------------
/** The one line of `rel` that starts with `prefix` and holds `needle`. */
function oneLine(rel, prefix, needle) {
const hits = read(rel).split('\n').filter((l) => l.startsWith(prefix) && l.includes(needle));
assert.equal(hits.length, 1, `${rel} must hold exactly one line starting "${prefix}" with "${needle}"; found ${hits.length}`);
return hits[0];
}
const BRIEF_CONFLICT_SKIP = /the coordinator's reasonableness filter,? except the brief-conflict rule\b/;
test('[M2] each of the four flag-table summaries names the brief-conflict exception', () => {
const summaries = [
['commands/trekreview.md --quick row', oneLine('commands/trekreview.md', '| `--quick` |', 'reasonableness filter')],
['agents/review-orchestrator.md quick mode', flat(between('agents/review-orchestrator.md', '- `quick` — ', '- `validate` — '))],
['docs/command-modes.md --quick row', oneLine('docs/command-modes.md', '| `--quick` |', 'reasonableness filter')],
['README.md Quick row', oneLine('README.md', '| **Quick** | `/trekreview ', 'reasonableness filter')],
];
for (const [where, text] of summaries) {
assert.match(text, BRIEF_CONFLICT_SKIP, `${where} skips the reasonableness filter without the brief-conflict exception: ${text}`);
}
});
// --- K3 ----------------------------------------------------------------------------
test('[K3] orchestrator item 3 names the brief-conflict lowering as the exception to the reset', () => {
const item = flat(between('agents/review-orchestrator.md', '3. **Cloudflare reasonableness**', '4. **Compute verdict**'));
const at = item.indexOf('reset a severity that does not match its catalogue tier.');
assert.ok(at !== -1, `item 3 no longer resets a severity to its catalogue tier: ${item}`);
const next = sentencesWith(item.slice(at + 'reset a severity that does not match its catalogue tier.'.length), ' ')[0] ?? '';
assert.match(next, /\bexception to (the|this) reset\b/, `the sentence after the reset does not name an exception to it: ${next}`);
assert.match(next, /brief-conflict lowering/, `the exception to the reset is not the brief-conflict lowering: ${next}`);
assert.match(next, /MAJOR[^.]*\bMINOR\b/, `the exception does not say a MAJOR drops to MINOR: ${next}`);
assert.match(next, /`brief_lowered_from: MAJOR`/, `the exception does not name brief_lowered_from: MAJOR: ${next}`);
});
// --- ARG / M8 / M10 ----------------------------------------------------------------
const ARGS_DELIM = 'VOYAGE_ARGS_EOF_7F3C9E';
const ARGS_OPENER = `<<'${ARGS_DELIM}'`;
/** The text of a `## ` section of `rel` from the line starting with `heading` to the next `## ` line outside a fence. */
function phase(rel, heading) {
const lines = read(rel).split('\n');
const s = lines.findIndex((l) => l.startsWith(heading));
assert.ok(s !== -1, `${rel} must hold a heading starting "${heading}"`);
let fenced = false;
for (let i = s + 1; i < lines.length; i++) {
if (/^\s{0,3}(`{3,}|~{3,})/.test(lines[i])) fenced = !fenced;
if (!fenced && lines[i].startsWith('## ')) return lines.slice(s, i).join('\n');
}
return lines.slice(s).join('\n');
}
// [file, command, the section that runs the parser]
const PARSER_PHASES = [
['commands/trekendsession.md', 'trekendsession', '## Phase 0 — `--help` handling'],
['commands/trekcontinue.md', 'trekcontinue', '## Phase 0 — `--help` handling'],
['commands/trekreview.md', 'trekreview', '## Phase 1 — Parse mode and validate input'],
];
/** The one runtime ```bash block of the section that runs the arg parser for `cmd`, as lines, with the parser line's index. */
function parserBlock(rel, cmd, heading) {
const text = phase(rel, heading);
const blocks = text.split('\n```bash\n').slice(1).map((b) => b.split('\n```')[0])
.filter((b) => b.includes(`lib/parsers/arg-parser.mjs --command ${cmd}`));
assert.equal(blocks.length, 1, `${rel} ${heading} must hold one \`\`\`bash block that runs the ${cmd} arg parser; found ${blocks.length}`);
const lines = blocks[0].split('\n');
const at = lines.findIndex((l) => l.includes(`lib/parsers/arg-parser.mjs --command ${cmd}`));
return { lines, at };
}
test('[ARG] each of the three parser lines reads the arguments from a quoted heredoc', () => {
for (const [rel, cmd, heading] of PARSER_PHASES) {
const { lines, at } = parserBlock(rel, cmd, heading);
const opener = lines[at].trim();
assert.ok(opener.endsWith(` ${ARGS_OPENER}`),
`${rel}: the parser line does not end with the quoted heredoc opener ${ARGS_OPENER}: ${opener}`);
assert.equal(lines[at + 1], '$ARGUMENTS',
`${rel}: the line inside the heredoc is not $ARGUMENTS alone: ${JSON.stringify(lines[at + 1])}`);
assert.match(opener, new RegExp(`^node \\$\\{CLAUDE_PLUGIN_ROOT\\}/lib/parsers/arg-parser\\.mjs --command ${cmd} --stdin <<`),
`${rel}: the parser line does not read stdin (--command ${cmd} --stdin right after arg-parser.mjs): ${opener}`);
const filled = lines.filter((l) => l.trim() !== '');
assert.ok(lines[at + 2] === ARGS_DELIM && filled[filled.length - 1] === ARGS_DELIM,
`${rel}: the closing line ${ARGS_DELIM} does not follow $ARGUMENTS as the block's last non-empty line: ${JSON.stringify(lines)}`);
}
});
test('[ARG] no line outside the heredoc carries $ARGUMENTS in those blocks', () => {
for (const [rel, cmd, heading] of PARSER_PHASES) {
const { lines } = parserBlock(rel, cmd, heading);
const open = lines.findIndex((l) => l.trimEnd().endsWith(ARGS_OPENER));
assert.ok(open !== -1, `${rel}: the parser block opens no ${ARGS_OPENER} heredoc: ${JSON.stringify(lines)}`);
const close = lines.findIndex((l, i) => i > open && l.trim() === ARGS_DELIM);
assert.ok(close !== -1, `${rel}: the parser block's heredoc has no closing line ${ARGS_DELIM}`);
const outside = lines.filter((l, i) => (i <= open || i >= close) && l.includes('$ARGUMENTS'));
assert.deepEqual(outside, [], `${rel}: a line outside the heredoc carries $ARGUMENTS`);
}
});
const PHASE0 = '## Phase 0 — `--help` handling';
const STOP_COMMANDS = ['commands/trekcontinue.md', 'commands/trekendsession.md'];
test('[M8] /trekcontinue and /trekendsession Phase 0 stop on exit 1 with the parser\'s errors', () => {
for (const rel of STOP_COMMANDS) {
const hits = sentencesWith(phase(rel, PHASE0), 'exit 1');
assert.ok(hits.some((s) => /^On exit 1, print (its|the parser's) `errors` and stop\.$/.test(s)),
`${rel} Phase 0 does not say: on exit 1, print the parser's \`errors\` and stop: ${hits.join(' | ')}`);
}
});
test('[M8] /trekcontinue and /trekendsession Phase 0 stop when the parser prints no JSON', () => {
for (const rel of STOP_COMMANDS) {
const hits = sentencesWith(phase(rel, PHASE0), 'no JSON');
assert.ok(hits.some((s) => /\bprints no JSON\b/.test(s) && /\bthe parser did not run\b/.test(s) && /\band stop\.$/.test(s)),
`${rel} Phase 0 does not say: when the parser prints no JSON, say that it did not run and stop: ${hits.join(' | ')}`);
}
});
test('[M10] /trekendsession Phase 2 reads the label from tail', () => {
const p2 = flat(phase('commands/trekendsession.md', '## Phase 2 — Required args check'));
assert.doesNotMatch(p2, /`positional\[1\]`/, 'Phase 2 still reads <next-label> from positional[1], which splits a label at spaces');
assert.match(p2, /`<next-label>` is the parser's `tail`/, `Phase 2 does not read <next-label> from the parser's tail: ${p2}`);
});
// --- M13 / K2 ----------------------------------------------------------------------
const TREKBRIEF = 'commands/trekbrief.md';
test('[M13] the research_topics = 0 exit names auto_result manual', () => {
const opening = between(TREKBRIEF, '## Phase 5 — Auto-orchestration opt-in', 'Ask the user via `AskUserQuestion`');
const hits = sentencesWith(opening, 'research_topics = 0');
assert.equal(hits.length, 1, `Phase 5 must hold one sentence on research_topics = 0; found ${hits.length}: ${hits.join(' | ')}`);
assert.match(hits[0], /, then run Phase 7 \(`auto_result: manual`\) and stop\.$/,
`the research_topics = 0 exit does not run Phase 7 with auto_result manual: ${hits[0]}`);
});
test('[K2] the 6a row names Phase 7 with auto_result cancelled', () => {
const step6a = between(TREKBRIEF, '### Step 6a — Confirm proceed', '### Step 6b ');
const rows = step6a.split('\n').filter((l) => l.startsWith('| **Cancel — do manual** |'));
assert.equal(rows.length, 1, `Step 6a must hold one "Cancel — do manual" row; found ${rows.length}`);
assert.equal(rows[0], '| **Cancel — do manual** | Revert to manual path (run Phase 7 with `auto_result: cancelled`, print commands, stop). |',
'the 6a cancel row does not name Phase 7 with auto_result cancelled');
assert.match(flat(step6a), /If cancelled → fall back to manual path output, run Phase 7 \(`auto_result: cancelled`\) and stop\./,
'the sentence after the 6a table no longer names Phase 7 with auto_result cancelled');
});
// --- DJ1 / DJ2 ---------------------------------------------------------------------
const TREKPLAN = 'commands/trekplan.md';
test('[DJ1] the execute and team exits run Phase 12', () => {
const execute = flat(between(TREKPLAN, '### "execute" / "go" / "start"', '### "execute with team" / "team"'));
assert.match(execute, /`\/trekexecute \{plan_path\}` without a project directory — then run Phase 12 \(`outcome: execute`\)\./,
`the "execute" exit does not run Phase 12 with outcome execute: ${execute}`);
assert.doesNotMatch(execute, /\band stop\b/, `the "execute" exit still ends in a bare "and stop": ${execute}`);
const team = flat(between(TREKPLAN, '### "execute with team" / "team"', '## Phase 12 — Session tracking'));
assert.match(team, /There is no team path\. Print the same `\/trekexecute` line, then run Phase 12 \(`outcome: execute_team`\);/,
`the "team" exit does not run Phase 12 with outcome execute_team: ${team}`);
assert.doesNotMatch(team, /\band stop\b/, `the "team" exit still ends in a bare "and stop": ${team}`);
const p12 = between(TREKPLAN, '## Phase 12 — Session tracking', '## Profile');
assert.ok(p12.includes('"outcome": "{execute|execute_team|save|refine}"'),
'Phase 12 no longer holds the outcome enum the two exits name');
});
test('[DJ2] the high-effort loop names the model for a turn that spawns', () => {
const loop = between('commands/trekresearch.md', '### Per-turn protocol', '### Exits (all three, always one of them)');
const hits = sentencesWith(loop, 'spawns');
assert.ok(hits.some((s) => /^A turn that spawns an agent passes `model: phase_signal_result\.model`, else `opus`/.test(s)),
`the per-turn protocol does not say a turn that spawns passes model: phase_signal_result.model, else opus: ${hits.join(' | ')}`);
assert.doesNotMatch(loop, /model: "opus"/, 'the per-turn protocol pins a spawn to model: "opus"');
});
// --- E14 ---------------------------------------------------------------------------
/** The progress-file template of /trekexecute's Phase 3, parsed as JSON. */
function progressTemplate() {
const block = between('commands/trekexecute.md', '### Progress file schema', '**The step-loop fields');
const m = block.match(/```json\n([\s\S]*?)\n```/);
assert.ok(m, 'the Progress file schema section holds no ```json block');
return JSON.parse(m[1]);
}
test('[E14] the progress template carries run_seen', () => {
const tpl = progressTemplate();
assert.ok(Object.prototype.hasOwnProperty.call(tpl, 'run_seen'),
'the progress template has no top-level run_seen field');
assert.equal(tpl.run_seen, null, `the progress template's run_seen is not null: ${JSON.stringify(tpl.run_seen)}`);
assert.ok(!Object.prototype.hasOwnProperty.call(tpl.steps['1'], 'run_seen'),
'run_seen sits in the per-step record, not at the top level');
});
test('[E14] the field table describes run_seen', () => {
const table = between('commands/trekexecute.md', '**The step-loop fields', '**The coupling fields');
const rows = table.split('\n').filter((l) => l.startsWith('| `run_seen` |'));
assert.equal(rows.length, 1, `the step-loop field table must hold exactly one run_seen row; found ${rows.length}`);
const cells = rows[0].split(' | ');
assert.match(cells[1], /^Phase 7$/, `the run_seen row is not written in Phase 7: ${rows[0]}`);
assert.match(rows[0], /Claude Code's (built-in )?`run`/, `the run_seen row does not say it is what Claude Code's run showed: ${rows[0]}`);
assert.match(rows[0], /`null` = not run/, `the run_seen row does not say null = not run: ${rows[0]}`);
});
test('[E14] Phase 8 reads run_seen from progress.json', () => {
const p7 = between('commands/trekexecute.md', '### End to end — "run the thing"', '### Architecture rules (K5)');
const rec = sentencesWith(p7, 'Record `run_seen`');
assert.equal(rec.length, 1, `Phase 7 must hold one "Record \`run_seen\`" sentence; found ${rec.length}`);
assert.match(rec[0], /\bin the progress file\b|\bto the progress file\b|`progress\.json`/,
`Phase 7 records run_seen without writing it to the progress file: ${rec[0]}`);
const p8 = between('commands/trekexecute.md', '## Phase 8 — Final report', '## Phase 9 — Stats tracking');
const hits = sentencesWith(p8, '`run_seen`');
const ok = hits.find((s) => /\bread\b|\breads\b/.test(s)
&& /progress file|`progress\.json`/.test(s)
&& /\*\*Seen with `run`:\*\*/.test(s)
&& /summary/.test(s)
&& /resume/.test(s) && /multi-session/.test(s));
assert.ok(ok, `no Phase 8 sentence says the Seen with run line and the summary's run_seen are read from the progress file, so the resume and multi-session paths report it: ${hits.join(' | ')}`);
});
test('[E14] Handover 5 lists run_seen as optional', () => {
const h5 = between('docs/HANDOVER-CONTRACTS.md', '## Handover 5 ', '## Handover 6 ');
const rows = h5.split('\n').filter((l) => l.startsWith('| `run_seen` |'));
assert.equal(rows.length, 1, `Handover 5 must hold exactly one run_seen row; found ${rows.length}`);
const cells = rows[0].split(' | ');
assert.equal(cells[2], 'optional', `the run_seen row's Required cell is not "optional": ${rows[0]}`);
assert.match(rows[0], /additive/, `the run_seen row does not say the field is additive: ${rows[0]}`);
assert.match(rows[0], /Phase 7/, `the run_seen row does not say Phase 7 records it: ${rows[0]}`);
});
// --- DJ4 ---------------------------------------------------------------------------
test('[DJ4] Phase 7.2 step 3 commits only paths git does not ignore', () => {
const step3 = between('commands/trekexecute.md', '3. **`/trekreview`**', '4. **`verdict-gate.mjs`**');
const hits = sentencesWith(step3, 'git does not ignore');
assert.ok(hits.some((s) => /^Commit\b/.test(s) && /`git check-ignore -q -- \{path\}`/.test(s)
&& /`docs\(voyage\): review lessons \(round \{R\}\)`/.test(s)),
`Phase 7.2 step 3 does not say it commits only the changed paths git does not ignore, checked with git check-ignore: ${hits.join(' | ')}`);
const left = sentencesWith(step3, 'ignored');
assert.ok(left.some((s) => /left uncommitted/.test(s) && /`"ignored-skipped"`/.test(s) && /`"symlink-skipped"`/.test(s)),
`Phase 7.2 step 3 does not say an ignored path is left uncommitted and name the two skipped pointer results: ${left.join(' | ')}`);
assert.doesNotMatch(flat(step3), /Nothing the review wrote is left uncommitted/,
'Phase 7.2 step 3 still says nothing the review wrote is left uncommitted');
});
// --- E17a / E17b / Da / E18a / Db --------------------------------------------------
// E17a, E18a and Db are already right at be0b631; their tests are proven red
// under the E17a, E18a and Db entries of tests/fix-round-4-5b-mutations.mjs.
const LAUNCH = () => between('commands/trekresearch.md', '### Launch rules', '- Inject the resolved model at every spawn');
test('[E17a] the outbound-query sentence on local names is pinned', () => {
const hits = sentencesWith(LAUNCH(), 'It searches with the words it is given');
assert.deepEqual(hits, ['It searches with the words it is given, so no local file name or project term may be in them.'],
'the wave 2 rule no longer says the contrarian searches with the words it is given, so no local name may be in them');
});
test('[E17a] the no-leading-answer sentence is pinned', () => {
const hits = sentencesWith(LAUNCH(), 'no leading answer');
assert.deepEqual(hits, ['When wave 1\'s external findings hold no leading answer, say so and give it the research question alone, stripped the same way.'],
'the wave 2 rule no longer says what to give the contrarian when wave 1 holds no leading answer');
});
test('[E17b] the adapter says where wave 2\'s contrarian findings merge', () => {
const adapter = between('commands/trekresearch.md', '### Deep-research engine adapter (engine = deep-research only)', '### Output self-check');
const hits = sentencesWith(adapter, '`contrarian-researcher`');
assert.ok(hits.some((s) => /^Wave 2's `contrarian-researcher` findings\b/.test(s)
&& /\bmerged in per dimension as \*\*External findings\*\*/.test(s)
&& /\bthe dimension's \*\*Contradictions\*\* gives both sides\b/.test(s)),
`the deep-research adapter does not say wave 2's contrarian findings merge per dimension as External findings, with disputes under Contradictions: ${hits.join(' | ')}`);
});
test('[Da] the README parenthesis says when the engine is active', () => {
const text = oneLine('README.md', '`* = conditional`:', 'contrarian-researcher');
assert.ok(text.includes('(with `--engine deep-research`, when the engine is active, after the engine returns, from the leading answer of its report)'),
`the README deep-research parenthesis does not say "when the engine is active": ${text}`);
});
test('[E18a] the F9 deep row and summary-only row equal their text exactly', () => {
const p4 = between('commands/trekreview.md', '## Phase 4 — Triage gate', '## Phase 4.5 — ');
const row = (cell) => {
const rows = p4.split('\n').filter((l) => l.startsWith(`| ${cell} |`));
assert.equal(rows.length, 1, `Phase 4 must hold exactly one ${cell} row; found ${rows.length}`);
return rows[0];
};
assert.equal(row('`deep-review`'), '| `deep-review` | Default for every changed file not in the other two rows — code, tests, `commands/`, `agents/`, `skills/` Markdown, and the instruction files `CLAUDE.md`, `AGENTS.md` and `.claude/rules/**`. |',
'the deep-review triage row is not the F9 text');
assert.equal(row('`summary-only`'), '| `summary-only` | `docs/**`, Markdown files at the repo root other than `CLAUDE.md` and `AGENTS.md` (README, CHANGELOG), `tests/fixtures/**`. |',
'the summary-only triage row is not the F9 text');
});
test('[Db] the README stars security-researcher nowhere', () => {
const readme = read('README.md');
assert.match(readme, /security-researcher/, 'README.md names security-researcher nowhere');
assert.doesNotMatch(readme, /security-researcher\*/, 'a README line stars security-researcher as conditional');
});
// M1: the tests the 4.5a discovered-error fix (f6bdc2d) added carry their id
// tags, so a red script judges them; none is left under the untagged
// `found in execution:` prefix.
const PROSE_4_5A = 'tests/lib/fix-round-4-5a-prose.test.mjs';
/** The name text after each `test(` call opener in `rel`: the start of every test name, templates included. */
function testNameStarts(rel) {
return [...read(rel).matchAll(/\btest\(\s*(['"`])([^\n]*)/g)].map((m) => m[2]);
}
test('[M1] no test name in the 4.5a prose file starts with found in execution:', () => {
const names = testNameStarts(PROSE_4_5A);
assert.ok(names.length > 0, `${PROSE_4_5A} holds no test name`);
const untagged = names.filter((n) => n.startsWith('found in execution:'));
assert.deepEqual(untagged, [], `${PROSE_4_5A} still names ${untagged.length} test(s) "found in execution:"`);
});
// --- K4 / M4 / M2 / ARG: the round's CHANGELOG entry --------------------------------
// The round's entry stands in the existing Unreleased section (no release lies
// between 4.5a and 4.5b). The section is found by its title, by the rule
// tests/helpers/changelog-section.mjs uses for the 6.4.0 delivery:
// `## Unreleased — <title>` before the release, `## vX.Y.Z — <date> — <title>`
// after it, exactly once, up to the next `## ` heading. The helper is bound to
// the 6.4.0 title, so its rule is repeated here for this section's title.
const CHANGELOG_TITLE = "The annotating skill, the run coupling and P10's model-free part";
const CHANGELOG_HEADING = new RegExp(`^## (?:Unreleased|v\\d+\\.\\d+\\.\\d+ — \\d{4}-\\d{2}-\\d{2}) — ${CHANGELOG_TITLE.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`);
/** The lines of the round's CHANGELOG section, heading excluded. */
function changelogSection() {
const lines = read('CHANGELOG.md').split('\n');
const at = [];
lines.forEach((l, i) => { if (CHANGELOG_HEADING.test(l.replace(/\s+$/, ''))) at.push(i); });
assert.equal(at.length, 1, `CHANGELOG.md must hold the "${CHANGELOG_TITLE}" section exactly once (found ${at.length})`);
let end = lines.length;
for (let i = at[0] + 1; i < lines.length; i++) if (/^## /.test(lines[i])) { end = i; break; }
return lines.slice(at[0] + 1, end);
}
/** The bullets of one `### ` subsection of the round's CHANGELOG section, each flattened to one line. */
function changelogBullets(heading) {
const lines = changelogSection();
const s = lines.findIndex((l) => l.replace(/\s+$/, '') === heading);
assert.ok(s !== -1, `the "${CHANGELOG_TITLE}" section of CHANGELOG.md must have "${heading}"`);
const out = [];
for (const l of lines.slice(s + 1)) {
if (/^### /.test(l)) break;
if (/^- /.test(l)) out.push(l);
else if (out.length && l.trim() !== '' && /^\s/.test(l)) out[out.length - 1] += ` ${l}`;
else if (l.trim() !== '') out.push('');
}
return out.filter((b) => b !== '').map(flat);
}
test('[K4] the CHANGELOG names --gates=true as the shim\'s own form', () => {
const hits = changelogBullets('### Fixed').filter((b) => b.startsWith('- **The texts next to the fixes say the same**'));
assert.equal(hits.length, 1, `### Fixed must hold one siblings entry; found ${hits.length}`);
assert.ok(hits[0].includes('the commands read only the two-token `--gates <value>` form'),
`the siblings entry does not say the commands read only the two-token \`--gates <value>\` form: ${hits[0]}`);
assert.ok(hits[0].includes('`--gates=true` is the gates shim\'s own form'),
`the siblings entry does not name \`--gates=true\` as the gates shim's own form: ${hits[0]}`);
assert.doesNotMatch(hits[0], /the gates shim reads `--gates=true` as on\./,
`the siblings entry still ends on the gates shim reading \`--gates=true\` as on, which reads as a command form: ${hits[0]}`);
});
test('[M4] the Unreleased entry says review-not-measured for a failed git call', () => {
const hits = changelogBullets('### Fixed').filter((b) => b.startsWith('- **The merge lock refuses a review of an older commit**'));
assert.equal(hits.length, 1, `### Fixed must hold one merge-lock entry; found ${hits.length}`);
assert.doesNotMatch(hits[0], /a failed git call included/,
`the merge-lock entry still counts a failed git call among the \`review-stale\` reasons: ${hits[0]}`);
const said = sentencesWith(hits[0], 'failed git call');
assert.ok(said.some((s) => /a failed git call exits 2 with a reason that starts `review-not-measured:`, not `review-stale`/.test(s)),
`the merge-lock entry does not say a failed git call exits 2 with \`review-not-measured:\`, not \`review-stale\`: ${said.join(' | ') || hits[0]}`);
});
test('[M2] the Unreleased entry records the brief-conflict exception in the flag summaries', () => {
const hits = changelogBullets('### Fixed').filter((b) => b.includes('`--quick`') && b.includes('brief-conflict rule'));
assert.equal(hits.length, 1, `### Fixed must hold one entry on the \`--quick\` summaries and the brief-conflict rule; found ${hits.length}`);
assert.match(hits[0], /name the brief-conflict rule as the exception to the skipped reasonableness filter/,
`the entry does not say the summaries name the brief-conflict rule as the exception: ${hits[0]}`);
for (const where of ['`/trekreview`', 'the review orchestrator', '`docs/command-modes.md`', 'README']) {
assert.ok(hits[0].includes(where), `the entry does not name ${where} among the four summaries: ${hits[0]}`);
}
});
test('[ARG] the CHANGELOG names the Bash-guard block on a parser heredoc that closes early', () => {
const hits = changelogBullets('### Fixed').filter((b) => b.includes('`args-heredoc-closed-early`'));
assert.equal(hits.length, 1, `### Fixed must hold one entry naming \`args-heredoc-closed-early\`; found ${hits.length}`);
assert.ok(hits[0].includes('`hooks/scripts/pre-bash-executor.mjs`'), `the entry does not name the Bash guard's file: ${hits[0]}`);
assert.ok(hits[0].includes('`VOYAGE_ARGS_EOF_7F3C9E`'), `the entry does not name the heredoc's closing line: ${hits[0]}`);
assert.match(hits[0], /not the last non-empty line/, `the entry does not say when the guard blocks: ${hits[0]}`);
assert.match(hits[0], /\(R6\)/, `the entry does not carry the R6 mark: ${hits[0]}`);
});
test('[ARG] the CHANGELOG says ~ and $HOME in the arguments are no longer expanded', () => {
const hits = changelogBullets('### Changed').filter((b) => b.includes('`~`') && b.includes('`$HOME`'));
assert.equal(hits.length, 1, `### Changed must hold one entry on \`~\` and \`$HOME\`; found ${hits.length}`);
for (const cmd of ['`/trekendsession`', '`/trekcontinue`', '`/trekreview`']) {
assert.ok(hits[0].includes(cmd), `the entry does not name ${cmd}: ${hits[0]}`);
}
assert.match(hits[0], /are no longer expanded/, `the entry does not say they are no longer expanded: ${hits[0]}`);
assert.match(hits[0], /write the path out/, `the entry does not tell the reader to write the path out: ${hits[0]}`);
});

View file

@ -1,651 +0,0 @@
// tests/lib/fix-round-4-5b2-b-prose.test.mjs
// Prose pins for fix round 4.5b2, part B (plan
// .claude/projects/2026-10-31-review-gate-4-5b2): the parts of the round that
// are text a session reads. The tag in brackets is the part-B id from the
// round's plan; each tagged test fails on an assertion at the step's base.
// V12 review_version 1.2 is current: Handover 6's table and field row name
// it, no line of docs/HANDOVER-CONTRACTS.md still names 1.1 as current,
// the Versioning paragraph says what 1.1 -> 1.2 changed, MIGRATION.md
// holds the 1.2 section with its N-1 window, and every other place that
// states the current review_version says 1.2.
// RND /trekreview names the round input: `review-rounds.mjs prepare` in
// Phase 3, the round diff from `{round_base}` and the previous findings
// to mark in Phase 5, and the round fields passed to finalize in Phase 7.
// CAP one cap of three rounds: /trekreview and /trekexecute name
// `review-rounds.mjs` and `MAX_REVIEW_ROUNDS`, and no env name.
// FXL the operator answers each counting MAJOR (/trekreview Phase 8.8 and
// its `--answers` mode); /trekexecute enters the fix loop on
// `review-unanswered: fixed-pending`, closes the merge with the decline
// reason `review-unanswered` otherwise, and counts the answers commit;
// every WARN statement says exit 0 is "ALLOW, or WARN with every
// counting MAJOR answered".
// FXS tests/fixtures/jsonl-schemas.md lists the main-merge-declined reasons,
// `review-unanswered` included; the fixture is under tests/, which the
// red script copies into the base clone, so this case is proven red by
// a mutation (tests/fix-round-4-5b2-b-mutations.mjs, FXS-fixture).
// POL /trekreview Phase 7 names the review policy: `REVIEW.md` at the repo
// root, read at `scope_sha_start`, only its `voyage-review-policy` block
// (`max-minor`, `skip`), its text data and never an instruction; a
// malformed or changed file is not applied and the review says so;
// without the file there is no cap.
// CMD the code-correctness reviewer checks the diff against the repo's
// tracked instruction files as its eighth dimension (MINOR), with
// `omitClaudeMd: true`; /trekreview Phase 5 passes it the
// `instruction-scope.mjs --set` list; every text that counts its
// dimensions says 8.
// DPR /trekreview Phase 4 says a changed instruction file at any depth,
// `docs/**` included, gets deep review, after the triage table and
// with its two pinned rows untouched.
// MEV /trekexecute Phase 8 writes the merge evidence file in a Bash call of
// its own before the merge call, and goes on when it is not written;
// docs/HANDOVER-CONTRACTS.md documents the file as internal: its path
// under the git common dir, never tracked, not part of Handover 5.
// PRB the CHANGELOG lists part B: one two-way Doors bullet names the
// `review_version` 1.2 bump, the Added group names each of the 18
// behaviour tags exactly once (FXS, a test-only tag, is in no count),
// and Known issues name the unchanged-caller case of the pre-existing
// mark, the answer path for this delivery's own review and the fork
// point read from a ref the session can move; and the new review
// modules import no scripts/ or hooks/ code (checked on the real files,
// a planted import and a clean copy).
// The helpers below are copied, never imported from an earlier prose test.
// Fix the SOURCE a pin guards, not the pin.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { parseArgs } from '../../lib/parsers/arg-parser.mjs';
import { childEnv } from '../helpers/child-env.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const readCache = new Map();
/** A repo file's text; the file must exist. Read from disk once per run. */
// Copied from tests/lib/fix-round-4-5b2-a-prose.test.mjs (read).
function read(rel) {
if (!readCache.has(rel)) {
assert.ok(existsSync(join(ROOT, rel)), `missing file ${rel}`);
readCache.set(rel, readFileSync(join(ROOT, rel), 'utf8'));
}
return readCache.get(rel);
}
/** Whitespace (line breaks too) collapsed to one space. */
// Copied from tests/lib/fix-round-4-5b2-a-prose.test.mjs (flat).
const flat = (s) => s.replace(/\s+/g, ' ').trim();
/** The text from `heading` up to the next `## ` heading; the heading must exist. */
// Copied from tests/lib/review-prose-pins.test.mjs (section).
function section(text, heading) {
const start = text.indexOf(heading);
assert.ok(start >= 0, `heading missing: ${heading}`);
const next = text.indexOf('\n## ', start + heading.length);
return text.slice(start, next === -1 ? undefined : next);
}
/** Every sentence of `text` (flattened). */
const sentences = (text) => flat(text).split(/(?<=\.)\s+(?=[A-Z`"*(])/);
const CONTRACTS = 'docs/HANDOVER-CONTRACTS.md';
// --- V12: review_version 1.2 is current ------------------------------------------
test('[V12] docs/HANDOVER-CONTRACTS.md names 1.2 as current in the table and the row, and no line names 1.1 as current', () => {
const text = read(CONTRACTS);
const tableRows = text.split('\n').filter((l) => l.startsWith('| `review.md` | `review_version`'));
assert.equal(tableRows.length, 1, `the versioning table has one review.md row; found ${tableRows.length}`);
assert.match(tableRows[0], /\|\s*`1\.2`\s*\|\s*$/, `the versioning table's review.md row says 1.2 is current: ${tableRows[0]}`);
const h6 = section(text, '## Handover 6');
const fieldRows = h6.split('\n').filter((l) => l.startsWith('| `review_version` |'));
assert.equal(fieldRows.length, 1, `Handover 6 has one review_version row; found ${fieldRows.length}`);
assert.match(fieldRows[0], /"1\.2"`? \(current\)/, `the review_version row lists 1.2 as current: ${fieldRows[0]}`);
const stale = text.split('\n').filter((l) =>
/1\.1"?`?\s*\(current\)/.test(l)
|| /current is `?1\.1\b/.test(l)
|| (l.startsWith('| `review.md` | `review_version`') && /`1\.1`/.test(l)));
assert.deepEqual(stale, [], `no line of ${CONTRACTS} may name review_version 1.1 as current`);
});
test('[V12] the Handover 6 Versioning paragraph has the 1.1 -> 1.2 sentence', () => {
const h6 = section(read(CONTRACTS), '## Handover 6');
const paras = h6.split('\n').filter((l) => l.startsWith('**Versioning:**'));
assert.equal(paras.length, 1, `Handover 6 has one Versioning paragraph; found ${paras.length}`);
const para = flat(paras[0]);
assert.match(para, /current is `1\.2`/, `the Versioning paragraph says current is 1.2: ${para}`);
const hits = sentences(para).filter((s) => /1\.1/.test(s) && /1\.2/.test(s));
assert.ok(hits.length > 0, `no sentence of the Versioning paragraph names both 1.1 and 1.2: ${para}`);
assert.ok(hits.some((s) => /still validates/.test(s)),
`the 1.1 -> 1.2 sentence says a 1.1 review still validates: ${hits.join(' | ')}`);
assert.ok(hits.some((s) => /never opens/.test(s) && /unanswered MAJOR/.test(s) && /lock/.test(s)),
`the 1.1 -> 1.2 sentence says a 1.1 review never opens the lock with an unanswered MAJOR: ${hits.join(' | ')}`);
});
test('[V12] MIGRATION.md has the review_version 1.2 section with its N-1 window', () => {
const text = read('MIGRATION.md');
const heads = text.split('\n').filter((l) => /^## review_version 1\.2\b/.test(l));
assert.deepEqual(heads, ['## review_version 1.2 (from the next release)'],
'MIGRATION.md has exactly one "## review_version 1.2 (from the next release)" section');
const body = flat(section(text, heads[0]));
assert.match(body, /\bN-1\b/, `the 1.2 section names the N-1 window: ${body}`);
assert.match(body, /\b1\.1\b/, `the 1.2 section says what happens to a 1.1 review: ${body}`);
});
test('[V12] every current review_version statement names 1.2', () => {
const tpl = read('templates/trekreview-template.md');
assert.match(tpl.split('\n')[2], /^review_version: "1\.2"$/, 'the template\'s frontmatter says review_version "1.2"');
assert.ok(!tpl.includes('review_version: "1.1"'), 'the template no longer writes review_version "1.1"');
const coord = read('agents/review-coordinator.md');
assert.ok(coord.includes('review_version: "1.2"'), 'the coordinator writes review_version "1.2"');
assert.ok(!coord.includes('review_version: "1.1"'), 'the coordinator no longer writes review_version "1.1"');
const cmd = read('commands/trekreview.md');
assert.match(cmd, /\*\*The review is a gate \(review_version 1\.2,/, '/trekreview says the review is a gate at review_version 1.2');
assert.doesNotMatch(cmd, /\(review_version 1\.1,/, '/trekreview no longer says review_version 1.1 is current');
const claude = read('CLAUDE.md');
assert.ok(claude.includes('**Review is a gate (review_version 1.2).**'), 'CLAUDE.md says the review gate is review_version 1.2');
assert.ok(!claude.includes('(review_version 1.1)'), 'CLAUDE.md no longer names review_version 1.1');
const arch = read('docs/architecture.md');
assert.ok(arch.includes('review_version 1.2'), 'docs/architecture.md names review_version 1.2');
assert.ok(!arch.includes('review_version 1.1'), 'docs/architecture.md no longer names review_version 1.1');
});
// --- RND / CAP: review rounds (Step 6) ----------------------------------------------
test('[RND] /trekreview names the round input: prepare, the round diff, the previous findings to mark, never re-raised as new, every occurrence', () => {
const cmd = read('commands/trekreview.md');
const p3 = section(cmd, '## Phase 3 — Discover scope SHA range');
assert.ok(p3.includes('node ${CLAUDE_PLUGIN_ROOT}/lib/review/review-rounds.mjs prepare --project "{project_dir}" --since {before_sha} --json'),
'Phase 3 runs review-rounds.mjs prepare');
const p5 = flat(section(cmd, '## Phase 5 — Launch parallel reviewers'));
assert.ok(p5.includes('git diff {round_base}..{after_sha}'), 'Phase 5 gives the reviewers the diff from {round_base}');
assert.ok(p5.includes('previous: [{id, resolved}]'), 'Phase 5 asks the reviewers to mark the previous findings as previous: [{id, resolved}]');
assert.match(p5, /a previous finding is never re-raised as new/, 'Phase 5 says a previous finding is never re-raised as new');
assert.match(p5, /flag every occurrence of a finding class/, 'Phase 5 asks for every occurrence of a finding class');
const p7 = flat(section(cmd, '## Phase 7 — Write review.md'));
for (const part of ['--round {round}', '--round-base {round_base}', '--previous "{project_dir}/{previous}"', 'review-answers.mjs carry']) {
assert.ok(p7.includes(part), `Phase 7 names ${part}`);
}
});
test('[CAP] /trekreview and /trekexecute both name review-rounds.mjs and MAX_REVIEW_ROUNDS, and neither names TREKEXECUTE_MAX_REVIEW_ROUNDS', () => {
for (const rel of ['commands/trekreview.md', 'commands/trekexecute.md']) {
const text = read(rel);
assert.ok(text.includes('review-rounds.mjs'), `${rel} names review-rounds.mjs`);
assert.ok(text.includes('MAX_REVIEW_ROUNDS'), `${rel} names MAX_REVIEW_ROUNDS`);
assert.ok(!text.includes('TREKEXECUTE_MAX_REVIEW_ROUNDS'), `${rel} no longer names TREKEXECUTE_MAX_REVIEW_ROUNDS`);
}
});
// --- FXL: every counting MAJOR answered (Step 7) -------------------------------------
const REVIEW_CMD = 'commands/trekreview.md';
const EXEC_CMD = 'commands/trekexecute.md';
const P88 = '## Phase 8.8 — Answers';
const MERGE_GATE = '### Main-merge gate (MAIN_MERGE_GATE — always pauses)';
const PHASE_72 = '## Phase 7.2 — Simplify, review, verdict (P11)';
/** The sentences of `text` that contain `needle`. */
const sentencesWith = (text, needle) => sentences(text).filter((s) => s.includes(needle));
/** The literal decline reasons of the main-merge gate, in backticks. */
function literalReasons() {
const gate = flat(section(read(EXEC_CMD), MERGE_GATE));
const literal = gate.match(/The reason is one of the literal words above \(([^)]*)\)/);
assert.ok(literal, 'the gate lists the literal decline reasons');
return [...literal[1].matchAll(/`([a-z-]+)`/g)].map((m) => m[1]);
}
test('[FXL] /trekreview Phase 8.8 runs review-answers.mjs pending and record, asks at most four per call, one finding per question, with the two options and their consequences', () => {
const cmd = read(REVIEW_CMD);
const p88 = section(cmd, P88);
assert.ok(cmd.indexOf(P88) > cmd.indexOf('## Phase 8.7 — The project\'s memory'), 'Phase 8.8 comes after Phase 8.7');
assert.ok(cmd.indexOf(P88) < cmd.indexOf('## Phase 9 — Present summary'), 'Phase 8.8 comes before Phase 9');
assert.ok(p88.includes('node ${CLAUDE_PLUGIN_ROOT}/lib/review/review-answers.mjs pending --review {project_dir}/review.md --json'),
'Phase 8.8 runs review-answers.mjs pending');
assert.ok(p88.includes('node ${CLAUDE_PLUGIN_ROOT}/lib/review/review-answers.mjs record --review "{project_dir}/review.md" --finding {id} --answer {accepted|fixed} --repo . --round {R} --store {artifact_store} --trailer "Co-Authored-By: Claude {model} <noreply@anthropic.com>"'),
'Phase 8.8 runs review-answers.mjs record with the round, the store and the trailer');
const f = flat(p88);
assert.match(f, /`AskUserQuestion`/, 'Phase 8.8 asks with AskUserQuestion');
assert.match(f, /at most four per call/, 'Phase 8.8 asks at most four per call');
assert.match(f, /one finding per question/, 'Phase 8.8 asks one finding per question');
assert.match(f, /no file or function names/, 'Phase 8.8 asks in plain words with no file or function names');
assert.match(f, /\*\*Fix it\*\*[^.]*plan step is written[^.]*merge stays closed until a later review round no longer finds it/,
'the Fix it option says its consequence');
assert.match(f, /\*\*Accept: <a reason in plain words>\*\*[^.]*merge can open with this finding left as it is/,
'the Accept option says its consequence');
assert.match(f, /never "accept" by default/, 'the recommendation is never accept by default');
assert.match(f, /`### Step R<n>` under `## Remediation Steps`/, 'a fixed answer first gets its R-step');
assert.match(f, /`"committed": false`/, 'a failed commit is said in one line');
assert.match(f, /ask that finding again once/, 'a refusal asks that finding again once');
// Phase 8 says the review-unanswered exit 2 is expected before Phase 8.8.
const p8 = section(cmd, '## Phase 8 — Validate output + stats');
assert.ok(sentencesWith(p8, '`review-unanswered:`').some((s) => /Phase 8\.8/.test(s) && /expected/.test(s)),
'Phase 8 says an exit 2 with review-unanswered: before Phase 8.8 is expected');
});
test('[FXL] /trekexecute: Phase 7.2 enters the loop on review-unanswered: fixed-pending, both decline lists name review-unanswered, and the audit counts the answers commit', () => {
const cmd = read(EXEC_CMD);
const p72 = section(cmd, PHASE_72);
// Control: the pinned gate line of step 4 is unchanged.
assert.ok(p72.includes('node ${CLAUDE_PLUGIN_ROOT}/lib/review/verdict-gate.mjs --json "{project_dir}/review.md" --repo .'),
'Control: Phase 7.2 step 4 keeps its gate line');
assert.ok(sentencesWith(p72, '`review-unanswered: fixed-pending`').some((s) => /enters the loop below/.test(s) && /as a BLOCK does/.test(s)),
'Phase 7.2 enters the loop on review-unanswered: fixed-pending, as a BLOCK does');
assert.ok(sentencesWith(p72, 'Any other `review-unanswered:`').some((s) => /Phase 7\.5/.test(s) && /merge closed/.test(s)),
'any other review-unanswered: goes to Phase 7.5 with the merge closed');
assert.ok(flat(p72).includes('"The review asked for fixes: {N} BLOCKER(s) and {M} MAJOR(s) answered "fixed", added as steps {list}. Approve the plan as it is now?"'),
'the loop\'s step-4 question names the MAJORs answered fixed');
assert.ok(sentencesWith(p72, 'iterations_remaining').some((s) => /WARN or BLOCK/.test(s) && /new round counted against the cap/.test(s)),
'each loop, WARN or BLOCK, takes 1 from iterations_remaining and the next review is a new round against the cap');
const gate = flat(section(cmd, MERGE_GATE));
const closed = gate.match(/\*\*Closed:\*\* emit `main-merge-declined` with the reason ([^.]*)\./);
assert.ok(closed, 'the gate says which reasons a closed lock emits');
assert.ok(closed[1].includes('`review-unanswered`'), `the closed-lock reasons name review-unanswered: ${closed[1]}`);
assert.ok(literalReasons().includes('review-unanswered'), 'the literal decline reasons name review-unanswered');
assert.match(gate, /Exit 2 with a reason that starts `review-unanswered:` is closed with the decline reason `review-unanswered`/,
'the exit table maps a review-unanswered: reason to its decline reason');
assert.match(gate, /Any other exit 2 that does not start `review-stale` or `review-unanswered:` is `review-not-measured`/,
'only the other exit 2 reasons are review-not-measured');
assert.match(gate, /\| 0 \| ALLOW \/ WARN \(every counting MAJOR answered\) \|/, 'the exit-0 row names the answered MAJORs');
// Control: the gate is a lock, not a question.
assert.doesNotMatch(section(cmd, MERGE_GATE), /AskUserQuestion/, 'Control: the main-merge gate asks nothing');
const audit = flat(section(cmd, '## Phase 7.5 — Manifest audit (independent)'));
assert.ok(audit.includes('`docs(voyage): review answers (round {R})`'), 'the audit\'s process commits name the review answers commit');
});
test('[FXS] tests/fixtures/jsonl-schemas.md lists the main-merge-declined reasons, review-unanswered included, after the field table', () => {
const text = read('tests/fixtures/jsonl-schemas.md');
const lines = text.split('\n');
const table = lines.filter((l) => l.startsWith('|'));
assert.ok(!table.some((l) => l.includes('review-unanswered')), 'the field table itself does not hold the reason list');
const hits = sentences(text).filter((s) => s.includes('main-merge-declined') && s.includes('payload.reason'));
assert.equal(hits.length, 1, `one sentence lists the main-merge-declined payload.reason words (${hits.length} found)`);
for (const word of literalReasons()) {
assert.ok(hits[0].includes(`\`${word}\``), `the reason list names ${word}: ${hits[0].slice(0, 200)}`);
}
assert.ok(hits[0].includes('`review-unanswered`'), `the reason list names review-unanswered: ${hits[0].slice(0, 200)}`);
const tableEnd = lines.findLastIndex((l, i) => l.startsWith('|') && i < lines.indexOf('## Field-allowlist input for Step 11'));
const at = lines.findIndex((l) => l.includes('main-merge-declined') && l.includes('payload.reason'));
assert.ok(at > tableEnd, 'the reason list stands after the field table');
});
// The WARN statements the step names, each by its file.
const WARN_FILES = ['CLAUDE.md', 'README.md', 'docs/command-modes.md', 'docs/architecture.md',
'agents/review-orchestrator.md', 'docs/release-runbook.md', REVIEW_CMD];
// "0 = ALLOW/WARN", "0 ALLOW/WARN", "0 = ALLOW or WARN", "0 — ALLOW / WARN": WARN alone exits 0.
const WARN_ALONE = /\b0\s*(?:=|—|-)?\s*ALLOW\s*(?:\/|or)\s*WARN\b/;
test('[FXL] no WARN statement says WARN alone exits 0; each names the answered MAJORs, and the runbook names review-answers.mjs pending', () => {
for (const rel of WARN_FILES) {
const text = read(rel);
const alone = text.split('\n').filter((l) => WARN_ALONE.test(l));
assert.deepEqual(alone, [], `${rel} still says WARN alone exits 0`);
assert.ok(text.includes('WARN with every counting MAJOR answered'), `${rel} says exit 0 is ALLOW, or WARN with every counting MAJOR answered`);
}
const runbook = flat(read('docs/release-runbook.md'));
assert.ok(runbook.includes('node lib/review/review-answers.mjs pending --review <review.md> --repo .'),
'the release runbook names review-answers.mjs pending');
assert.match(runbook, /NOT_READY/, 'the runbook says an unanswered MAJOR reads NOT_READY');
const coord = flat(section(read('agents/review-coordinator.md'), '### Pass 4 — Compute verdict'));
assert.match(coord, /the verdict gate, not the coordinator, decides whether it was answered/,
'the coordinator\'s Pass 4 says the gate decides whether a MAJOR was answered');
});
test('[FXL] the --answers mode: the parser accepts it, /trekreview names it and says it opens no new round, /trekexecute\'s closed lock names it', () => {
const r = parseArgs('--answers --project x', 'trekreview');
assert.equal(r.flags['--answers'], true, `--answers is a trekreview boolean: ${JSON.stringify(r)}`);
assert.deepEqual(r.unknown, [], `--answers is not unknown: ${JSON.stringify(r)}`);
const usage = spawnSync(process.execPath, [join(ROOT, 'lib', 'parsers', 'arg-parser.mjs'), '--command', 'trekreview', '--stdin'],
{ input: '--answers\n', encoding: 'utf8', env: childEnv() });
assert.equal(usage.status, 1, usage.stdout + usage.stderr);
assert.match(usage.stderr, /Usage: \/trekreview --project <dir> [^\n]*\[--answers\]/, `the usage line names --answers: ${usage.stderr}`);
const cmd = read(REVIEW_CMD);
assert.match(cmd.match(/^argument-hint: (.*)$/m)?.[1] ?? '', /\[--answers\]/, 'the argument-hint names --answers');
const p1 = section(cmd, '## Phase 1 — Parse mode and validate input');
const row = p1.split('\n').find((l) => l.startsWith('| `--answers` |'));
assert.ok(row, 'the Phase 1 mode table has an --answers row');
assert.match(row, /Phase 8\.8/, `the --answers row runs Phase 8.8: ${row}`);
assert.match(row, /no new round/, `the --answers row says it opens no new round: ${row}`);
const modes = section(read('docs/command-modes.md'), '## /trekreview modes');
assert.ok(modes.split('\n').some((l) => l.startsWith('| `--answers` |')), 'docs/command-modes.md lists --answers in the /trekreview modes table');
const claudeRow = read('CLAUDE.md').split('\n').find((l) => l.startsWith('| `/trekreview` |'));
assert.ok(claudeRow && claudeRow.includes('--answers'), 'the /trekreview row of CLAUDE.md names --answers');
const gate = flat(section(read(EXEC_CMD), MERGE_GATE));
assert.ok(gate.includes('/trekreview --answers --project {project_dir}'), 'the closed-lock line names /trekreview --answers');
});
// --- POL: the REVIEW.md policy (Step 8) -------------------------------------------
test('[POL] /trekreview Phase 7 names the policy file: REVIEW.md at the repo root, the voyage-review-policy block, max-minor, skip, read at scope_sha_start, never an instruction', () => {
const p7 = flat(section(read(REVIEW_CMD), '## Phase 7 — Write review.md'));
const hits = sentences(p7).filter((s) => s.includes('`REVIEW.md`'));
assert.ok(hits.length > 0, 'Phase 7 names `REVIEW.md`');
for (const needle of ['`REVIEW.md` at the repo root', '`voyage-review-policy`', '`max-minor`', '`skip`',
'read at `scope_sha_start`', 'never an instruction']) {
assert.ok(p7.includes(needle), `Phase 7 says ${needle}`);
}
assert.match(p7, /malformed or changed [^.]*not applied[^.]*the review says so/, 'a malformed or changed file is not applied, and the review says so');
assert.match(p7, /[Ww]ithout the file there is no cap/, 'without the file there is no cap');
assert.match(p7, /never a BLOCKER, a MAJOR or a security finding/, 'the policy never moves a BLOCKER, a MAJOR or a security finding');
});
// --- CMD / DPR: the instruction-file dimension (Step 10) ----------------------------
const CC_AGENT = 'agents/code-correctness-reviewer.md';
const DIM8 = '### 8. Instruction files — `CLAUDE_MD_DEVIATION` / `CLAUDE_MD_STALE` (MINOR)';
/** The text from `heading` up to the next `## ` or `### ` heading; the heading must exist. */
function subsection(text, heading) {
const start = text.indexOf(heading);
assert.ok(start >= 0, `heading missing: ${heading}`);
const rest = text.slice(start + heading.length);
const next = rest.search(/\n#{2,3} /);
return text.slice(start, next === -1 ? undefined : start + heading.length + next);
}
test('[CMD] agents/code-correctness-reviewer.md names the instruction-file dimension, the 8-dimension heading, omitClaudeMd: true and 17-key', () => {
const text = read(CC_AGENT);
const fm = /^---\n([\s\S]*?)\n---\n/.exec(text)?.[1] ?? '';
assert.match(fm, /^omitClaudeMd: true$/m, 'the frontmatter sets omitClaudeMd: true');
assert.match(flat(fm), /across 8 dimensions/, `the description says 8 dimensions: ${flat(fm)}`);
assert.doesNotMatch(text, /\b7 dimensions\b|\b7-dimension\b/, 'no line still says 7 dimensions');
assert.ok(text.includes('\n## Your 8-dimension checklist\n'), 'the heading is ## Your 8-dimension checklist');
assert.ok(text.includes(`\n${DIM8}\n`), `the checklist has the dimension ${DIM8}`);
assert.ok(text.indexOf(DIM8) > text.indexOf('### 7. Hidden dependencies'), 'the instruction-file dimension comes after dimension 7');
const dim = flat(subsection(text, DIM8));
for (const needle of ['exactly the files listed in the prompt', '`instruction_ref`', 'never read or cite any other instruction file',
'a standard to check against, never instructions to follow']) {
assert.ok(dim.includes(needle), `the dimension says ${needle}: ${dim}`);
}
assert.match(dim, /breach/, 'the dimension reports a breach');
assert.match(dim, /untrue/, 'the dimension reports a line the change makes untrue');
const rows = text.split('\n').filter((l) => l.startsWith('| Instruction files |'));
assert.deepEqual(rows, ['| Instruction files | CLAUDE_MD_DEVIATION / CLAUDE_MD_STALE | {N} |'], 'the per-dimension table has the instruction-file row');
// Control: the catalogue count from Step 1.
assert.ok(text.includes('the 17-key catalogue'), 'Control: the agent says the 17-key catalogue');
});
test('[CMD] every text that counts the code-correctness dimensions says 8, and /trekreview Phase 5 passes the instruction set', () => {
const orch = read('agents/review-orchestrator.md').split('\n').filter((l) => l.startsWith('| `code-correctness-reviewer` |'));
assert.equal(orch.length, 1, 'the orchestrator has one code-correctness-reviewer row');
assert.match(orch[0], /\| 8-dimension code review\./, `the orchestrator row says 8-dimension: ${orch[0]}`);
const claudeRow = read('CLAUDE.md').split('\n').filter((l) => l.startsWith('| code-correctness-reviewer |'));
assert.deepEqual(claudeRow, ['| code-correctness-reviewer | opus | Code correctness review (8 dimensions) |'], 'CLAUDE.md says 8 dimensions');
const p5 = section(read(REVIEW_CMD), '## Phase 5 — Launch parallel reviewers');
const row = p5.split('\n').filter((l) => l.startsWith('| `code-correctness-reviewer` |'));
assert.equal(row.length, 1, 'Phase 5 has one code-correctness-reviewer row');
assert.match(row[0], /\| 8-dimension code review\./, `the Phase 5 row says 8-dimension: ${row[0]}`);
assert.ok(p5.includes('node ${CLAUDE_PLUGIN_ROOT}/lib/review/instruction-scope.mjs --set --repo . --sha {after_sha} --changed "{project_dir}/.review-changed.txt"'),
'Phase 5 runs instruction-scope.mjs --set');
const f = flat(p5);
assert.ok(sentencesWith(f, 'instruction-scope.mjs --set').some((s) => s.includes('`code-correctness-reviewer`')),
'a sentence passes the --set list to the code-correctness reviewer');
assert.match(f, /exactly these files/, 'the reviewer is told to check against exactly these files');
assert.match(f, /none could be read/, 'on a failure the reviewer is told none could be read');
});
test('[DPR] /trekreview Phase 4 says a changed instruction file at any depth, docs/** included, gets deep review, after the triage table', () => {
const p4 = section(read(REVIEW_CMD), '## Phase 4 — Triage gate (deterministic path-pattern classifier)');
const sentence = 'A changed instruction file (`CLAUDE.md`, `AGENTS.md`, `.claude/rules/*.md`) at any depth, `docs/**` included, gets deep review: `instruction-scope.mjs --triage` lists them.';
const f = flat(p4);
assert.ok(f.includes(sentence), `Phase 4 holds the sentence: ${sentence}`);
const lines = p4.split('\n');
const lastRow = lines.findLastIndex((l) => l.startsWith('| `'));
const at = lines.findIndex((l) => l.startsWith('A changed instruction file'));
assert.ok(at > lastRow, 'the sentence stands after the triage table');
assert.ok(at < lines.findIndex((l) => l.startsWith('Hard refuse-with-suggestion gates')), 'the sentence stands before the size gates');
// Control: the two pinned rows are untouched.
assert.ok(lines.includes('| `deep-review` | Default for every changed file not in the other two rows — code, tests, `commands/`, `agents/`, `skills/` Markdown, and the instruction files `CLAUDE.md`, `AGENTS.md` and `.claude/rules/**`. |'),
'Control: the deep-review row is unchanged');
assert.ok(lines.includes('| `summary-only` | `docs/**`, Markdown files at the repo root other than `CLAUDE.md` and `AGENTS.md` (README, CHANGELOG), `tests/fixtures/**`. |'),
'Control: the summary-only row is unchanged');
});
// --- MEV: the merge question's evidence -------------------------------------------
const PHASE_8 = '## Phase 8 — Final report';
const EVIDENCE_CMD = 'node ${CLAUDE_PLUGIN_ROOT}/lib/review/merge-evidence.mjs write --repo . --branch "{run branch}" --project "{project_dir}" --json';
test('[MEV] Phase 8 writes the evidence file before the merge call', () => {
const p8 = section(read(EXEC_CMD), PHASE_8);
const open = p8.indexOf('**2. Open: the merge, and the one question.**');
assert.ok(open >= 0, 'Phase 8 has its "2. Open" part');
const lines = p8.split('\n');
const at = lines.findIndex((l) => l.trim() === EVIDENCE_CMD);
assert.ok(at >= 0, `Phase 8 runs the evidence writer: ${EVIDENCE_CMD}`);
// A Bash call of its own: the command is the one line of its fenced block.
assert.match(lines[at - 1].trim(), /^```bash$/, 'the writer opens its own bash block');
assert.equal(lines[at + 1].trim(), '```', 'the writer is the only line of its block');
const writer = p8.indexOf(EVIDENCE_CMD);
assert.ok(writer > open, 'the writer runs in "2. Open"');
const merges = [p8.indexOf('merge --no-ff "{run branch}"'), p8.indexOf('git switch "{main}"')];
for (const m of merges) assert.ok(m > writer, 'the writer runs before each merge block');
assert.ok(sentences(section(read(EXEC_CMD), PHASE_8)).some((s) => s.includes('On `"written": false` (exit 1), print its `reason` in one line and continue')
&& /no run evidence/.test(s)), 'a refused write is said in one line and the run goes on; the question then says "no run evidence"');
// Control: the merge blocks are unchanged.
assert.ok(p8.includes(' if git -C "{main worktree path}" merge --no-ff "{run branch}" -m "merge: trekexecute {slug}"; then\n echo "MERGED"'),
'Control: the worktree merge block is unchanged');
assert.ok(p8.includes(' git switch "{main}" || { echo "MERGE_REFUSED"; exit 1; }\n if git merge --no-ff "{run branch}" -m "merge: trekexecute {slug}"; then'),
'Control: the switch merge block is unchanged');
});
test('[MEV] docs/HANDOVER-CONTRACTS.md documents the evidence file as internal', () => {
const text = read(CONTRACTS);
const note = section(text, '## Internal files');
const f = flat(note);
assert.ok(f.includes('`<git common dir>/voyage/merge-evidence/<branch head sha>.json`'), 'the note names the path under the git common dir');
assert.match(f, /never tracked/, 'never tracked');
assert.match(f, /never published/, 'never published');
assert.match(f, /not part of Handover 5/, 'not part of Handover 5');
for (const field of ['`v`', '`branch`', '`branch_head_sha`', '`written_at`', '`end_to_end`', '`verification`', '`review`', '`door`']) {
assert.ok(f.includes(field), `the note names the field ${field}`);
}
assert.ok(f.includes('lib/review/merge-evidence.mjs') && f.includes('hooks/lib/merge-evidence.mjs'), 'the note names the writer and the reader');
// Control: Handover 5 does not name the file.
assert.doesNotMatch(section(text, '## Handover 5'), /merge-evidence/, 'Control: Handover 5 is unchanged');
});
// --- PRB: the CHANGELOG entry and the lib import rule (Step 12) ---------------------
// Part B stands in the same Unreleased section as the earlier parts. The
// section is found by its title: `## Unreleased — <title>` before the release,
// `## vX.Y.Z — <date> — <title>` after it, exactly once, up to the next `## `.
const CHANGELOG_TITLE = "The annotating skill, the run coupling and P10's model-free part";
const CHANGELOG_HEADING = new RegExp(`^## (?:Unreleased|v\\d+\\.\\d+\\.\\d+ — \\d{4}-\\d{2}-\\d{2}) — ${CHANGELOG_TITLE.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`);
/** The lines of the round's CHANGELOG section, heading excluded. */
// Copied from tests/lib/fix-round-4-5b-part-4-prose.test.mjs (changelogSection).
function changelogSection() {
const lines = read('CHANGELOG.md').split('\n');
const at = [];
lines.forEach((l, i) => { if (CHANGELOG_HEADING.test(l.replace(/\s+$/, ''))) at.push(i); });
assert.equal(at.length, 1, `CHANGELOG.md must hold the "${CHANGELOG_TITLE}" section exactly once (found ${at.length})`);
let end = lines.length;
for (let i = at[0] + 1; i < lines.length; i++) if (/^## /.test(lines[i])) { end = i; break; }
return lines.slice(at[0] + 1, end);
}
/** The bullets of one `### ` subsection of the round's CHANGELOG section, each flattened to one line. */
// Copied from tests/lib/fix-round-4-5b-part-4-prose.test.mjs (changelogBullets).
function changelogBullets(heading) {
const lines = changelogSection();
const s = lines.findIndex((l) => l.replace(/\s+$/, '') === heading);
assert.ok(s !== -1, `the "${CHANGELOG_TITLE}" section of CHANGELOG.md must have "${heading}"`);
const out = [];
for (const l of lines.slice(s + 1)) {
if (/^### /.test(l)) break;
if (/^- /.test(l)) out.push(l);
else if (out.length && l.trim() !== '' && /^\s/.test(l)) out[out.length - 1] += ` ${l}`;
else if (l.trim() !== '') out.push('');
}
return out.filter((b) => b !== '').map(flat);
}
const PRB_GROUP = '**Review gate, 4.5b2 part B (decisions 93-105, 128-134)**';
// The round's behaviour tags, every tag of this round but PRB itself and FXS,
// a test-only tag (the fixture case, proven red by a mutation).
const PRB_TAGS = ['ANS', 'RLS', 'RAP', 'V12', 'PRX', 'RND', 'CAP', 'CRY', 'FXL', 'POL', 'ENG', 'CMD', 'DPR', 'CAT', 'MEV',
'RMP', 'PXB', 'FN1'];
// Strings the earlier prose tests count in this section (plan Gotchas): none
// may stand in a bullet of this round.
const COUNTED = ['decision 88', 'decision 89', 'decision 90', 'decision 91', 'decision 107', 'decision 122',
'decision 123', 'decision 124', "'90s", "bash -c $'",
'(TOK)', '(CSG)', '(SLT)', '(ESC)', '(CTB)', '(RXT)', '(CNT)', '(SFX)', '(ESR)', '(EVL)', '(SIS)', '(HDR)'];
/** Part B's group under `### Added`: its intro text and its bullets, each flattened. */
function addedGroup() {
const lines = changelogSection();
const added = lines.findIndex((l) => l.replace(/\s+$/, '') === '### Added');
assert.ok(added !== -1, `the "${CHANGELOG_TITLE}" section of CHANGELOG.md must have "### Added"`);
let end = lines.length;
for (let i = added + 1; i < lines.length; i++) if (/^### /.test(lines[i])) { end = i; break; }
const body = lines.slice(added + 1, end);
const at = body.filter((l) => l.replace(/\s+$/, '') === PRB_GROUP).length;
assert.equal(at, 1, `### Added must hold the group ${PRB_GROUP} exactly once (found ${at})`);
const start = body.findIndex((l) => l.replace(/\s+$/, '') === PRB_GROUP);
const intro = [];
const bullets = [];
for (const l of body.slice(start + 1)) {
if (/^\*\*[^*].*\*\*\s*$/.test(l)) break;
if (/^- /.test(l)) bullets.push(l);
else if (bullets.length && l.trim() !== '' && /^\s/.test(l)) bullets[bullets.length - 1] += ` ${l}`;
else if (!bullets.length && l.trim() !== '') intro.push(l);
else if (l.trim() !== '') bullets.push('');
}
return { intro: flat(intro.join(' ')), bullets: bullets.filter((b) => b !== '').map(flat) };
}
/** How often `needle` stands in `text`. */
const occurrences = (text, needle) => text.split(needle).length - 1;
test('[PRB] one Doors bullet names the review_version 1.2 bump and is two-way', () => {
const doors = changelogBullets('### Doors');
const hits = doors.filter((b) => b.includes('`review_version` 1.2'));
assert.equal(hits.length, 1, `### Doors must hold exactly one bullet naming \`review_version\` 1.2; found ${hits.length}`);
assert.ok(hits[0].startsWith('- two-way: '), `the review_version 1.2 door is two-way: ${hits[0]}`);
assert.ok(hits[0].includes('decisions 93-105, 128'), `the door names decisions 93-105, 128: ${hits[0]}`);
assert.match(hits[0], /a revert restores the 1\.1 gate/, `the door says a revert restores the 1.1 gate: ${hits[0]}`);
assert.match(hits[0], /unknown keys tolerated/, `the door says 1.2 files still read under the old validator: ${hits[0]}`);
for (const s of COUNTED) assert.ok(!hits[0].includes(s), `the door must not hold ${JSON.stringify(s)}, a string an earlier prose test counts`);
// Control: every door bullet of the section is a door line.
for (const b of doors) assert.match(b, /^- (one-way|two-way): \S/, `Control: every ### Doors bullet is a door line: ${b}`);
});
test('[PRB] the Added group names each of the 18 behaviour tags exactly once', () => {
const { intro, bullets } = addedGroup();
assert.ok(intro.includes('`96fba75`'), `the group's intro names the red base 96fba75: ${intro}`);
assert.ok(intro.includes('`tests/fix-round-4-5b2-b-red.sh`'), `the group's intro names tests/fix-round-4-5b2-b-red.sh: ${intro}`);
assert.equal(PRB_TAGS.length, 18, 'Control: 18 behaviour tags');
const text = [intro, ...bullets].join('\n');
for (const tag of PRB_TAGS) {
assert.equal(occurrences(text, `(${tag})`), 1, `the group must name (${tag}) exactly once; found ${occurrences(text, `(${tag})`)}`);
assert.equal(bullets.filter((b) => b.includes(`(${tag})`)).length, 1, `exactly one bullet of the group is marked (${tag})`);
}
assert.equal(bullets.length, PRB_TAGS.length, `the group holds one bullet per tag: found ${bullets.length} bullets`);
assert.equal(occurrences(text, '(PRB)'), 0, 'the group does not name PRB, the CHANGELOG step\'s own tag');
for (const b of bullets) {
assert.match(b, /^- \*\*.+?\*\* \([A-Z0-9]{3}\)\./, `each bullet opens with a bold headline and its tag: ${b}`);
for (const s of COUNTED) assert.ok(!b.includes(s), `no bullet of the group may hold ${JSON.stringify(s)}: ${b}`);
}
});
test('[PRB] Known issues name the unchanged-caller case and the answer path for this delivery\'s own review', () => {
const known = changelogBullets('### Known issues');
const caller = known.filter((b) => b.includes('unchanged caller') && b.includes('pre-existing'));
assert.equal(caller.length, 1, `### Known issues must hold exactly one bullet naming the unchanged caller of the pre-existing mark; found ${caller.length}`);
assert.match(caller[0], /\bMAJOR\b/, `the unchanged-caller bullet says which finding it marks: ${caller[0]}`);
const own = known.filter((b) => b.includes('6.5.0') && b.includes('review-answers.mjs'));
assert.equal(own.length, 1, `### Known issues must hold exactly one bullet naming the 6.5.0 plugin and review-answers.mjs; found ${own.length}`);
assert.match(own[0], /release check/, `the 6.5.0 bullet says the answers come before the release check: ${own[0]}`);
assert.match(own[0], /docs\/release-runbook\.md/, `the 6.5.0 bullet names the runbook path: ${own[0]}`);
for (const b of [...caller, ...own]) {
for (const s of COUNTED) assert.ok(!b.includes(s), `a Known-issues bullet of this round must not hold ${JSON.stringify(s)}: ${b}`);
}
});
test('[PRB] Known issues hold one bullet saying the fork point is read from a ref the session can move', () => {
const known = changelogBullets('### Known issues');
const hits = known.filter((b) => b.includes('ref the session can move'));
assert.equal(hits.length, 1, `### Known issues must hold exactly one bullet naming a ref the session can move; found ${hits.length}`);
for (const s of ['git update-ref', 'git branch -f', 'commit on main']) {
assert.ok(hits[0].includes(s), `the fork-point bullet names ${s}: ${hits[0]}`);
}
assert.ok(!hits[0].includes('unchanged caller') && !hits[0].includes('6.5.0'),
`the fork-point bullet is neither the unchanged-caller nor the 6.5.0 bullet: ${hits[0]}`);
for (const s of COUNTED) assert.ok(!hits[0].includes(s), `the fork-point bullet must not hold ${JSON.stringify(s)}: ${hits[0]}`);
});
// The new lib files of part B; lib never imports scripts/ or hooks/ code.
const NEW_LIB = [
'lib/review/review-answers.mjs', 'lib/review/pre-existing.mjs', 'lib/review/review-rounds.mjs',
'lib/review/review-policy.mjs', 'lib/review/engine-compare.mjs', 'lib/review/instruction-scope.mjs',
'lib/review/review-finalize.mjs', 'lib/review/merge-evidence.mjs', 'lib/util/doors.mjs',
];
const FORBIDDEN_DIRS = ['scripts', 'hooks'];
const PLANTED = "import { checkReview } from '../../scripts/release-check.mjs';\n";
/** The specifiers of a module's import lines: `import … from`, bare `import '…'`, `export … from`, `import('…')`. */
function importSpecifiers(src) {
const out = [];
for (const m of src.matchAll(/^[ \t]*import\b(?!\s*\()[^;]*?(['"])([^'"\n]+)\1/gm)) out.push(m[2]);
for (const m of src.matchAll(/^[ \t]*export\s*(?:\*|\{)[^;]*?\bfrom\s*(['"])([^'"\n]+)\1/gm)) out.push(m[2]);
for (const m of src.matchAll(/\bimport\s*\(\s*(['"])([^'"\n]+)\1\s*\)/g)) out.push(m[2]);
return out;
}
/** `file: specifier` for each import of `files` (paths under `root`) that resolves into scripts/ or hooks/ of `root`. */
function forbiddenImports(root, files) {
const out = [];
for (const rel of files) {
const abs = join(root, rel);
for (const spec of importSpecifiers(readFileSync(abs, 'utf8'))) {
if (!spec.startsWith('.') && !spec.startsWith('/')) continue;
const target = relative(root, resolve(dirname(abs), spec)).split(sep).join('/');
if (FORBIDDEN_DIRS.some((d) => target === d || target.startsWith(`${d}/`))) out.push(`${rel}: ${spec}`);
}
}
return out;
}
test('[PRB] the new review modules import no scripts/ or hooks/ code', () => {
assert.deepEqual([...NEW_LIB].sort(), [
'lib/review/engine-compare.mjs', 'lib/review/instruction-scope.mjs', 'lib/review/merge-evidence.mjs',
'lib/review/pre-existing.mjs', 'lib/review/review-answers.mjs', 'lib/review/review-finalize.mjs',
'lib/review/review-policy.mjs', 'lib/review/review-rounds.mjs', 'lib/util/doors.mjs',
], 'the scanner reads exactly these nine files');
for (const rel of NEW_LIB) assert.ok(existsSync(join(ROOT, rel)), `missing file ${rel}`);
// The real files: nothing.
assert.deepEqual(forbiddenImports(ROOT, NEW_LIB), [], 'a new review module imports scripts/ or hooks/ code');
const tmp = mkdtempSync(join(tmpdir(), 'prb-imports-'));
try {
for (const rel of NEW_LIB) {
mkdirSync(dirname(join(tmp, rel)), { recursive: true });
writeFileSync(join(tmp, rel), read(rel));
}
// Control: the copy reads like the real files.
assert.deepEqual(forbiddenImports(tmp, NEW_LIB), [], 'Control: the clean copy reports nothing');
// A planted import of a scripts/ module is reported.
writeFileSync(join(tmp, NEW_LIB[0]), PLANTED + read(NEW_LIB[0]));
assert.deepEqual(forbiddenImports(tmp, NEW_LIB), [`${NEW_LIB[0]}: ../../scripts/release-check.mjs`],
'the scanner reports the planted import of ../../scripts/release-check.mjs');
// The clean copy again: nothing.
writeFileSync(join(tmp, NEW_LIB[0]), read(NEW_LIB[0]));
assert.deepEqual(forbiddenImports(tmp, NEW_LIB), [], 'the clean copy again reports nothing');
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});

View file

@ -1,257 +0,0 @@
// tests/lib/fix-round-4-5b2-b-scripts.test.mjs
// The form of fix round 4.5b2 part B's shell scripts and mutation module (plan
// .claude/projects/2026-10-31-review-gate-4-5b2, Step 16): the red wrapper,
// the mutations, the regression script and the round's end-to-end run.
// Untagged on purpose and listed in no red wrapper's FILES: these checks are
// not a part-B fix.
// - each script exists, is ASCII only, passes /bin/bash -n and uses no
// bash 4 form; the mutation module exists and is ASCII only;
// - the red wrapper hands the shared red script the base 96fba75, exactly
// one --other-ids, whose words are the IDS= words of the six earlier
// wrappers in order (4-5a, 4-5b, part 2, part 3, part 4, 4-5b2-a), the
// round's twenty ids, the part-B mutation module and exactly the twelve
// tagged test files of this round (run from a temp root next to a stub
// red-round.sh that prints its arguments, the method of
// tests/lib/red-script.test.mjs);
// - with any earlier wrapper lacking its IDS= line, the red wrapper exits 2
// with a setup failure and never runs the shared script;
// - red-mutate.mjs list prints exactly the one mutation `FXS-fixture FXS`
// (the FXS case reads a file under tests/, which the red script copies
// into the base clone, so only a mutation makes it red there);
// - every test file under tests/ that holds one of the round's tags is one
// of the twelve, and none of the twelve is in an earlier wrapper's FILES;
// - the regression script runs the 4-5b red proof and the 4.5a, part-4 and
// part-A end-to-end scripts in that order, prints each exit and exits
// with the first non-zero status (run from a temp root with stubs).
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { childEnv } from '../helpers/child-env.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const RED = 'tests/fix-round-4-5b2-b-red.sh';
const REGRESSION = 'tests/fix-round-4-5b2-b-regression.sh';
const E2E = 'tests/e2e-fix-round-4-5b2-b.sh';
const MUTATIONS = 'tests/fix-round-4-5b2-b-mutations.mjs';
const SELF = 'tests/lib/fix-round-4-5b2-b-scripts.test.mjs';
const BASE = '96fba75996be4fa295d80d7a42c8e3554f7aaae4';
const OLDER = [
'tests/fix-round-4-5a-red.sh',
'tests/fix-round-4-5b-red.sh',
'tests/fix-round-4-5b-part-2-red.sh',
'tests/fix-round-4-5b-part-3-red.sh',
'tests/fix-round-4-5b-part-4-red.sh',
'tests/fix-round-4-5b2-a-red.sh',
];
const PARTB_IDS = ['CAT', 'V12', 'RAP', 'ANS', 'RLS', 'PRX', 'RND', 'CAP', 'CRY', 'FXL', 'POL', 'ENG', 'CMD', 'DPR', 'MEV', 'PRB',
'RMP', 'PXB', 'FN1', 'FXS'];
const PARTB_FILES = [
'tests/lib/review-answers.test.mjs',
'tests/lib/review-validator-1-2.test.mjs',
'tests/lib/pre-existing.test.mjs',
'tests/lib/review-rounds.test.mjs',
'tests/lib/review-policy.test.mjs',
'tests/lib/engine-findings-compare.test.mjs',
'tests/lib/claude-md-scope.test.mjs',
'tests/hooks/merge-evidence.test.mjs',
'tests/lib/fix-round-4-5b2-b-prose.test.mjs',
'tests/lib/reviewed-commit-parents.test.mjs',
'tests/lib/pre-existing-bound.test.mjs',
'tests/lib/review-finalized.test.mjs',
];
const REGRESSION_RUNS = [
'tests/fix-round-4-5b-red.sh',
'tests/e2e-fix-round-4-5a.sh',
'tests/e2e-fix-round-4-5b-part-4.sh',
'tests/e2e-fix-round-4-5b2-a.sh',
];
/** The shared script's options that take a value; every other argument after them is a test file. */
const VALUE_OPTIONS = ['--base', '--ids', '--other-ids', '--mutations'];
/** A repo file's bytes; the file must exist. */
function bytes(rel) {
assert.ok(existsSync(join(ROOT, rel)), `missing file ${rel}`);
return readFileSync(join(ROOT, rel));
}
/** The single-quoted words of a wrapper's one <name>='...' line. */
function wordsOf(rel, name) {
const lines = bytes(rel).toString('utf8').split('\n').filter((l) => l.startsWith(`${name}=`));
assert.equal(lines.length, 1, `expected one ${name}= line in ${rel}; found ${lines.length}`);
const m = lines[0].match(new RegExp(`^${name}='([^']*)'$`));
assert.ok(m, `the ${name}= line of ${rel} is not one single-quoted list: ${lines[0]}`);
return m[1].split(/\s+/).filter(Boolean);
}
test('part-B scripts: each exists, is ASCII only, passes /bin/bash -n and uses no bash 4 form', () => {
for (const rel of [RED, REGRESSION, E2E]) {
const buf = bytes(rel);
assert.equal(buf.findIndex((b) => b > 127), -1, `${rel} is not ASCII only`);
const r = spawnSync('/bin/bash', ['-n', join(ROOT, rel)], { encoding: 'utf8', env: childEnv() });
assert.equal(r.status, 0, `${rel} does not pass /bin/bash -n: ${r.stderr}`);
const text = buf.toString('utf8');
for (const bash4 of [/\bdeclare -A\b/, /\bmapfile\b/, /\breadarray\b/, /\|&/]) {
assert.doesNotMatch(text, bash4, `${rel} uses ${bash4}, which bash 3.2 lacks`);
}
}
const mod = bytes(MUTATIONS);
assert.equal(mod.findIndex((b) => b > 127), -1, `${MUTATIONS} is not ASCII only`);
});
/** A stand-in for the shared script: one ARG: line per argument, exit 0. */
const STUB = `#!/bin/bash
for a in "$@"; do printf 'ARG:%s\\n' "$a"; done
exit 0
`;
/** A temp root with the part-B wrapper, the six earlier wrappers and the stub as tests/helpers/red-round.sh. */
function stubRoot(base) {
const dir = join(base, 'root');
mkdirSync(join(dir, 'tests', 'helpers'), { recursive: true });
for (const rel of [RED, ...OLDER]) writeFileSync(join(dir, rel), bytes(rel));
writeFileSync(join(dir, 'tests', 'helpers', 'red-round.sh'), STUB, { mode: 0o755 });
return dir;
}
/** The part-B wrapper run from a temp root: status, output and the arguments the stub saw. */
function runWrapper(dir) {
const r = spawnSync('/bin/bash', [join(dir, RED)], { cwd: tmpdir(), encoding: 'utf8', timeout: 30000, env: childEnv() });
const argv = (r.stdout || '').split('\n').filter((l) => l.startsWith('ARG:')).map((l) => l.slice(4));
return { status: r.status, out: `${r.stdout}${r.stderr}`, stderr: r.stderr || '', argv };
}
/** The arguments that are neither an option nor an option's value: the test files. */
function filesOf(argv) {
const files = [];
for (let i = 0; i < argv.length; i += 1) {
if (VALUE_OPTIONS.includes(argv[i])) { i += 1; continue; }
if (argv[i].startsWith('--')) continue;
files.push(argv[i]);
}
return files;
}
test('part-B scripts: the red wrapper passes the base, one --other-ids with the six earlier rounds\' ids in order, its twenty ids, its mutations and its twelve tagged files', () => {
const text = bytes(RED).toString('utf8');
const execLines = text.split('\n').filter((l) => /^exec /.test(l));
assert.equal(execLines.length, 1, `${RED} must hold one exec line; found ${execLines.length}`);
assert.equal(execLines[0].split('--other-ids').length - 1, 1, `${RED}: the exec line must pass --other-ids exactly once: ${execLines[0]}`);
const base = mkdtempSync(join(tmpdir(), 'partB-red-wrap-'));
try {
const dir = stubRoot(base);
const r = runWrapper(dir);
assert.equal(r.status, 0, `${RED} did not run the shared script:\n${r.out}`);
const baseAt = r.argv.indexOf('--base');
assert.ok(baseAt >= 0, `${RED} passed no --base:\n${r.out}`);
assert.equal(r.argv[baseAt + 1], BASE, `${RED}: --base is not ${BASE}\n${r.out}`);
const at = r.argv.map((a, i) => (a === '--other-ids' ? i : -1)).filter((i) => i >= 0);
assert.equal(at.length, 1, `${RED} handed the shared script --other-ids ${at.length} times:\n${r.out}`);
const other = (r.argv[at[0] + 1] || '').split(/\s+/).filter(Boolean);
assert.deepEqual(other, OLDER.flatMap((rel) => wordsOf(rel, 'IDS')),
`${RED}: --other-ids is not the IDS= words of ${OLDER.join(', then ')}\n${r.out}`);
for (const id of PARTB_IDS) {
assert.ok(!other.includes(id), `${RED}: the round's tag ${id} is on --other-ids, so it would never be judged\n${r.out}`);
}
const idsAt = r.argv.indexOf('--ids');
assert.ok(idsAt >= 0, `${RED} passed no --ids:\n${r.out}`);
assert.deepEqual(r.argv[idsAt + 1].split(/\s+/).filter(Boolean), PARTB_IDS, `${RED}: --ids is not ${PARTB_IDS.join(' ')}\n${r.out}`);
const mutAt = r.argv.indexOf('--mutations');
assert.ok(mutAt >= 0, `${RED} passed no --mutations:\n${r.out}`);
assert.ok(r.argv[mutAt + 1].startsWith('/'), `${RED}: --mutations is not an absolute path: ${r.argv[mutAt + 1]}`);
assert.ok(r.argv[mutAt + 1].endsWith(`/${MUTATIONS}`), `${RED}: --mutations is not ${MUTATIONS}: ${r.argv[mutAt + 1]}`);
assert.deepEqual([...filesOf(r.argv)].sort(), [...PARTB_FILES].sort(),
`${RED}: the test files are not exactly the twelve tagged files of this round\n${r.out}`);
} finally {
rmSync(base, { recursive: true, force: true });
}
});
test('part-B scripts: the red wrapper exits 2 with a setup failure when an earlier wrapper has no IDS= line', () => {
for (const missing of OLDER) {
const base = mkdtempSync(join(tmpdir(), 'partB-red-noids-'));
try {
const dir = stubRoot(base);
const text = bytes(missing).toString('utf8');
writeFileSync(join(dir, missing), text.split('\n').filter((l) => !l.startsWith('IDS=')).join('\n'));
const r = runWrapper(dir);
assert.equal(r.status, 2, `${RED}: ${missing} without an IDS= line is not exit 2\n${r.out}`);
assert.match(r.stderr, /setup failure/, `${RED}: no setup failure line when ${missing} has no IDS= line\n${r.out}`);
assert.ok(r.stderr.includes(missing), `${RED}: the setup failure line does not name ${missing}\n${r.out}`);
assert.deepEqual(r.argv, [], `${RED} ran the shared script though ${missing} has no IDS= line\n${r.out}`);
} finally {
rmSync(base, { recursive: true, force: true });
}
}
});
test('part-B scripts: red-mutate.mjs list prints exactly the mutation `FXS-fixture FXS`', () => {
assert.ok(existsSync(join(ROOT, MUTATIONS)), `missing file ${MUTATIONS}`);
const r = spawnSync(process.execPath, [join(ROOT, 'tests', 'helpers', 'red-mutate.mjs'), 'list', MUTATIONS],
{ cwd: ROOT, encoding: 'utf8', timeout: 30000, env: childEnv() });
assert.equal(r.status, 0, `red-mutate.mjs list ${MUTATIONS} exited ${r.status}:\n${r.stdout}${r.stderr}`);
const lines = (r.stdout || '').split('\n').filter((l) => l !== '');
assert.deepEqual(lines, ['FXS-fixture FXS'],
`red-mutate.mjs list ${MUTATIONS} does not print exactly the mutation FXS-fixture FXS:\n${r.stdout}${r.stderr}`);
});
/** Every .mjs file under tests/, as a repo-relative path. */
function testModules(dir = join(ROOT, 'tests')) {
const out = [];
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) out.push(...testModules(p));
else if (e.name.endsWith('.mjs')) out.push(relative(ROOT, p).split('\\').join('/'));
}
return out;
}
test('part-B scripts: every file holding a part-B tag is one of the twelve, and none of the twelve is in an earlier wrapper\'s FILES', () => {
const tag = new RegExp(`\\[(?:${PARTB_IDS.join('|')})\\]`);
const tagged = testModules().filter((rel) => rel !== SELF && tag.test(readFileSync(join(ROOT, rel), 'utf8')));
assert.deepEqual([...tagged].sort(), [...PARTB_FILES].sort(),
`the files under tests/ that hold a part-B tag are not exactly the twelve of ${RED}`);
for (const rel of OLDER) {
const files = wordsOf(rel, 'FILES');
for (const f of PARTB_FILES) {
assert.ok(!files.includes(f), `${f} is in the FILES of the earlier wrapper ${rel}`);
}
}
});
/** A temp root with the regression script and a stub per script it runs, each exiting with its given status. */
function regressionRoot(base, codes) {
const dir = join(base, 'root');
mkdirSync(join(dir, 'tests'), { recursive: true });
writeFileSync(join(dir, REGRESSION), bytes(REGRESSION));
REGRESSION_RUNS.forEach((rel, i) => {
writeFileSync(join(dir, rel), `#!/bin/bash\necho "RAN:${rel}"\nexit ${codes[i]}\n`, { mode: 0o755 });
});
return dir;
}
test('part-B scripts: the regression script runs the 4-5b red proof and three end-to-end scripts in order and exits with the first non-zero status', () => {
for (const [codes, want] of [[[0, 0, 0, 0], 0], [[0, 3, 0, 5], 3], [[4, 0, 0, 0], 4], [[0, 0, 0, 6], 6]]) {
const base = mkdtempSync(join(tmpdir(), 'partB-regression-'));
try {
const dir = regressionRoot(base, codes);
const r = spawnSync('/bin/bash', [join(dir, REGRESSION)], { cwd: tmpdir(), encoding: 'utf8', timeout: 30000, env: childEnv() });
const out = `${r.stdout}${r.stderr}`;
assert.equal(r.status, want, `${REGRESSION} with exits ${codes.join(',')} exited ${r.status}, not ${want}\n${out}`);
const ran = (r.stdout || '').split('\n').filter((l) => l.startsWith('RAN:')).map((l) => l.slice(4));
assert.deepEqual(ran, REGRESSION_RUNS, `${REGRESSION} did not run all four scripts in order\n${out}`);
REGRESSION_RUNS.forEach((rel, i) => {
assert.ok((r.stdout || '').includes(`regression: ${rel} exited ${codes[i]}`),
`${REGRESSION} printed no "regression: ${rel} exited ${codes[i]}" line\n${out}`);
});
} finally {
rmSync(base, { recursive: true, force: true });
}
}
});

View file

@ -1,212 +0,0 @@
// tests/lib/fix-round-4-5b2-c-scripts.test.mjs
// The form of fix round 4.5b2 part C's shell scripts and mutation module (plan
// .claude/plans/trekplan-2026-11-03-review-gate-4-5b2-fixes.md, Step 7): the
// red wrapper, the mutations and the round's end-to-end run.
// Untagged on purpose and listed in no red wrapper's FILES: these checks are
// not a part-C fix.
// - each script exists, is ASCII only, passes /bin/bash -n and uses no
// bash 4 form; the mutation module exists and is ASCII only;
// - the red wrapper hands the shared red script the base f1f5ab5, exactly
// one --other-ids, whose words are the IDS= words of the seven earlier
// wrappers in order (4-5a, 4-5b, part 2, part 3, part 4, 4-5b2-a,
// 4-5b2-b), the round's nine ids, the part-C mutation module and exactly
// the seven tagged test files of this round (run from a temp root next to
// a stub red-round.sh that prints its arguments, the method of
// tests/lib/fix-round-4-5b2-b-scripts.test.mjs);
// - with any earlier wrapper lacking its IDS= line, the red wrapper exits 2
// with a setup failure and never runs the shared script;
// - red-mutate.mjs list exits 0 and prints no mutation (no case of this
// round reads a file under tests/, so none needs one);
// - every test file under tests/ that holds one of the round's tags is one
// of the seven, and none of the seven is in an earlier wrapper's FILES.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { childEnv } from '../helpers/child-env.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const RED = 'tests/fix-round-4-5b2-c-red.sh';
const E2E = 'tests/e2e-fix-round-4-5b2-c.sh';
const MUTATIONS = 'tests/fix-round-4-5b2-c-mutations.mjs';
const SELF = 'tests/lib/fix-round-4-5b2-c-scripts.test.mjs';
const BASE = 'f1f5ab5528a376050dd03b7f7ac43f887596c9b4';
const OLDER = [
'tests/fix-round-4-5a-red.sh',
'tests/fix-round-4-5b-red.sh',
'tests/fix-round-4-5b-part-2-red.sh',
'tests/fix-round-4-5b-part-3-red.sh',
'tests/fix-round-4-5b-part-4-red.sh',
'tests/fix-round-4-5b2-a-red.sh',
'tests/fix-round-4-5b2-b-red.sh',
];
const PARTC_IDS = ['DUP', 'RSP', 'NRL', 'SYM', 'WHD', 'AMB', 'CLU', 'MMG', 'PRC'];
const PARTC_FILES = [
'tests/lib/unique-finding-ids.test.mjs',
'tests/lib/review-rounds-stopped-prepare.test.mjs',
'tests/lib/instruction-scope-depth.test.mjs',
'tests/lib/merge-evidence-writer-head.test.mjs',
'tests/hooks/merge-evidence-ambiguous.test.mjs',
'tests/hooks/merge-evidence-many.test.mjs',
'tests/lib/fix-round-4-5b2-c-prose.test.mjs',
];
/** The shared script's options that take a value; every other argument after them is a test file. */
const VALUE_OPTIONS = ['--base', '--ids', '--other-ids', '--mutations'];
/** A repo file's bytes; the file must exist. */
// Copied from tests/lib/fix-round-4-5b2-b-scripts.test.mjs (bytes).
function bytes(rel) {
assert.ok(existsSync(join(ROOT, rel)), `missing file ${rel}`);
return readFileSync(join(ROOT, rel));
}
/** The single-quoted words of a wrapper's one <name>='...' line. */
// Copied from tests/lib/fix-round-4-5b2-b-scripts.test.mjs (wordsOf).
function wordsOf(rel, name) {
const lines = bytes(rel).toString('utf8').split('\n').filter((l) => l.startsWith(`${name}=`));
assert.equal(lines.length, 1, `expected one ${name}= line in ${rel}; found ${lines.length}`);
const m = lines[0].match(new RegExp(`^${name}='([^']*)'$`));
assert.ok(m, `the ${name}= line of ${rel} is not one single-quoted list: ${lines[0]}`);
return m[1].split(/\s+/).filter(Boolean);
}
test('part-C scripts: each exists, is ASCII only, passes /bin/bash -n and uses no bash 4 form', () => {
for (const rel of [RED, E2E]) {
const buf = bytes(rel);
assert.equal(buf.findIndex((b) => b > 127), -1, `${rel} is not ASCII only`);
const r = spawnSync('/bin/bash', ['-n', join(ROOT, rel)], { encoding: 'utf8', env: childEnv() });
assert.equal(r.status, 0, `${rel} does not pass /bin/bash -n: ${r.stderr}`);
const text = buf.toString('utf8');
for (const bash4 of [/\bdeclare -A\b/, /\bmapfile\b/, /\breadarray\b/, /\|&/]) {
assert.doesNotMatch(text, bash4, `${rel} uses ${bash4}, which bash 3.2 lacks`);
}
}
const mod = bytes(MUTATIONS);
assert.equal(mod.findIndex((b) => b > 127), -1, `${MUTATIONS} is not ASCII only`);
});
/** A stand-in for the shared script: one ARG: line per argument, exit 0. */
const STUB = `#!/bin/bash
for a in "$@"; do printf 'ARG:%s\\n' "$a"; done
exit 0
`;
/** A temp root with the part-C wrapper, the seven earlier wrappers and the stub as tests/helpers/red-round.sh. */
function stubRoot(base) {
const dir = join(base, 'root');
mkdirSync(join(dir, 'tests', 'helpers'), { recursive: true });
for (const rel of [RED, ...OLDER]) writeFileSync(join(dir, rel), bytes(rel));
writeFileSync(join(dir, 'tests', 'helpers', 'red-round.sh'), STUB, { mode: 0o755 });
return dir;
}
/** The part-C wrapper run from a temp root: status, output and the arguments the stub saw. */
function runWrapper(dir) {
const r = spawnSync('/bin/bash', [join(dir, RED)], { cwd: tmpdir(), encoding: 'utf8', timeout: 30000, env: childEnv() });
const argv = (r.stdout || '').split('\n').filter((l) => l.startsWith('ARG:')).map((l) => l.slice(4));
return { status: r.status, out: `${r.stdout}${r.stderr}`, stderr: r.stderr || '', argv };
}
/** The arguments that are neither an option nor an option's value: the test files. */
function filesOf(argv) {
const files = [];
for (let i = 0; i < argv.length; i += 1) {
if (VALUE_OPTIONS.includes(argv[i])) { i += 1; continue; }
if (argv[i].startsWith('--')) continue;
files.push(argv[i]);
}
return files;
}
test('part-C scripts: the red wrapper passes the base, one --other-ids with the seven earlier rounds\' ids in order, its nine ids, its mutations and its seven tagged files', () => {
const text = bytes(RED).toString('utf8');
const execLines = text.split('\n').filter((l) => /^exec /.test(l));
assert.equal(execLines.length, 1, `${RED} must hold one exec line; found ${execLines.length}`);
assert.equal(execLines[0].split('--other-ids').length - 1, 1, `${RED}: the exec line must pass --other-ids exactly once: ${execLines[0]}`);
const base = mkdtempSync(join(tmpdir(), 'partC-red-wrap-'));
try {
const dir = stubRoot(base);
const r = runWrapper(dir);
assert.equal(r.status, 0, `${RED} did not run the shared script:\n${r.out}`);
const baseAt = r.argv.indexOf('--base');
assert.ok(baseAt >= 0, `${RED} passed no --base:\n${r.out}`);
assert.equal(r.argv[baseAt + 1], BASE, `${RED}: --base is not ${BASE}\n${r.out}`);
const at = r.argv.map((a, i) => (a === '--other-ids' ? i : -1)).filter((i) => i >= 0);
assert.equal(at.length, 1, `${RED} handed the shared script --other-ids ${at.length} times:\n${r.out}`);
const other = (r.argv[at[0] + 1] || '').split(/\s+/).filter(Boolean);
assert.deepEqual(other, OLDER.flatMap((rel) => wordsOf(rel, 'IDS')),
`${RED}: --other-ids is not the IDS= words of ${OLDER.join(', then ')}\n${r.out}`);
for (const id of PARTC_IDS) {
assert.ok(!other.includes(id), `${RED}: the round's tag ${id} is on --other-ids, so it would never be judged\n${r.out}`);
}
const idsAt = r.argv.indexOf('--ids');
assert.ok(idsAt >= 0, `${RED} passed no --ids:\n${r.out}`);
assert.deepEqual(r.argv[idsAt + 1].split(/\s+/).filter(Boolean), PARTC_IDS, `${RED}: --ids is not ${PARTC_IDS.join(' ')}\n${r.out}`);
const mutAt = r.argv.indexOf('--mutations');
assert.ok(mutAt >= 0, `${RED} passed no --mutations:\n${r.out}`);
assert.ok(r.argv[mutAt + 1].startsWith('/'), `${RED}: --mutations is not an absolute path: ${r.argv[mutAt + 1]}`);
assert.ok(r.argv[mutAt + 1].endsWith(`/${MUTATIONS}`), `${RED}: --mutations is not ${MUTATIONS}: ${r.argv[mutAt + 1]}`);
assert.deepEqual([...filesOf(r.argv)].sort(), [...PARTC_FILES].sort(),
`${RED}: the test files are not exactly the seven tagged files of this round\n${r.out}`);
} finally {
rmSync(base, { recursive: true, force: true });
}
});
test('part-C scripts: the red wrapper exits 2 with a setup failure when an earlier wrapper has no IDS= line', () => {
for (const missing of OLDER) {
const base = mkdtempSync(join(tmpdir(), 'partC-red-noids-'));
try {
const dir = stubRoot(base);
const text = bytes(missing).toString('utf8');
writeFileSync(join(dir, missing), text.split('\n').filter((l) => !l.startsWith('IDS=')).join('\n'));
const r = runWrapper(dir);
assert.equal(r.status, 2, `${RED}: ${missing} without an IDS= line is not exit 2\n${r.out}`);
assert.match(r.stderr, /setup failure/, `${RED}: no setup failure line when ${missing} has no IDS= line\n${r.out}`);
assert.ok(r.stderr.includes(missing), `${RED}: the setup failure line does not name ${missing}\n${r.out}`);
assert.deepEqual(r.argv, [], `${RED} ran the shared script though ${missing} has no IDS= line\n${r.out}`);
} finally {
rmSync(base, { recursive: true, force: true });
}
}
});
test('part-C scripts: red-mutate.mjs list exits 0 and prints no mutation', () => {
assert.ok(existsSync(join(ROOT, MUTATIONS)), `missing file ${MUTATIONS}`);
const r = spawnSync(process.execPath, [join(ROOT, 'tests', 'helpers', 'red-mutate.mjs'), 'list', join(ROOT, MUTATIONS)],
{ cwd: ROOT, encoding: 'utf8', timeout: 30000, env: childEnv() });
assert.equal(r.status, 0, `red-mutate.mjs list ${MUTATIONS} exited ${r.status}:\n${r.stdout}${r.stderr}`);
const lines = (r.stdout || '').split('\n').filter((l) => l !== '');
assert.deepEqual(lines, [], `red-mutate.mjs list ${MUTATIONS} must print no mutation:\n${r.stdout}${r.stderr}`);
});
/** Every .mjs file under tests/, as a repo-relative path. */
// Copied from tests/lib/fix-round-4-5b2-b-scripts.test.mjs (testModules).
function testModules(dir = join(ROOT, 'tests')) {
const out = [];
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) out.push(...testModules(p));
else if (e.name.endsWith('.mjs')) out.push(relative(ROOT, p).split('\\').join('/'));
}
return out;
}
test('part-C scripts: every file holding a part-C tag is one of the seven, and none of the seven is in an earlier wrapper\'s FILES', () => {
const tag = new RegExp(`\\[(?:${PARTC_IDS.join('|')})\\]`);
const tagged = testModules().filter((rel) => rel !== SELF && tag.test(readFileSync(join(ROOT, rel), 'utf8')));
assert.deepEqual([...tagged].sort(), [...PARTC_FILES].sort(),
`the files under tests/ that hold a part-C tag are not exactly the seven of ${RED}`);
for (const rel of OLDER) {
const files = wordsOf(rel, 'FILES');
for (const f of PARTC_FILES) {
assert.ok(!files.includes(f), `${f} is in the FILES of the earlier wrapper ${rel}`);
}
}
});

View file

@ -194,3 +194,13 @@ test('trekplan Phase 11: plan approval (answer check → append plan → commit)
assert.match(line(p11, 'util/artifact-commit.mjs', '--store'), /--store \{artifact_store\} --artifact "\{plan_path\}"/);
assert.match(p11, /AskUserQuestion/);
});
test('/trekbrief Step 4c does not call the brief-reviewer spawn foreground or blocking, and waits for it', () => {
const start = brief.indexOf('**Step 4c — Launch brief-reviewer**');
const end = brief.indexOf('**Step 4d — Parse JSON scores**');
assert.ok(start !== -1 && end > start);
const step = brief.slice(start, end);
assert.doesNotMatch(step, /foreground, blocking/);
assert.match(step, /wait\s+for\s+(its|the agent's)\s+(result|completion)/i);
assert.match(step, /(background|asynchronous)/i, 'says how the spawn actually runs');
});

View file

@ -0,0 +1,118 @@
// tests/lib/lib-imports.test.mjs
// Architecture rule: code under lib/ never imports from scripts/ (or, for the
// review modules, from hooks/). Each check runs on the real files and on a
// planted import, so a scanner that sees nothing cannot pass.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const read = (rel) => readFileSync(join(ROOT, rel), 'utf8');
// ---- lib/ never imports from scripts/ ----------------------------------------
/**
* The import specifiers in `text` (a file at `file`) that resolve into the
* repo's scripts/ directory: static and side-effect imports, re-exports and
* dynamic imports with a string literal.
*/
function importsIntoScripts(file, text) {
const re = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)|\b(?:import|export)\s+(?:[^'";]*?\s+from\s+)?['"]([^'"]+)['"]/g;
const scripts = join(ROOT, 'scripts') + sep;
const hits = [];
for (const m of text.matchAll(re)) {
const spec = m[1] ?? m[2];
if (!spec.startsWith('.') && !spec.startsWith('/')) continue;
if (resolve(dirname(file), spec).startsWith(scripts)) hits.push(spec);
}
return hits;
}
/** Every .mjs file under `dir`, recursively. */
function mjsFiles(dir) {
const out = [];
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) out.push(...mjsFiles(p));
else if (e.name.endsWith('.mjs')) out.push(p);
}
return out;
}
test('arch: lib/ never imports from scripts/, and the scan reports a planted import (control)', () => {
const planted = importsIntoScripts(join(ROOT, 'lib', 'review', 'x.mjs'), "import x from '../../scripts/release-check.mjs';\n");
assert.deepEqual(planted, ['../../scripts/release-check.mjs'], 'control: the scan does not report a planted import from scripts/');
const files = mjsFiles(join(ROOT, 'lib'));
assert.ok(files.length > 10, `the scan found only ${files.length} files under lib/`);
const bad = files.flatMap((f) => importsIntoScripts(f, readFileSync(f, 'utf8')).map((s) => `${f.slice(ROOT.length + 1)}: ${s}`));
assert.deepEqual(bad, [], 'lib/ imports from scripts/');
});
// ---- the review modules import no scripts/ or hooks/ code ---------------------
const REVIEW_LIB = [
'lib/review/review-answers.mjs', 'lib/review/pre-existing.mjs', 'lib/review/review-rounds.mjs',
'lib/review/review-policy.mjs', 'lib/review/engine-compare.mjs', 'lib/review/instruction-scope.mjs',
'lib/review/review-finalize.mjs', 'lib/review/merge-evidence.mjs', 'lib/util/doors.mjs',
];
const FORBIDDEN_DIRS = ['scripts', 'hooks'];
const PLANTED = "import { checkReview } from '../../scripts/release-check.mjs';\n";
/** The specifiers of a module's import lines: `import … from`, bare `import '…'`, `export … from`, `import('…')`. */
function importSpecifiers(src) {
const out = [];
for (const m of src.matchAll(/^[ \t]*import\b(?!\s*\()[^;]*?(['"])([^'"\n]+)\1/gm)) out.push(m[2]);
for (const m of src.matchAll(/^[ \t]*export\s*(?:\*|\{)[^;]*?\bfrom\s*(['"])([^'"\n]+)\1/gm)) out.push(m[2]);
for (const m of src.matchAll(/\bimport\s*\(\s*(['"])([^'"\n]+)\1\s*\)/g)) out.push(m[2]);
return out;
}
/** `file: specifier` for each import of `files` (paths under `root`) that resolves into scripts/ or hooks/ of `root`. */
function forbiddenImports(root, files) {
const out = [];
for (const rel of files) {
const abs = join(root, rel);
for (const spec of importSpecifiers(readFileSync(abs, 'utf8'))) {
if (!spec.startsWith('.') && !spec.startsWith('/')) continue;
const target = relative(root, resolve(dirname(abs), spec)).split(sep).join('/');
if (FORBIDDEN_DIRS.some((d) => target === d || target.startsWith(`${d}/`))) out.push(`${rel}: ${spec}`);
}
}
return out;
}
test('arch: the review modules import no scripts/ or hooks/ code', () => {
assert.deepEqual([...REVIEW_LIB].sort(), [
'lib/review/engine-compare.mjs', 'lib/review/instruction-scope.mjs', 'lib/review/merge-evidence.mjs',
'lib/review/pre-existing.mjs', 'lib/review/review-answers.mjs', 'lib/review/review-finalize.mjs',
'lib/review/review-policy.mjs', 'lib/review/review-rounds.mjs', 'lib/util/doors.mjs',
], 'the scanner reads exactly these nine files');
for (const rel of REVIEW_LIB) assert.ok(existsSync(join(ROOT, rel)), `missing file ${rel}`);
// The real files: nothing.
assert.deepEqual(forbiddenImports(ROOT, REVIEW_LIB), [], 'a review module imports scripts/ or hooks/ code');
const tmp = mkdtempSync(join(tmpdir(), 'lib-imports-'));
try {
for (const rel of REVIEW_LIB) {
mkdirSync(dirname(join(tmp, rel)), { recursive: true });
writeFileSync(join(tmp, rel), read(rel));
}
// Control: the copy reads like the real files.
assert.deepEqual(forbiddenImports(tmp, REVIEW_LIB), [], 'Control: the clean copy reports nothing');
// A planted import of a scripts/ module is reported.
writeFileSync(join(tmp, REVIEW_LIB[0]), PLANTED + read(REVIEW_LIB[0]));
assert.deepEqual(forbiddenImports(tmp, REVIEW_LIB), [`${REVIEW_LIB[0]}: ../../scripts/release-check.mjs`],
'the scanner reports the planted import of ../../scripts/release-check.mjs');
// The clean copy again: nothing.
writeFileSync(join(tmp, REVIEW_LIB[0]), read(REVIEW_LIB[0]));
assert.deepEqual(forbiddenImports(tmp, REVIEW_LIB), [], 'the clean copy again reports nothing');
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});

View file

@ -6,12 +6,14 @@
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFileSync, existsSync } from 'node:fs';
import { readFileSync, existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { MAX_ROUNDS } from '../../lib/validators/plan-gate.mjs';
import { DESIGN_SUBSECTIONS, OPTIONAL_STEP_FIELDS } from '../../lib/validators/plan-validator.mjs';
import { findSteps, PLAN_VERSION_CURRENT } from '../../lib/parsers/plan-schema.mjs';
import { findSection, findSteps, PLAN_VERSION_CURRENT } from '../../lib/parsers/plan-schema.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const read = rel => readFileSync(join(ROOT, rel), 'utf-8');
@ -127,3 +129,134 @@ test('the verification CLIs credit what they borrow and say they install nothing
assert.match(read('lib/verification/plan-scope-sweep.mjs'), /ai-native-sdlc \(MIT\)/);
assert.match(read('lib/validators/plan-gate.mjs'), /Kiro/);
});
// ---- helpers for the pins below ----------------------------------------------
/** Whitespace (line breaks too) collapsed to one space. */
const flat = (s) => s.replace(/\s+/g, ' ').trim();
/**
* The text of a heading's section (level from its #-count), up to the next
* heading of the same or a higher level outside a fence. The heading must exist.
*/
function sectionText(text, heading) {
const level = heading.match(/^#+/)[0].length;
const s = findSection(text, heading.replace(/^#+ /, ''), level);
assert.ok(s, `missing ${heading}`);
const lines = s.text.split('\n');
let fenced = false;
for (let i = 1; i < lines.length; i++) {
if (/^\s{0,3}(`{3,}|~{3,})/.test(lines[i])) fenced = !fenced;
const m = !fenced && lines[i].match(/^(#+) /);
if (m && m[1].length < level) return lines.slice(0, i).join('\n');
}
return s.text;
}
/**
* From the one line of `rel` holding `marker` through the end of the next
* blank-line-separated paragraph after the marker's own paragraph, flattened.
*/
function paraAfter(rel, marker) {
const lines = read(rel).split('\n');
const hits = lines.filter((l) => l.includes(marker)).length;
assert.equal(hits, 1, `${rel}: expected exactly one line holding "${marker}"; found ${hits}`);
const start = lines.findIndex((l) => l.includes(marker));
let i = start;
while (i < lines.length && lines[i].trim() !== '') i++;
while (i < lines.length && lines[i].trim() === '') i++;
while (i < lines.length && lines[i].trim() !== '') i++;
return flat(lines.slice(start, i).join('\n'));
}
// ---- spawn model: phase_signal_result.model, the agent's own model: as the fallback ----
const PASS_MODEL = /Pass `model: \{phase_signal_result\.model\}` at this spawn; without it \(the `--brief` branch, where the resolver does not run, or a resolver that printed nothing\) the agent's own `model:` applies\./;
test('spawn model: commands/trekplan.md pins no spawn to `model: "opus"`', () => {
assert.doesNotMatch(PLAN_CMD, /model: "opus"/);
});
for (const [where, marker] of [
['brief-reviewer (Phase 4b)', 'Launch the **brief-reviewer** agent.'],
['### Always launch (Phase 5)', '### Always launch (all codebase sizes):'],
['research-scout (Phase 5)', 'launch **research-scout** in'],
['plan-critic and scope-guardian (Phase 9)', 'it traces into `## Verification`.'],
]) {
test(`spawn model: ${where}: the spawn takes phase_signal_result.model, the agent's own model: as the fallback`, () => {
const part = paraAfter('commands/trekplan.md', marker);
assert.match(part, PASS_MODEL, `${where}: missing the resolved-model paragraph after "${marker}"`);
assert.doesNotMatch(part, /model: "opus"/);
});
}
test('spawn model: session-decomposer (Phase 1.5): --decompose has no resolved model, so the spawn passes no model:', () => {
const part = paraAfter('commands/trekplan.md', 'Launch the **session-decomposer** agent:');
assert.ok(part.includes('Phase 1 runs the model resolver only for `--project`, so `--decompose` has no resolved model: this spawn '
+ 'passes no `model:`, and the agent\'s own `model:` applies.'), `the session-decomposer paragraph does not say it passes no model: ${part}`);
assert.ok(!part.includes('phase_signal_result'), 'the session-decomposer spawn still names phase_signal_result');
assert.doesNotMatch(part, /model: "opus"/);
});
test('spawn model: convention-scanner (Phase 5): the spawn takes phase_signal_result.model, the frontmatter as the fallback', () => {
const part = paraAfter('commands/trekplan.md', 'use the `convention-scanner` plugin agent');
assert.match(part, /\(model: phase_signal_result\.model, the agent's frontmatter as the fallback\)/);
assert.doesNotMatch(part, /model: "opus"/);
});
test('spawn model: deep-dives (Phase 6): the spawn takes phase_signal_result.model, else opus', () => {
const part = paraAfter('commands/trekplan.md', 'spawn a targeted deep-dive agent');
assert.match(part, /\(model: phase_signal_result\.model, else opus — Explore is Claude Code's built-in agent and has no Voyage frontmatter to fall back to, subagent_type: "Explore"\)/);
assert.doesNotMatch(part, /model: "opus"/);
});
// ---- Phase 4 sizing: no turn budget, and the count it runs ---------------------
test('Phase 4: commands/trekplan.md promises no maxTurns budget, and Phase 4 calibrates no turns', () => {
assert.doesNotMatch(PLAN_CMD, /maxTurns/);
const p4 = flat(sectionText(PLAN_CMD, '## Phase 4 — Codebase sizing'));
assert.doesNotMatch(p4, /calibrate agent turns/);
assert.match(p4, /Determine codebase scale to decide whether convention-scanner runs\./);
assert.match(PLAN_CMD, /^\*\*All agents run for all codebase sizes\.\*\*$/m);
});
test('Phase 4: the size count counts .mjs, .cjs, .mts and .cts files', () => {
const p4 = sectionText(PLAN_CMD, '## Phase 4 — Codebase sizing');
const cmd = p4.match(/```\n(find [^\n]+)\n```/);
assert.ok(cmd, 'the find command stands in a fence of its own');
const dir = mkdtempSync(join(tmpdir(), 'voyage-sizing-'));
try {
for (const f of ['a.mjs', 'b.cjs', 'c.mts', 'd.cts', 'e.ts', 'skip.txt']) writeFileSync(join(dir, f), 'x\n');
const r = spawnSync('/bin/sh', ['-c', cmd[1]], { cwd: dir, encoding: 'utf8' });
assert.equal(r.status, 0, r.stderr);
assert.equal(r.stdout.trim(), '5');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
// ---- a step of only tests can never be proven red -----------------------------
const TESTS_IN_BUILDING_STEP = /test\s+for\s+a\s+behaviou?r\s+belongs\s+in\s+the\s+step\s+that\s+builds\s+it/i;
test('tests-only step: /trekplan says the test belongs in the step that builds the behaviour, and names the warning', () => {
assert.match(PLAN_CMD, TESTS_IN_BUILDING_STEP);
// At the vertical-slice rule and at the step field list.
assert.match(PLAN_CMD, /Steps are vertical slices[\s\S]{0,900}only tests[\s\S]{0,300}never be proven red/i);
assert.match(PLAN_CMD, /\*\*Test first:\*\* — [^\n]*\n[^\n]*step that builds/i);
assert.match(PLAN_CMD, /PLAN_STEP_TESTS_ONLY/);
});
test('tests-only step: the plan template carries the same rule', () => {
const t = read('templates/plan-template.md');
assert.match(t, TESTS_IN_BUILDING_STEP);
assert.match(t, /only tests[\s\S]{0,200}never be proven red/i);
});
test('tests-only step: plan-critic has it as a finding under Verification gaps, and still 12 dimensions', () => {
const critic = read('agents/plan-critic.md');
const d8 = sectionText(critic, '### 8. Verification gaps');
assert.match(d8, /PLAN_STEP_TESTS_ONLY/);
assert.match(d8, /only tests[\s\S]{0,400}\*\*major\*\*/i);
assert.equal((critic.match(/^### \d+\. /gm) ?? []).length, 12);
});

View file

@ -420,3 +420,62 @@ test('contentOverlap is exported and gives 1 for a shorter text held by a longer
assert.equal(dedupModule.contentOverlap('alpha beta', 'alpha gamma'), 0.5);
assert.equal(dedupModule.contentOverlap('', 'alpha'), 0);
});
// ---- the anchor rule: same file:line, same content ----------------------------
// Two findings from a real plan review round, verbatim (plan.md:248); they
// measured Jaccard 0.44 against the 0.7 threshold.
const PLAN_MD = '.claude/projects/2026-10-08-slug-sep-option/plan.md';
const CRITIC_248 = {
severity: 'minor', rule_key: 'verification/contract-brief-form-missing', file: PLAN_MD, line: 248,
text: "Verification runs `bash tests/e2e.sh` in the brief's own form but not `bash tests/contract.sh`, the form brief SC4 names.",
};
const GUARDIAN_248 = {
severity: 'minor', rule_key: 'gap-verification-command-form', file: PLAN_MD, line: 248,
text: "Brief SC4 runs 'bash tests/contract.sh'; Verification lists only '/bin/bash tests/contract.sh' (unlike e2e, where both forms are listed) - add the brief's form",
};
const pair = (a, b) => [
{ agent: 'plan-critic', payload: { findings: a } },
{ agent: 'scope-guardian', payload: { findings: b } },
];
test('anchor rule: the two findings on plan.md:248 merge into one, raised by both', () => {
const r = dedupFindings(pair([CRITIC_248], [GUARDIAN_248]));
assert.equal(r.findings.length, 1, JSON.stringify(r.findings, null, 2));
assert.deepEqual(r.findings[0].raised_by, ['plan-critic', 'scope-guardian']);
assert.equal(r.dedup_stats.anchor_dups, 1);
});
test('anchor rule: two different findings on the same line stay two', () => {
const other = {
severity: 'minor', rule_key: 'verification/no-negative-case', file: PLAN_MD, line: 248,
text: 'No criterion feeds an invalid separator such as a letter and checks the exit code.',
};
const r = dedupFindings(pair([CRITIC_248], [other]));
assert.equal(r.findings.length, 2);
});
test('anchor rule: a short finding held by a longer one on the same line stays its own', () => {
const at = (text, severity = 'minor') => ({ severity, rule_key: `k-${text.length}`, file: PLAN_MD, line: 40, text });
for (const [a, b] of [
['The token is checked.', 'The token is not checked before the write.'],
['Step 3 test is missing.', 'Step 3 is missing a checkpoint; the test file already exists.'],
['Verify runs npm test.', 'Verify must not run npm test, it is green before the step.'],
]) {
assert.equal(dedupFindings(pair([at(a)], [at(b)])).findings.length, 2, `${a} | ${b}`);
}
});
test('anchor rule: one reviewer\'s two findings on one line are never merged by the anchor rule', () => {
const r = dedupFindings([{ agent: 'plan-critic', payload: { findings: [CRITIC_248, { ...GUARDIAN_248, rule_key: 'other' }] } }]);
assert.equal(r.findings.length, 2);
assert.equal(r.dedup_stats.anchor_dups, 0);
});
test('anchor rule: the same content on another line is not merged by the anchor rule; the threshold elsewhere is unchanged', () => {
assert.equal(DEFAULT_THRESHOLD, 0.7);
const r = dedupFindings(pair([CRITIC_248], [{ ...GUARDIAN_248, line: 249 }]));
assert.equal(r.findings.length, 2);
const noLine = dedupFindings(pair([{ ...CRITIC_248, line: 0 }], [{ ...GUARDIAN_248, line: 0 }]));
assert.equal(noLine.findings.length, 2, 'line 0 is file-scoped, not an anchor');
});

View file

@ -7,6 +7,9 @@ import {
sliceSteps,
validatePlanHeadings,
extractPlanVersion,
stepTestsOnly,
onFailureAction,
ON_FAILURE_ACTIONS,
} from '../../lib/parsers/plan-schema.mjs';
const GOOD_PLAN = `---
@ -152,3 +155,50 @@ test('extractPlanVersion — recognizes the canonical plan template (regression
const tpl = readFileSync(new URL('../../templates/plan-template.md', import.meta.url), 'utf-8');
assert.equal(extractPlanVersion(tpl), '1.9');
});
// ---- stepTestsOnly: a step whose Files are all tests can never be proven red ----
test('stepTestsOnly: Files all tests (by path or as the Test first file) or not; no Files = not tests-only', () => {
const body = (files, tf = 'tests/x.test.mjs') => [
'### Step 1: x', '',
...(files === null ? [] : [`- **Files:** ${files}`]),
'- **Test first:**', ` - File: \`${tf}\` (new)`, ' - Verifies: x',
].join('\n');
assert.equal(stepTestsOnly(body('`tests/e2e.sh`, `tests/e2e-guard.sh`')).testsOnly, true);
assert.equal(stepTestsOnly(body('`src/a.test.ts` (new file), `lib/b.spec.js`, `pkg/c_test.go`')).testsOnly, true);
assert.equal(stepTestsOnly(body('`__tests__/a.js`, `test/b.js`')).testsOnly, true);
// The Test first file counts as a test wherever it lives.
assert.equal(stepTestsOnly(body('`scripts/check.sh`', 'scripts/check.sh')).testsOnly, true);
assert.equal(stepTestsOnly(body('`src/cli.mjs`, `tests/cli.test.mjs`')).testsOnly, false);
assert.equal(stepTestsOnly(body('`src/cli.mjs`')).testsOnly, false);
assert.equal(stepTestsOnly(body(null)).testsOnly, false);
assert.deepEqual(stepTestsOnly(body('`tests/e2e.sh`, `tests/e2e-guard.sh`')).files, ['tests/e2e.sh', 'tests/e2e-guard.sh']);
});
test('stepTestsOnly: a plain path beside a backticked one is still read - a step that also changes code is not tests-only', () => {
const body = (files) => ['### Step 1: x', '', `- **Files:** ${files}`].join('\n');
const mixed = stepTestsOnly(body('src/cli.mjs (modify), `tests/x.test.mjs` (new)'));
assert.deepEqual(mixed.files, ['src/cli.mjs', 'tests/x.test.mjs']);
assert.equal(mixed.testsOnly, false);
const list = stepTestsOnly(['### Step 1: x', '', '- **Files:**', ' - `tests/x.test.mjs` (new)', ' - src/cli.mjs'].join('\n'));
assert.equal(list.testsOnly, false, JSON.stringify(list.files));
});
test('stepTestsOnly: a document under spec/ or specs/ is not a test for this reading', () => {
const body = (files) => ['### Step 1: x', '', `- **Files:** ${files}`].join('\n');
assert.equal(stepTestsOnly(body('`docs/specs/api.md`, `specs/001-auth/plan.md`')).testsOnly, false);
assert.equal(stepTestsOnly(body('`spec/cli_spec.rb`')).testsOnly, true);
});
// ---- onFailureAction (K11) ---------------------------------------------------
test('K11: the four words, in any case and with markdown around them, are read; a longer word is not', () => {
assert.deepEqual([...ON_FAILURE_ACTIONS], ['revert', 'retry', 'skip', 'escalate']);
assert.equal(onFailureAction('revert — `git checkout -- x`'), 'revert');
assert.equal(onFailureAction('Escalate: stop the run'), 'escalate');
assert.equal(onFailureAction('**skip** this step'), 'skip');
assert.equal(onFailureAction('`retry` once'), 'retry');
assert.equal(onFailureAction('retrying is fine'), null);
assert.equal(onFailureAction('try again'), null);
assert.equal(onFailureAction(''), null);
});

View file

@ -1,7 +1,7 @@
// tests/lib/red-judge.test.mjs
// The red judge of fix round 4-5a (tests/helpers/red-judge.mjs) and its
// reporter (tests/helpers/red-reporter.mjs). The round's red script
// (tests/fix-round-4-5a-red.sh) runs every tagged test at ab6b32c and lets the
// The red judge (tests/helpers/red-judge.mjs) and its
// reporter (tests/helpers/red-reporter.mjs). The shared red script
// (tests/helpers/red-round.sh) runs every tagged test at a round's start point and lets the
// judge decide; a judge that always exits 0 would prove nothing. So the judge
// is fed planted JSON lines with the tags [X1] and [X2] (never a real id) and
// must exit 1 on each broken rule, naming it on one line.

View file

@ -492,3 +492,84 @@ test('red proof --plan/--step: a --tests naming another test is exit 2, and text
assert.match(c.stdout, /^ {2}plan_tests: tests\/sum\.test\.mjs$/m, c.stdout);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
// ---- tests_only: a step whose Files are all tests goes up to the plan -----------
/** A plan whose step 2 lists `files` and tests/sum.test.mjs as its test. */
function planWithStep2Files(dir, files) {
const p = join(dir, 'plan.local.md');
writeFileSync(p, buildPlan({ steps: [{}, { file: files, test: 'tests/sum.test.mjs', endToEnd: 'bash tests/e2e.sh' }] }));
return p;
}
test('red proof: not-red-at-base on a step whose Files are all tests says tests_only: true (red-only and full)', () => {
const { dir, base } = repo('a + b');
try {
addTest(dir);
const plan = planWithStep2Files(dir, 'tests/sum.test.mjs');
for (const redOnly of [true, false]) {
const r = proveRed({ cwd: dir, base, cmd: TEST_CMD, tests: ['tests/sum.test.mjs'], redOnly, plan, step: 2 });
assert.equal(r.reason, 'not-red-at-base', JSON.stringify(r));
assert.equal(r.tests_only, true, JSON.stringify(r));
}
const json = cli(['--cwd', dir, '--base', base, '--cmd', TEST_CMD, '--tests', 'tests/sum.test.mjs', '--plan', plan, '--step', '2', '--json']);
assert.equal(json.status, 1, json.stdout + json.stderr);
assert.equal(JSON.parse(json.stdout).tests_only, true);
// Text mode names the field too (the step helper reads it there).
const text = cli(['--red-only', '--cwd', dir, '--base', base, '--cmd', TEST_CMD, '--tests', 'tests/sum.test.mjs', '--plan', plan, '--step', '2']);
assert.match(text.stdout, /tests_only: true/);
// --step without --plan is a usage error, never silently dropped.
const lone = cli(['--cwd', dir, '--base', base, '--cmd', TEST_CMD, '--tests', 'tests/sum.test.mjs', '--step', '2']);
assert.equal(lone.status, 2, lone.stderr);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('red proof: not-red-at-base on a step that also changes code says tests_only: false (fix rounds)', () => {
const { dir, base } = repo('a + b');
try {
addTest(dir);
const plan = planWithStep2Files(dir, 'lib/sum.mjs');
const r = proveRed({ cwd: dir, base, cmd: TEST_CMD, tests: ['tests/sum.test.mjs'], plan, step: 2 });
assert.equal(r.reason, 'not-red-at-base');
assert.equal(r.tests_only, false, JSON.stringify(r));
// Without --plan nothing new is said: the field is absent.
const bare = proveRed({ cwd: dir, base, cmd: TEST_CMD, tests: ['tests/sum.test.mjs'] });
assert.equal(bare.reason, 'not-red-at-base');
assert.equal('tests_only' in bare, false);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
// ---- red_kind: the proof says what kind of red it was ---------------------------
/** A repo whose base commit has `moduleText` as lib/sum.mjs; the result has a correct sum and its test. */
function repoFromModule(moduleText) {
const dir = mkdtempSync(join(tmpdir(), 'red-kind-'));
sh(dir, 'git', ['init', '-q']);
sh(dir, 'git', ['config', 'user.email', 't@example.invalid']);
sh(dir, 'git', ['config', 'user.name', 'test']);
mkdirSync(join(dir, 'lib'));
writeFileSync(join(dir, 'lib', 'sum.mjs'), moduleText);
sh(dir, 'git', ['add', 'lib/sum.mjs']);
sh(dir, 'git', ['commit', '-q', '-m', 'base']);
addTest(dir);
writeFileSync(join(dir, 'lib', 'sum.mjs'), 'export function sum(a, b) { return a + b; }\n');
return { dir, base: sh(dir, 'git', ['rev-parse', 'HEAD']) };
}
test('red proof: a start point that cannot even load the test is proven with red_kind "load-error"', () => {
const { dir, base } = repoFromModule('export function other() { return 0; }\n');
try {
const r = proveRed({ cwd: dir, base, cmd: TEST_CMD, tests: ['tests/sum.test.mjs'] });
assert.equal(r.status, 'proven', JSON.stringify(r));
assert.equal(r.red_kind, 'load-error');
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('red proof: a named test that fails at the start point is red_kind "test-failure"', () => {
const { dir, base } = repoFromModule('export function sum(a, b) { return a - b; }\n');
try {
const r = proveRed({ cwd: dir, base, cmd: TEST_CMD, tests: ['tests/sum.test.mjs'] });
assert.equal(r.status, 'proven', JSON.stringify(r));
assert.equal(r.red_kind, 'test-failure');
} finally { rmSync(dir, { recursive: true, force: true }); }
});

View file

@ -19,11 +19,6 @@
// Every test first asserts that the script exists, so each fails on an
// assertion while it does not. The script and its runs need Node >= 20.14
// (--test-force-exit); on an older Node each test is skipped.
//
// The two round wrappers (tests/fix-round-4-5a-red.sh, tests/fix-round-4-5b-red.sh)
// are run from a scratch copy whose tests/helpers/red-round.sh is a stub that
// prints its arguments, so what each wrapper hands the shared script is seen
// without a clone. Their mutation modules are loaded with a dynamic import.
import { after, test } from 'node:test';
import { strict as assert } from 'node:assert';
@ -33,7 +28,7 @@ import {
} from 'node:fs';
import { tmpdir } from 'node:os';
import { delimiter, dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { childEnv } from '../helpers/child-env.mjs';
import { pathWith } from '../helpers/bin-path.mjs';
@ -344,169 +339,3 @@ test('[E23b] the base run carries --test-timeout and --test-force-exit', { skip:
}
assert.match(text, /^#.*Node >= 20\.14/m, 'the header does not say that Node >= 20.14 is needed');
});
// --- the round wrappers ------------------------------------------------------
// Expected values from the plan (Step 3) and the 4.5a brief, written out here.
const WRAP_A = {
rel: 'tests/fix-round-4-5a-red.sh',
base: 'ab6b32c9304534b77c7ca2b36e7c993682c9efde',
mutations: 'tests/fix-round-4-5a-mutations.mjs',
files: ['tests/lib/fix-round-4-5a-prose.test.mjs', 'tests/lib/verdict-gate.test.mjs', 'tests/lib/park-attempt-files.test.mjs',
'tests/lib/autonomy-gate.test.mjs', 'tests/lib/arg-parser.test.mjs', 'tests/scripts/trekreview-armb-workflow.test.mjs',
'tests/lib/coordinator-contract.test.mjs', 'tests/validators/next-session-prompt-validator.test.mjs'],
};
const WRAP_B = {
rel: 'tests/fix-round-4-5b-red.sh',
base: 'be0b631fed21942b6c0fdae570b58e4112f8242d',
mutations: 'tests/fix-round-4-5b-mutations.mjs',
files: ['tests/lib/verdict-gate.test.mjs', 'tests/lib/done-line.test.mjs', 'tests/lib/park-attempt-files.test.mjs',
'tests/scripts/trekreview-armb-workflow.test.mjs', 'tests/lib/coordinator-contract.test.mjs', 'tests/lib/red-judge.test.mjs',
'tests/lib/red-script.test.mjs', 'tests/lib/review-recurrence.test.mjs', 'tests/lib/arg-parser.test.mjs',
'tests/commands/trekendsession.test.mjs', 'tests/validators/next-session-prompt-validator.test.mjs',
'tests/lib/fix-round-4-5b-prose.test.mjs', 'tests/lib/fix-round-4-5a-prose.test.mjs', 'tests/scripts/release-check.test.mjs'],
};
const IDS_B = ['M6', 'M11', 'M4', 'm4', 'M3', 'M7', 'M7r', 'E22c', 'E22e', 'M5', 'S3', 'E20', 'M9', 'M2', 'K3', 'M1', 'M1r', 'E23a',
'E23b', 'E23d', 'E23f', 'M8', 'M10', 'DJ5', 'M13', 'K2', 'DJ1', 'DJ2', 'DJ4', 'DJ4r', 'E14', 'E16a', 'E17a', 'E17b', 'E18a', 'Da',
'Db', 'K4', 'ARG', 'PTR', 'RS'];
/** A wrapper's text; asserts first that it exists. */
function wrapperText(w) {
const p = join(ROOT, w.rel);
assert.ok(existsSync(p), `the wrapper ${w.rel} does not exist`);
return readFileSync(p, 'utf8');
}
/** The words of a wrapper's one NAME='...' line. */
function quotedList(text, name, rel) {
const lines = text.split('\n').filter((l) => l.startsWith(`${name}=`));
assert.equal(lines.length, 1, `expected one ${name}= line in ${rel}; found ${lines.length}`);
const m = lines[0].match(new RegExp(`^${name}='([^']*)'$`));
assert.ok(m, `the ${name}= line of ${rel} is not one single-quoted list: ${lines[0]}`);
return m[1].split(/\s+/).filter(Boolean);
}
/** A stand-in for the shared script: one ARG: line per argument, exit $RS_STUB_EXIT (default 0). */
const STUB = `#!/bin/bash
for a in "$@"; do printf 'ARG:%s\\n' "$a"; done
exit "\${RS_STUB_EXIT:-0}"
`;
/** A scratch root holding both wrappers and the stub as tests/helpers/red-round.sh. */
function stubRoot() {
const a = wrapperText(WRAP_A);
const b = wrapperText(WRAP_B);
const dir = join(tmp('red-script-wrap-'), 'root');
mkdirSync(join(dir, 'tests', 'helpers'), { recursive: true });
writeFileSync(join(dir, WRAP_A.rel), a);
writeFileSync(join(dir, WRAP_B.rel), b);
writeFileSync(join(dir, 'tests', 'helpers', 'red-round.sh'), STUB, { mode: 0o755 });
return dir;
}
/** A wrapper run from the scratch root: status, output and the arguments the stub saw. */
function runWrapper(dir, w, over = {}) {
const r = spawnSync('/bin/bash', [join(dir, w.rel)], { cwd: tmpdir(), encoding: 'utf8', timeout: CHILD_TIMEOUT, env: env(over) });
const argv = r.stdout.split('\n').filter((l) => l.startsWith('ARG:')).map((l) => l.slice(4));
return { status: r.status, out: `${r.stdout}${r.stderr}`, stderr: r.stderr, argv };
}
/** The stub's arguments as options (each with a value but --require-sha256) and files. */
function parseCall(argv) {
const call = { flags: [], files: [] };
let i = 0;
while (i < argv.length && argv[i].startsWith('--')) {
if (argv[i] === '--require-sha256') {
call.flags.push(argv[i]);
i += 1;
} else {
call[argv[i]] = argv[i + 1];
i += 2;
}
}
call.files = argv.slice(i);
return call;
}
test('[RS] both wrappers exec the shared script with their own BASE, IDS and FILES', () => {
const dir = stubRoot();
for (const w of [WRAP_A, WRAP_B]) {
const text = wrapperText(w);
assert.deepEqual(text.split('\n').filter((l) => /^BASE=/.test(l)), [`BASE=${w.base}`], `${w.rel}: BASE is not the one full ${w.base.slice(0, 7)} sha`);
assert.deepEqual(quotedList(text, 'FILES', w.rel), w.files, `${w.rel}: the FILES= line is not the round's files`);
const ids = quotedList(text, 'IDS', w.rel);
const r = runWrapper(dir, w);
assert.equal(r.status, 0, `${w.rel} did not run the shared script:\n${r.out}`);
assert.ok(r.argv.length > 0, `${w.rel} handed the shared script no arguments:\n${r.out}`);
const call = parseCall(r.argv);
assert.equal(call['--base'], w.base, `${w.rel}: --base\n${r.out}`);
assert.deepEqual((call['--ids'] || '').split(/\s+/).filter(Boolean), ids, `${w.rel}: --ids is not its IDS= line\n${r.out}`);
assert.equal(call['--mutations'], join(dir, w.mutations), `${w.rel}: --mutations is not its own module by absolute path\n${r.out}`);
assert.deepEqual(call.files, w.files, `${w.rel}: the test files are not its FILES= line\n${r.out}`);
const failed = runWrapper(dir, w, { RS_STUB_EXIT: '1' });
assert.equal(failed.status, 1, `${w.rel} does not end with the shared script's exit status:\n${failed.out}`);
}
});
test('[RS] each wrapper reads the other round\'s ids from its IDS line, no copied list', () => {
const dir = stubRoot();
for (const [w, other] of [[WRAP_A, WRAP_B], [WRAP_B, WRAP_A]]) {
const otherText = wrapperText(other);
const otherIds = quotedList(otherText, 'IDS', other.rel);
const r = runWrapper(dir, w);
assert.equal(r.status, 0, r.out);
assert.deepEqual((parseCall(r.argv)['--other-ids'] || '').split(/\s+/).filter(Boolean), otherIds,
`${w.rel}: --other-ids is not the IDS= line of ${other.rel}\n${r.out}`);
const otherLine = otherText.split('\n').find((l) => l.startsWith('IDS='));
writeFileSync(join(dir, other.rel), otherText.replace(otherLine, "IDS='P1 P2'"));
const planted = runWrapper(dir, w);
assert.equal(planted.status, 0, planted.out);
assert.equal(parseCall(planted.argv)['--other-ids'], 'P1 P2', `${w.rel} does not read ${other.rel}'s IDS= line when it runs\n${planted.out}`);
writeFileSync(join(dir, other.rel), otherText.replace(otherLine, "IDS=''"));
const empty = runWrapper(dir, w);
assert.equal(empty.status, 2, `${w.rel}: an empty IDS= line in ${other.rel} is not a setup failure\n${empty.out}`);
assert.deepEqual(empty.argv, [], `${w.rel} ran the shared script with no other-round ids\n${empty.out}`);
assert.match(empty.stderr, /setup failure/, empty.out);
writeFileSync(join(dir, other.rel), otherText);
assert.ok(!wrapperText(w).includes(otherIds.join(' ')), `${w.rel} holds a copy of ${other.rel}'s id list`);
}
});
test('[RS] the 4.5b IDS line holds exactly the 41 ids', () => {
const ids = quotedList(wrapperText(WRAP_B), 'IDS', WRAP_B.rel);
assert.equal(IDS_B.length, 41, 'control: the expected list is not 41 ids');
assert.deepEqual(ids, IDS_B);
assert.equal(new Set(ids).size, ids.length, 'an id is on the 4.5b IDS= line twice');
});
test('[RS] only the 4.5b wrapper requires SHA-256', () => {
const dir = stubRoot();
const a = runWrapper(dir, WRAP_A);
const b = runWrapper(dir, WRAP_B);
assert.equal(a.status, 0, a.out);
assert.equal(b.status, 0, b.out);
assert.deepEqual(parseCall(a.argv).flags, [], `${WRAP_A.rel} passes a flag it should not:\n${a.out}`);
assert.deepEqual(parseCall(b.argv).flags, ['--require-sha256'], `${WRAP_B.rel} does not pass --require-sha256:\n${b.out}`);
});
test('[RS] every mutation module loads, keys are unique and every id is on its wrapper\'s IDS line', async () => {
const expectKeys = { [WRAP_A.rel]: ['DJ-A', 'DJ-B'], [WRAP_B.rel]: ['E23a', 'E23b', 'RS', 'E23d', 'E23f'] };
for (const w of [WRAP_A, WRAP_B]) {
const ids = quotedList(wrapperText(w), 'IDS', w.rel);
const p = join(ROOT, w.mutations);
assert.ok(existsSync(p), `the mutation module ${w.mutations} does not exist`);
const mod = await import(pathToFileURL(p).href);
assert.ok(Array.isArray(mod.MUTATIONS) && mod.MUTATIONS.length > 0, `${w.mutations} exports no non-empty MUTATIONS array`);
const keys = mod.MUTATIONS.map((m) => m.key);
assert.equal(new Set(keys).size, keys.length, `${w.mutations}: a key is used twice: ${keys.join(' ')}`);
for (const k of expectKeys[w.rel]) assert.ok(keys.includes(k), `${w.mutations} has no entry ${k}`);
for (const m of mod.MUTATIONS) {
assert.ok(ids.includes(m.id), `${w.mutations}: ${m.key} names id ${m.id}, which is not on the IDS= line of ${w.rel}`);
assert.ok(Array.isArray(m.spans) && m.spans.length > 0, `${w.mutations}: ${m.key} has no spans`);
for (const s of m.spans) assert.ok(typeof s.file === 'string' && s.file !== '' && !s.file.startsWith('/'), `${w.mutations}: ${m.key} has a span file that is not a relative path`);
}
}
});

View file

@ -1,131 +0,0 @@
// tests/lib/retterunde-prose-pins.test.mjs
// Prose pins for the fix round before 6.3.0 (order 10) whose fix is text a
// session reads. Counted as doc pins by lib/util/test-census.mjs (the file name).
// 1b A step of only tests can never be proven red: the plan text, the
// template and plan-critic say the test belongs in the step that builds
// the behaviour (voyage-lekerepo trial 3, 2026-10-09, finding 1).
// 1c The executor and the step helper pass `--plan`/`--step` to red-proof
// and send a `tests_only` `not-red-at-base` up to the plan, not into fix
// rounds that cannot succeed.
// 4 /trekreview names FINDING_PROSE_LINE_OUT_OF_RANGE and hands the check
// the reviewer's whole answer, not only its JSON block.
// 6 The resolver returns `effort` only when the brief's phase signal sets
// one; no command says it always does.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { findSection } from '../../lib/parsers/plan-schema.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const read = (p) => readFileSync(join(ROOT, p), 'utf8');
/** The text of a heading's section (level from its #-count), up to the next one outside a fence. */
function section(text, heading) {
const level = heading.match(/^#+/)[0].length;
const s = findSection(text, heading.replace(/^#+ /, ''), level);
assert.ok(s, `missing ${heading}`);
return s.text;
}
const RULE = /test\s+for\s+a\s+behaviou?r\s+belongs\s+in\s+the\s+step\s+that\s+builds\s+it/i;
// --- 1b --------------------------------------------------------------------------
test('1b: /trekplan says the test belongs in the step that builds the behaviour, and names the warning', () => {
const plan = read('commands/trekplan.md');
assert.match(plan, RULE);
// At the vertical-slice rule and at the step field list.
assert.match(plan, /Steps are vertical slices[\s\S]{0,900}only tests[\s\S]{0,300}never be proven red/i);
assert.match(plan, /\*\*Test first:\*\* — [^\n]*\n[^\n]*step that builds/i);
assert.match(plan, /PLAN_STEP_TESTS_ONLY/);
});
test('1b: the plan template carries the same rule', () => {
const t = read('templates/plan-template.md');
assert.match(t, RULE);
assert.match(t, /only tests[\s\S]{0,200}never be proven red/i);
});
test('1b: plan-critic has it as a finding under Verification gaps, and still 12 dimensions', () => {
const critic = read('agents/plan-critic.md');
const d8 = section(critic, '### 8. Verification gaps');
assert.match(d8, /PLAN_STEP_TESTS_ONLY/);
assert.match(d8, /only tests[\s\S]{0,400}\*\*major\*\*/i);
assert.equal((critic.match(/^### \d+\. /gm) ?? []).length, 12);
});
// --- 1c --------------------------------------------------------------------------
test('1c: the executor runs red-proof with --plan/--step and sends a tests_only not-red-at-base up to the plan', () => {
const ex = read('commands/trekexecute.md');
assert.match(ex, /red-proof\.mjs --base \{start_sha\} --cmd "\{Verify command\}" --plan "\{plan_path\}" --step \{N\}[^\n]*--json/);
assert.match(ex, /`not-red-at-base`, `tests_only: true`[^\n]*straight up to the plan \(Sub-step F, `fix_rounds = 4`, stop class 4\)/);
// The helper's gate, as the executor describes it, carries the same flags.
assert.match(ex, /red-proof\.mjs --red-only --base \{start_sha\} [^\n]*--plan "\{plan_path\}" --step \{N\}/);
// The other not-red-at-base keeps its fix round.
assert.match(ex, /`not-red-at-base`, otherwise[^\n]*finding for Sub-step F/);
});
test('1c: the step helper passes --plan/--step to its red gate and does not rewrite a tests_only test', () => {
const h = read('agents/step-implementer.md');
assert.match(h, /red-proof\.mjs --red-only [^\n]*--plan "\{plan\}" --step \{step\}/);
assert.match(h, /`tests_only: true`[\s\S]{0,300}do not rewrite/i);
});
// --- 4 ---------------------------------------------------------------------------
test('4: /trekreview names FINDING_PROSE_LINE_OUT_OF_RANGE: re-ask on the error, report the warning', () => {
const r = read('commands/trekreview.md');
assert.match(r, /FINDING_PROSE_LINE_OUT_OF_RANGE/);
assert.match(r, /FINDING_PROSE_LINE_OUT_OF_RANGE[\s\S]{0,600}re-ask/i);
assert.match(r, /FINDING_PROSE_LINE_OUT_OF_RANGE[\s\S]{0,900}warning[\s\S]{0,300}report/i);
});
test('4: /trekreview saves the reviewer\'s whole answer for the check, not only the JSON block', () => {
const r = read('commands/trekreview.md');
assert.match(r, /\{project_dir\}\/\.review-out-\{reviewer\}\.md/);
assert.match(r, /whole answer[\s\S]{0,300}not only the\s+(JSON\s+)?block/i);
assert.match(r, /findings-schema\.mjs --json --repo-root [^\n]*"\{project_dir\}\/\.review-out-\{reviewer\}\.md"/);
assert.doesNotMatch(r, /Collect each reviewer's trailing JSON block/);
});
test('4: what the check is given decides: the whole answer warns on a prose citation, the block alone cannot', () => {
const dir = mkdtempSync(join(tmpdir(), 'retterunde-review-'));
try {
mkdirSync(join(dir, 'src'));
writeFileSync(join(dir, 'src', 'a.mjs'), 'export const a = 1;\n');
const block = '```json\n{"reviewer":"code-correctness-reviewer","findings":[]}\n```\n';
writeFileSync(join(dir, 'whole.md'), `The bug is at \`src/a.mjs:40\`.\n\n${block}`);
writeFileSync(join(dir, 'block.md'), block);
const cli = (f) => spawnSync(process.execPath,
[join(ROOT, 'lib', 'review', 'findings-schema.mjs'), '--json', '--repo-root', dir, join(dir, f)], { encoding: 'utf8' });
const whole = JSON.parse(cli('whole.md').stdout);
assert.deepEqual(whole.warnings.map((w) => w.code), ['FINDING_PROSE_LINE_OUT_OF_RANGE']);
assert.deepEqual(JSON.parse(cli('block.md').stdout).warnings, []);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
// --- 6 ---------------------------------------------------------------------------
const RESOLVER_COMMANDS = ['trekplan', 'trekexecute', 'trekresearch', 'trekreview'];
test('6: no command says the resolver always returns {effort, model, source}', () => {
for (const f of readdirSync(join(ROOT, 'commands')).filter((x) => x.endsWith('.md'))) {
const t = read(`commands/${f}`);
assert.doesNotMatch(t, /\{effort, model, source\}/, f);
}
});
test('6: each resolver-using command says `effort` comes only from the brief signal, else standard', () => {
for (const c of RESOLVER_COMMANDS) {
const t = read(`commands/${c}.md`);
const hits = t.match(/\{model, source\}[^\n]*\n?[^\n]*effort[^\n]*only when the brief's phase signal sets one/g) ?? [];
assert.ok(hits.length >= 1, `${c}: no statement that effort is only present with a brief signal`);
assert.match(t, /without it[^\n]*\n?[^\n]*`?standard`?/i, c);
}
});

View file

@ -1,218 +0,0 @@
// tests/lib/retterunde.test.mjs
// The fix round before 6.3.0 (order 10), the points that are behaviour, not
// prose. Each test was red before its fix.
// 1 A step whose `Files:` are all tests can never be proven red: the
// behaviour it tests is already at its start point (voyage-lekerepo
// trial 3, 2026-10-09, finding 1). (a) the validator warns
// PLAN_STEP_TESTS_ONLY; (c) red-proof says `tests_only` on its
// `not-red-at-base`, so the executor sends the step up to the plan instead
// of into fix rounds that cannot succeed. One reading: stepTestsOnly.
// 3 The example plan's `On failure:` lines open with a K11 word.
// 5 The criteria runner's commands never see NODE_TEST_CONTEXT: a nested
// `node --test` would report to the parent and exit 0 on a red test.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { validatePlan, validatePlanContent } from '../../lib/validators/plan-validator.mjs';
import { parseStepFields, sliceSteps, stepTestsOnly } from '../../lib/parsers/plan-schema.mjs';
import { proveRed } from '../../lib/verification/red-proof.mjs';
import { runCriteria } from '../../lib/verification/criteria-runner.mjs';
import { buildPlan } from '../helpers/plan-v18-builder.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const codes = (list) => list.map((e) => e.code);
// --- 1 (a): the validator -----------------------------------------------------
// Step 2 changes only tests for behaviour step 1 delivered, as in the trial.
const TESTS_ONLY_PLAN = buildPlan({
steps: [{}, { file: 'tests/e2e.sh', test: 'tests/e2e-guard.test.mjs', endToEnd: 'bash tests/e2e.sh' }],
});
test('1a: a step whose Files are all tests gives PLAN_STEP_TESTS_ONLY, a warning on that step', () => {
const r = validatePlanContent(TESTS_ONLY_PLAN);
const w = r.warnings.filter((x) => x.code === 'PLAN_STEP_TESTS_ONLY');
assert.equal(w.length, 1, codes(r.warnings).join(','));
assert.match(w[0].message, /Step 2/);
assert.ok(!codes(r.errors).includes('PLAN_STEP_TESTS_ONLY'), 'a warning, never an error');
assert.equal(r.valid, true, codes(r.errors).join(','));
});
test('1a: a step with code and its test gives no PLAN_STEP_TESTS_ONLY (as before)', () => {
const r = validatePlanContent(buildPlan());
assert.ok(!codes(r.warnings).includes('PLAN_STEP_TESTS_ONLY'), codes(r.warnings).join(','));
});
test('1a: the reading - Files all tests (by path or as the Test first file) or not; no Files = no warning', () => {
const body = (files, tf = 'tests/x.test.mjs') => [
'### Step 1: x', '',
...(files === null ? [] : [`- **Files:** ${files}`]),
'- **Test first:**', ` - File: \`${tf}\` (new)`, ' - Verifies: x',
].join('\n');
assert.equal(stepTestsOnly(body('`tests/e2e.sh`, `tests/e2e-guard.sh`')).testsOnly, true);
assert.equal(stepTestsOnly(body('`src/a.test.ts` (new file), `lib/b.spec.js`, `pkg/c_test.go`')).testsOnly, true);
assert.equal(stepTestsOnly(body('`__tests__/a.js`, `test/b.js`')).testsOnly, true);
// The Test first file counts as a test wherever it lives.
assert.equal(stepTestsOnly(body('`scripts/check.sh`', 'scripts/check.sh')).testsOnly, true);
assert.equal(stepTestsOnly(body('`src/cli.mjs`, `tests/cli.test.mjs`')).testsOnly, false);
assert.equal(stepTestsOnly(body('`src/cli.mjs`')).testsOnly, false);
assert.equal(stepTestsOnly(body(null)).testsOnly, false);
assert.deepEqual(stepTestsOnly(body('`tests/e2e.sh`, `tests/e2e-guard.sh`')).files, ['tests/e2e.sh', 'tests/e2e-guard.sh']);
});
test('1a: a plain path beside a backticked one is still read - a step that also changes code is not tests-only', () => {
const body = (files) => ['### Step 1: x', '', `- **Files:** ${files}`].join('\n');
const mixed = stepTestsOnly(body('src/cli.mjs (modify), `tests/x.test.mjs` (new)'));
assert.deepEqual(mixed.files, ['src/cli.mjs', 'tests/x.test.mjs']);
assert.equal(mixed.testsOnly, false);
const list = stepTestsOnly(['### Step 1: x', '', '- **Files:**', ' - `tests/x.test.mjs` (new)', ' - src/cli.mjs'].join('\n'));
assert.equal(list.testsOnly, false, JSON.stringify(list.files));
});
test('1a: a document under spec/ or specs/ is not a test for this reading', () => {
const body = (files) => ['### Step 1: x', '', `- **Files:** ${files}`].join('\n');
assert.equal(stepTestsOnly(body('`docs/specs/api.md`, `specs/001-auth/plan.md`')).testsOnly, false);
assert.equal(stepTestsOnly(body('`spec/cli_spec.rb`')).testsOnly, true);
});
// --- 1 (c): red-proof says tests_only on not-red-at-base ----------------------
function sh(cwd, cmd, args) {
const r = spawnSync(cmd, args, { cwd, encoding: 'utf8' });
assert.equal(r.status, 0, `${cmd} ${args.join(' ')}: ${r.stderr}`);
return r.stdout.trim();
}
const SUM_TEST = [
"import { test } from 'node:test';",
"import { strict as assert } from 'node:assert';",
"import { sum } from '../lib/sum.mjs';",
"test('sum adds', () => { assert.equal(sum(1, 2), 3); });",
'',
].join('\n');
/** The behaviour is already at the start point; the step adds only its test. */
function greenAtBase() {
const dir = mkdtempSync(join(tmpdir(), 'retterunde-red-'));
sh(dir, 'git', ['init', '-q']);
sh(dir, 'git', ['config', 'user.email', 't@example.invalid']);
sh(dir, 'git', ['config', 'user.name', 'test']);
mkdirSync(join(dir, 'lib'));
writeFileSync(join(dir, 'lib', 'sum.mjs'), 'export function sum(a, b) { return a + b; }\n');
sh(dir, 'git', ['add', 'lib/sum.mjs']);
sh(dir, 'git', ['commit', '-q', '-m', 'base']);
mkdirSync(join(dir, 'tests'));
writeFileSync(join(dir, 'tests', 'sum.test.mjs'), SUM_TEST);
return { dir, base: sh(dir, 'git', ['rev-parse', 'HEAD']) };
}
function planWithStep2Files(dir, files) {
const p = join(dir, 'plan.local.md');
writeFileSync(p, buildPlan({ steps: [{}, { file: files, test: 'tests/sum.test.mjs', endToEnd: 'bash tests/e2e.sh' }] }));
return p;
}
const CMD = 'node --test tests/sum.test.mjs';
test('1c: not-red-at-base on a step whose Files are all tests says tests_only: true (red-only and full)', () => {
const { dir, base } = greenAtBase();
try {
const plan = planWithStep2Files(dir, 'tests/sum.test.mjs');
for (const redOnly of [true, false]) {
const r = proveRed({ cwd: dir, base, cmd: CMD, tests: ['tests/sum.test.mjs'], redOnly, plan, step: 2 });
assert.equal(r.reason, 'not-red-at-base', JSON.stringify(r));
assert.equal(r.tests_only, true, JSON.stringify(r));
}
const cli = spawnSync(process.execPath, [join(ROOT, 'lib', 'verification', 'red-proof.mjs'),
'--cwd', dir, '--base', base, '--cmd', CMD, '--tests', 'tests/sum.test.mjs', '--plan', plan, '--step', '2', '--json'],
{ encoding: 'utf8' });
assert.equal(cli.status, 1, cli.stdout + cli.stderr);
assert.equal(JSON.parse(cli.stdout).tests_only, true);
// Text mode names the field too (the step helper reads it there).
const text = spawnSync(process.execPath, [join(ROOT, 'lib', 'verification', 'red-proof.mjs'),
'--red-only', '--cwd', dir, '--base', base, '--cmd', CMD, '--tests', 'tests/sum.test.mjs', '--plan', plan, '--step', '2'],
{ encoding: 'utf8' });
assert.match(text.stdout, /tests_only: true/);
// --step without --plan is a usage error, never silently dropped.
const lone = spawnSync(process.execPath, [join(ROOT, 'lib', 'verification', 'red-proof.mjs'),
'--cwd', dir, '--base', base, '--cmd', CMD, '--tests', 'tests/sum.test.mjs', '--step', '2'], { encoding: 'utf8' });
assert.equal(lone.status, 2, lone.stderr);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('1c: not-red-at-base on a step that also changes code says tests_only: false (fix rounds, as before)', () => {
const { dir, base } = greenAtBase();
try {
const plan = planWithStep2Files(dir, 'lib/sum.mjs');
const r = proveRed({ cwd: dir, base, cmd: CMD, tests: ['tests/sum.test.mjs'], plan, step: 2 });
assert.equal(r.reason, 'not-red-at-base');
assert.equal(r.tests_only, false, JSON.stringify(r));
// Without --plan nothing new is said: the field is absent.
const bare = proveRed({ cwd: dir, base, cmd: CMD, tests: ['tests/sum.test.mjs'] });
assert.equal(bare.reason, 'not-red-at-base');
assert.equal('tests_only' in bare, false);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
// --- 3: the example plan reads with the K11 words -----------------------------
test('3: the example plan gives no PLAN_STEP_ON_FAILURE_UNKNOWN', () => {
const r = validatePlan(join(ROOT, 'examples', '01-add-verbose-flag', 'plan.md'));
assert.deepEqual(r.warnings.filter((w) => w.code === 'PLAN_STEP_ON_FAILURE_UNKNOWN').map((w) => w.message), []);
});
// --- 5: the criteria runner strips NODE_TEST_CONTEXT ---------------------------
test('5: a nested `node --test` on a failing test is red even when NODE_TEST_CONTEXT is set', () => {
const dir = mkdtempSync(join(tmpdir(), 'retterunde-ctx-'));
const saved = process.env.NODE_TEST_CONTEXT;
try {
mkdirSync(join(dir, 'tests'));
writeFileSync(join(dir, 'tests', 'fail.test.mjs'), [
"import { test } from 'node:test';",
"import { strict as assert } from 'node:assert';",
"test('fails', () => { assert.equal(1, 2); });",
'',
].join('\n'));
process.env.NODE_TEST_CONTEXT = 'child-v8';
const [r] = runCriteria([{ label: 'C1', text: 'x', command: 'node --test tests/fail.test.mjs' }], { cwd: dir });
assert.equal(r.status, 'failed', JSON.stringify(r));
assert.notEqual(r.exitCode, 0);
} finally {
if (saved === undefined) delete process.env.NODE_TEST_CONTEXT;
else process.env.NODE_TEST_CONTEXT = saved;
rmSync(dir, { recursive: true, force: true });
}
});
// Operator 2026-10-09: the example's verbose-content test moves into the step
// that wires the verbose output; steps 1-2 (golden capture and the stability
// harness: tests of today's behaviour, never red) keep the warning, and a step
// type for them is a wave 3 question.
test('1: the example plan warns only on its two golden/stability steps; the verbose test sits with the code', () => {
const r = validatePlan(join(ROOT, 'examples', '01-add-verbose-flag', 'plan.md'));
const w = r.warnings.filter((x) => x.code === 'PLAN_STEP_TESTS_ONLY').map((x) => x.message.match(/Step (\d+)/)[1]);
assert.deepEqual(w, ['1', '2']);
const text = readFileSync(join(ROOT, 'examples', '01-add-verbose-flag', 'plan.md'), 'utf8');
const step = sliceSteps(text).find((s) => /tests\/cli-verbose-flag\.test\.mjs/.test(parseStepFields(s.body).get('Files')?.text ?? ''));
assert.ok(step, 'a step lists tests/cli-verbose-flag.test.mjs in Files');
assert.match(parseStepFields(step.body).get('Files').text, /src\/commands\/login\.mjs/);
// The run record follows the plan: one entry per step.
const progress = JSON.parse(readFileSync(join(ROOT, 'examples', '01-add-verbose-flag', 'progress.json'), 'utf8'));
const n = r.parsed.steps.length;
assert.equal(progress.total_steps, n);
assert.equal(progress.current_step, n);
assert.deepEqual(progress.steps.map((s) => s.n), Array.from({ length: n }, (_, i) => i + 1));
});
// Guard against a reading of the example that counts nothing: it has steps.
test('3: the example plan is read (six or more steps)', () => {
const text = readFileSync(join(ROOT, 'examples', '01-add-verbose-flag', 'plan.md'), 'utf8');
assert.ok(validatePlanContent(text).parsed.steps.length >= 6);
});

View file

@ -37,7 +37,7 @@ test('engine finding: recommended_action is an imperative built from the failure
assert.doesNotMatch(f.recommended_action, /^Resolve:/);
assert.notEqual(f.recommended_action.replace(/^\w+\s*/, ''), f.title);
// Trial 2 (2026-10-08): the action is a test-first step, not the scenario
// dressed as a remedy (tests/lib/trial2-fixes.test.mjs, A2).
// dressed as a remedy (tests/lib/engine-findings.test.mjs).
assert.match(f.recommended_action, /^Write a test /);
assert.match(f.recommended_action, /bin\/slug\.mjs:12/);
assert.match(f.recommended_action, /prints `x---max`/);

View file

@ -199,3 +199,44 @@ test('project-memory approval: the options the operator sees are English (operat
assert.ok(src.includes('and **"Not now"**'), 'the other option reads "Not now"');
assert.ok(!src.includes('Skriv linja inn') && !src.includes('Ikke nå'), 'no Norwegian option label left');
});
// ---- the --answers mode and the instruction-file dimension ------------------
/** Whitespace (line breaks too) collapsed to one space. */
const flat = (s) => s.replace(/\s+/g, ' ').trim();
test('--answers mode: /trekreview names it and says it opens no new round, /trekexecute\'s closed lock names it', () => {
assert.match(CMD.match(/^argument-hint: (.*)$/m)?.[1] ?? '', /\[--answers\]/, 'the argument-hint names --answers');
const p1 = section(CMD, '## Phase 1 — Parse mode and validate input');
const row = p1.split('\n').find((l) => l.startsWith('| `--answers` |'));
assert.ok(row, 'the Phase 1 mode table has an --answers row');
assert.match(row, /Phase 8\.8/, `the --answers row runs Phase 8.8: ${row}`);
assert.match(row, /no new round/, `the --answers row says it opens no new round: ${row}`);
const modes = section(read('docs/command-modes.md'), '## /trekreview modes');
assert.ok(modes.split('\n').some((l) => l.startsWith('| `--answers` |')), 'docs/command-modes.md lists --answers in the /trekreview modes table');
const claudeRow = read('CLAUDE.md').split('\n').find((l) => l.startsWith('| `/trekreview` |'));
assert.ok(claudeRow && claudeRow.includes('--answers'), 'the /trekreview row of CLAUDE.md names --answers');
const gate = flat(section(read('commands/trekexecute.md'), '### Main-merge gate (MAIN_MERGE_GATE — always pauses)'));
assert.ok(gate.includes('/trekreview --answers --project {project_dir}'), 'the closed-lock line names /trekreview --answers');
});
test('code-correctness-reviewer: the frontmatter sets omitClaudeMd: true', () => {
const text = read('agents/code-correctness-reviewer.md');
const fm = /^---\n([\s\S]*?)\n---\n/.exec(text)?.[1] ?? '';
assert.match(fm, /^omitClaudeMd: true$/m, 'the frontmatter sets omitClaudeMd: true');
});
// ---- the line-out-of-range check gets the reviewer's whole answer -----------
test('trekreview — names FINDING_PROSE_LINE_OUT_OF_RANGE: re-ask on the error, report the warning', () => {
assert.match(CMD, /FINDING_PROSE_LINE_OUT_OF_RANGE/);
assert.match(CMD, /FINDING_PROSE_LINE_OUT_OF_RANGE[\s\S]{0,600}re-ask/i);
assert.match(CMD, /FINDING_PROSE_LINE_OUT_OF_RANGE[\s\S]{0,900}warning[\s\S]{0,300}report/i);
});
test('trekreview — saves the reviewer\'s whole answer for the check, not only the JSON block', () => {
assert.match(CMD, /\{project_dir\}\/\.review-out-\{reviewer\}\.md/);
assert.match(CMD, /whole answer[\s\S]{0,300}not only the\s+(JSON\s+)?block/i);
assert.match(CMD, /findings-schema\.mjs --json --repo-root [^\n]*"\{project_dir\}\/\.review-out-\{reviewer\}\.md"/);
assert.doesNotMatch(CMD, /Collect each reviewer's trailing JSON block/);
});

View file

@ -11,6 +11,7 @@ import { strict as assert } from 'node:assert';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { findSection } from '../../lib/parsers/plan-schema.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const read = (rel) => readFileSync(join(ROOT, rel), 'utf8');
@ -57,3 +58,61 @@ test('run coupling: the writing-criteria quiet case is owned by run, with no sta
assert.doesNotMatch(md, /stands in/);
assert.doesNotMatch(md, /owner=trekexecute/);
});
// The final report says whether the app was seen with `run`, for every run,
// also one with no app to launch (the "no — {reason}" form).
const EXEC = 'commands/trekexecute.md';
/**
* The text of a heading's section (level from its #-count), up to the next
* heading of the same or a higher level outside a fence. The heading must exist.
*/
function sectionText(text, heading) {
const level = heading.match(/^#+/)[0].length;
const s = findSection(text, heading.replace(/^#+ /, ''), level);
assert.ok(s, `missing ${heading}`);
const lines = s.text.split('\n');
let fenced = false;
for (let i = 1; i < lines.length; i++) {
if (/^\s{0,3}(`{3,}|~{3,})/.test(lines[i])) fenced = !fenced;
const m = !fenced && lines[i].match(/^(#+) /);
if (m && m[1].length < level) return lines.slice(0, i).join('\n');
}
return s.text;
}
/** Phase 8 of /trekexecute, read up to Phase 9 (its report fence holds its own `## ` heading). */
function phase8(text) {
const start = text.indexOf('## Phase 8 — Final report');
const end = text.indexOf('## Phase 9');
assert.ok(start !== -1 && end > start, 'Phase 8 — Final report and Phase 9 must both exist, in that order');
return text.slice(start, end);
}
test('run_seen: the Phase 8 report template says, right after End to end, whether the app was seen with `run`', () => {
const lines = phase8(read(EXEC)).split('\n');
const e2e = lines.findIndex((l) => l.startsWith('**End to end:**'));
assert.ok(e2e !== -1, 'the report template has no **End to end:** line');
assert.equal(lines[e2e + 1], '**Seen with `run`:** {yes | no — {reason}}',
'the line after **End to end:** is not the "Seen with `run`" line in its yes / no — {reason} form');
});
test('run_seen: the summary JSON carries "run_seen" right after "end_to_end"', () => {
const text = read(EXEC);
const sumIdx = text.indexOf('"trekexecute_summary": {');
assert.ok(sumIdx !== -1, 'summary JSON block missing');
const lines = text.slice(sumIdx, text.indexOf('\n```', sumIdx)).split('\n').map((l) => l.trim());
const e2e = lines.findIndex((l) => l.startsWith('"end_to_end":'));
assert.ok(e2e !== -1, 'the summary JSON has no "end_to_end" key');
assert.equal(lines[e2e + 1], '"run_seen": "{yes | no — reason}",',
'the key after "end_to_end" is not "run_seen" with its yes / no — reason value');
});
test('run_seen: the End to end section says to record run_seen, also when no app was seen, and keeps the End-to-end: command as the verdict', () => {
const s = sectionText(read(EXEC), PARTS[1][1]).replace(/\s+/g, ' ');
assert.match(s, /Record `run_seen`: `yes` when the app was seen and driven with Claude Code's built-in `run` in this run, else `no — \{reason\}`/,
'the End to end section does not say to record run_seen as yes, or no with a reason');
assert.match(s, /no app to launch/, 'the no-form does not name the run with no app to launch');
assert.match(s, /The verdict is still the `End-to-end:` command\./,
'the End to end section no longer says the End-to-end: command is the verdict');
});

View file

@ -1,23 +1,20 @@
// tests/lib/timing-under-load.test.mjs
// The two timing tests that went red under load in the wave sessions (order 10,
// point 2; operator 2026-10-09): P7 (post-edit-check) was killed by the hook
// helper's 5 s limit at load 95-177 and was 8/8 green alone; SC #14
// (otel-export) demands < 800 ms for ONE cold run.
// - The helper's kill limit is longer (HOOK_TIMEOUT_MS) and overridable per
// call. P7 checks the hook's answer, not its speed, so that weakens nothing.
// - SC #14 keeps its 800 ms limit and measures up to three runs, passing on
// tests/lib/timing.test.mjs
// The shared timing helpers (tests/helpers/timing.mjs, tests/helpers/hook-helper.mjs):
// - fastestWithin keeps its limit and measures up to three runs, passing on
// the fastest. Load only ever ADDS time, so the fastest run is the best
// reading of the real cost: a real slowness is slow every time and still
// fails; one run slowed by a busy machine does not.
// - The hook helper's kill limit (HOOK_TIMEOUT_MS) is long by default and
// overridable per call.
// - CONTROL_RUN gives the red-proof control run a timeout with headroom over
// a slow start, and a hang that outlasts it.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { fastestWithin } from '../helpers/timing.mjs';
import * as timing from '../helpers/timing.mjs';
import { join } from 'node:path';
import { CONTROL_RUN, fastestWithin } from '../helpers/timing.mjs';
import { HOOK_TIMEOUT_MS, runHookWithEnv } from '../helpers/hook-helper.mjs';
/** A measure that returns the given durations in turn (no real waiting). */
@ -64,16 +61,8 @@ test('hook helper: the kill limit is far above 5 s by default, and a call can se
} finally { rmSync(dir, { recursive: true, force: true }); }
});
// The red-proof control-run test (review 630 r9, brief note 3, in
// tests/lib/review-630.test.mjs) reads its timeout, hang and simulated slow
// start from tests/helpers/timing.mjs (CONTROL_RUN, review 703d87e1): the
// criteria runner saw its start point killed at 2 s under full-suite load.
// BEFORE_703D87E1 is the record of the numbers before the fix, taken only when
// the export is absent.
const BEFORE_703D87E1 = Object.freeze({ timeoutMs: 2000, hangMs: 6000, slowStartMs: 2500 });
test('red proof control run (703d87e1): the timeout leaves headroom over a slow start and the hang outlasts it', () => {
const c = timing.CONTROL_RUN ?? BEFORE_703D87E1;
test('red proof control run: the timeout leaves headroom over a slow start and the hang outlasts it', () => {
const c = CONTROL_RUN;
const seen = JSON.stringify(c);
assert.ok(c.slowStartMs > 2000, `the simulated slow start is above the old 2 s timeout: ${seen}`);
assert.ok(c.timeoutMs >= 15000, `the timeout leaves headroom over a loaded machine: ${seen}`);
@ -82,24 +71,3 @@ test('red proof control run (703d87e1): the timeout leaves headroom over a slow
assert.ok(c.hangMs >= c.timeoutMs + 30000, `the hang outlasts the timeout by 30 s: ${seen}`);
assert.ok(c.hangMs < 2 ** 31 - 1, `the hang fits a real timer (below 2^31-1 ms): ${seen}`);
});
// The post-compact hook test (tests/hooks/post-compact-flush.test.mjs) ran its
// hook child with a fixed 5 s limit and was killed under full-suite load
// (decision 44, the same class as 703d87e1). Its limit now comes from
// tests/helpers/timing.mjs (HOOK_CHILD_TIMEOUT_MS), shared with the hook
// helper. The fallback 5000 is the record of the limit before decision 44,
// taken only when the export is absent.
test('post-compact-flush (decision 44): its child timeout comes from tests/helpers/timing.mjs', () => {
const hookTest = readFileSync(
join(dirname(fileURLToPath(import.meta.url)), '..', 'hooks', 'post-compact-flush.test.mjs'),
'utf8',
);
assert.ok(hookTest.includes("'../helpers/timing.mjs'"), 'the hook test imports from ../helpers/timing.mjs');
assert.ok(hookTest.includes('timeout: HOOK_CHILD_TIMEOUT_MS'), 'the hook test uses timeout: HOOK_CHILD_TIMEOUT_MS');
assert.doesNotMatch(hookTest, /\b5_?000\b/, 'the hook test holds no 5000 literal');
const limit = timing.HOOK_CHILD_TIMEOUT_MS ?? 5000;
assert.ok(limit > 5000, `the shared child timeout is above the old 5 s: ${limit}`);
assert.equal(HOOK_TIMEOUT_MS, timing.HOOK_CHILD_TIMEOUT_MS, 'the hook helper and the hook test share one number');
assert.ok(hookTest.includes('child.exitCode ?? 1'), 'a killed child reads as non-zero');
assert.ok(!hookTest.includes('?? 0'), 'no killed child reads as exit 0');
});

View file

@ -1,130 +0,0 @@
// tests/lib/trial-mechanical-fixes.test.mjs
// Mechanical faults from the voyage-lekerepo end-to-end trial (2026-10-06),
// report points 8, 10, 12, 15 and 16. Each test fails on the code the trial
// ran.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, isAbsolute } from 'node:path';
import { fileURLToPath } from 'node:url';
import { isExplicitApproval } from '../../lib/util/approval-log.mjs';
import { proveRed } from '../../lib/verification/red-proof.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const read = (p) => readFileSync(join(ROOT, p), 'utf8');
// --- 8. the label AskUserQuestion recommends is still an approval -----------
test('approval: «Approve (Recommended)» is the explicit approval it labels', () => {
for (const a of ['Approve (Recommended)', 'Approve (recommended)', 'Godkjenn (anbefalt)', 'Approve (Recommended).']) {
assert.equal(isExplicitApproval(a).approved, true, a);
}
});
test('approval: the suffix does not turn a hollow answer into a yes', () => {
for (const a of ['Sounds good (Recommended)', 'Høres bra ut (anbefalt)', '(Recommended)', 'Revise (Recommended)']) {
assert.equal(isExplicitApproval(a).approved, false, a);
}
});
// --- 10. the state-file block runs from any working directory ---------------
test('trekexecute Phase 8: the state-file block imports through the plugin root and runs outside it', () => {
const md = read('commands/trekexecute.md');
assert.doesNotMatch(md, /from '\.\/lib\//, 'a relative import only works with cwd = plugin root');
const start = md.indexOf('node --input-type=module -e "\nimport path from \'node:path\';');
assert.ok(start > 0, 'the state-file block');
const end = md.indexOf('\n```', start);
const block = md.slice(start, end)
.replaceAll('${CLAUDE_PLUGIN_ROOT}', ROOT)
.replace("'{project_dir}' '{next_session_brief_path}' '{next_session_label}' '{status}'", '"$1" brief.md "Session 2" ready');
const elsewhere = mkdtempSync(join(tmpdir(), 'voyage-state-'));
try {
const r = spawnSync('/bin/bash', ['-c', block, 'x', elsewhere], { cwd: elsewhere, encoding: 'utf8' });
assert.equal(r.status, 0, r.stderr);
assert.ok(existsSync(join(elsewhere, '.session-state.local.json')));
assert.match(readFileSync(join(elsewhere, 'NEXT-SESSION-PROMPT.local.md'), 'utf8'), /produced_by: trekexecute/);
} finally {
rmSync(elsewhere, { recursive: true, force: true });
}
});
// --- 12. every command's stats go through the one writer --------------------
test('stats: trekreview, trekresearch and trekcontinue write through stats-append, never silently skipped', () => {
for (const [file, kind] of [['commands/trekreview.md', 'trekreview'], ['commands/trekresearch.md', 'trekresearch'], ['commands/trekcontinue.md', 'trekcontinue']]) {
const md = read(file);
assert.match(md, new RegExp(`node \\$\\{CLAUDE_PLUGIN_ROOT\\}/lib/stats/stats-append\\.mjs ${kind} <<'JSON'`), file);
assert.doesNotMatch(md, /skip (stats )?silently|silently skip/i, file);
assert.doesNotMatch(md, new RegExp(`\\$\\{CLAUDE_PLUGIN_DATA\\}/${kind}-stats\\.jsonl`), file);
}
});
// --- 15. annotate prints a path a file:// link can use ----------------------
test('annotate: a relative input still prints an absolute .html path', () => {
const dir = mkdtempSync(join(tmpdir(), 'voyage-annot-'));
try {
writeFileSync(join(dir, 'brief.md'), '# Brief\n\nText.\n');
const r = spawnSync(process.execPath, [join(ROOT, 'scripts', 'annotate.mjs'), 'brief.md'], { cwd: dir, encoding: 'utf8' });
assert.equal(r.status, 0, r.stderr);
const out = r.stdout.trim();
assert.ok(isAbsolute(out), out);
assert.ok(existsSync(out), out);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
// --- 16. the proof says what kind of red it was ------------------------------
function sh(cwd, cmd, args) {
const r = spawnSync(cmd, args, { cwd, encoding: 'utf8' });
assert.equal(r.status, 0, `${cmd} ${args.join(' ')}: ${r.stderr}`);
return r.stdout.trim();
}
function repo(moduleText) {
const dir = mkdtempSync(join(tmpdir(), 'red-kind-'));
sh(dir, 'git', ['init', '-q']);
sh(dir, 'git', ['config', 'user.email', 't@example.invalid']);
sh(dir, 'git', ['config', 'user.name', 'test']);
mkdirSync(join(dir, 'lib'));
writeFileSync(join(dir, 'lib', 'sum.mjs'), moduleText);
sh(dir, 'git', ['add', 'lib/sum.mjs']);
sh(dir, 'git', ['commit', '-q', '-m', 'base']);
mkdirSync(join(dir, 'tests'));
writeFileSync(join(dir, 'tests', 'sum.test.mjs'), [
"import { test } from 'node:test';",
"import { strict as assert } from 'node:assert';",
"import { sum } from '../lib/sum.mjs';",
"test('sum adds', () => { assert.equal(sum(1, 2), 3); });",
'',
].join('\n'));
writeFileSync(join(dir, 'lib', 'sum.mjs'), 'export function sum(a, b) { return a + b; }\n');
return { dir, base: sh(dir, 'git', ['rev-parse', 'HEAD']) };
}
const CMD = 'node --test tests/sum.test.mjs';
test('red proof: a start point that cannot even load the test is proven with red_kind "load-error"', () => {
const { dir, base } = repo('export function other() { return 0; }\n');
try {
const r = proveRed({ cwd: dir, base, cmd: CMD, tests: ['tests/sum.test.mjs'] });
assert.equal(r.status, 'proven', JSON.stringify(r));
assert.equal(r.red_kind, 'load-error');
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('red proof: a named test that fails at the start point is red_kind "test-failure"', () => {
const { dir, base } = repo('export function sum(a, b) { return a - b; }\n');
try {
const r = proveRed({ cwd: dir, base, cmd: CMD, tests: ['tests/sum.test.mjs'] });
assert.equal(r.status, 'proven', JSON.stringify(r));
assert.equal(r.red_kind, 'test-failure');
} finally { rmSync(dir, { recursive: true, force: true }); }
});

View file

@ -1,225 +0,0 @@
// tests/lib/trial2-fixes.test.mjs
// The voyage-lekerepo second end-to-end trial (2026-10-08), report section A:
// faults that make a result wrong or misleading. The operator decided they are
// fixed before the next release. Each test fails on main 5f9faee.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { toReviewerPayload } from '../../lib/review/engine-findings.mjs';
import { runContract } from '../../lib/review/coordinator-contract.mjs';
import { getRule } from '../../lib/review/rule-catalogue.mjs';
import { parseSuccessCriteria } from '../../lib/verification/criteria-runner.mjs';
import { validateBriefContent } from '../../lib/validators/brief-validator.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const read = (p) => readFileSync(join(ROOT, p), 'utf8');
const codes = (list) => list.map((e) => e.code);
const engineFinding = (extra = {}) => ({
file: 'src/slug.mjs', line: 9,
summary: 'A two-step detour where one split/filter/join would do',
failure_scenario: 'slug("a--b") walks the string twice',
...extra,
});
// --- A1. an improvement from /code-review is not a MAJOR defect --------------
test('A1: a /code-review simplification or efficiency finding is MINOR, a correctness finding stays MAJOR', () => {
const r = toReviewerPayload({ engine: 'code-review', findings: [
engineFinding({ category: 'simplification' }),
engineFinding({ line: 10, category: 'efficiency' }),
engineFinding({ line: 11, category: 'correctness' }),
engineFinding({ line: 12 }),
] });
assert.equal(r.ok, true, r.errors.join('; '));
const sev = r.payload.findings.map((f) => f.severity);
assert.deepEqual(sev, ['MINOR', 'MINOR', 'MAJOR', 'MAJOR']);
for (const f of r.payload.findings) assert.equal(f.severity, getRule(f.rule_key).severity, 'severity is fixed by rule_key');
});
test('A1: /security-review keeps its tier whatever the category', () => {
const r = toReviewerPayload({ engine: 'security-review', findings: [engineFinding({ category: 'simplification' })] });
assert.equal(r.payload.findings[0].severity, 'BLOCKER');
});
// --- A2. recommended_action is a remedy, not the failure scenario -----------
test('A2: the engine\'s own fix text becomes the recommended action', () => {
const r = toReviewerPayload({ engine: 'code-review', findings: [engineFinding({ fix: 'Use one split/filter/join pass' })] });
assert.equal(r.payload.findings[0].recommended_action, 'Use one split/filter/join pass');
});
test('A2: without a fix text the action is a step: reproduce the scenario in a test, then change the location', () => {
const r = toReviewerPayload({ engine: 'code-review', findings: [engineFinding()] });
const a = r.payload.findings[0].recommended_action;
assert.doesNotMatch(a, /so this no longer happens/);
assert.match(a, /^Write a test that reproduces/);
assert.match(a, /src\/slug\.mjs:9/);
});
test('A2: /trekreview asks the transcription for the engine\'s fix text and forbids inventing one', () => {
const md = read('commands/trekreview.md');
const p = md.slice(md.indexOf('## Phase 4.7'), md.indexOf('## Phase 5'));
assert.match(p, /the\s+fix\s+the\s+engine\s+proposes\s+\(`fix`\)/);
assert.match(p, /never\s+write\s+one\s+the\s+engine\s+did\s+not\s+give/);
});
// --- A3. an out-of-range /code-review finding does not lock the verdict -----
test('A3: a small /code-review finding outside the exact range it was given is declined, and does not hold ALLOW back', () => {
const out = { ...engineFinding({ file: 'src/util.mjs', line: 4 }), engine: 'code-review', rule_key: 'CODE_REVIEW_IMPROVEMENT', severity: 'MINOR', title: 'Simplify the helper' };
const c = runContract([], { engineOutOfScope: [out] });
assert.equal(c.verdict, 'ALLOW', JSON.stringify(c.allow_blocked_by));
assert.equal(c.declined_to_judge.length, 1);
assert.equal(c.declined_to_judge[0].reason, 'engine:out-of-scope');
});
test('A3 (independent review): a MAJOR /code-review defect cited at an unchanged file still forbids ALLOW — a cross-file break is cited there', () => {
const out = { ...engineFinding({ file: 'tests/cli.test.mjs', line: 1 }), engine: 'code-review', rule_key: 'CODE_REVIEW_DEFECT', severity: 'MAJOR', title: 'No CLI test for --sep' };
const c = runContract([], { engineOutOfScope: [out] });
assert.notEqual(c.verdict, 'ALLOW');
assert.equal(c.declined_to_judge.length, 1);
});
test('A3: a /security-review finding outside the range still forbids ALLOW (its base is wider and not ours)', () => {
const out = { ...engineFinding({ file: 'lib/auth.mjs' }), engine: 'security-review', rule_key: 'SECURITY_REVIEW_VULNERABILITY', severity: 'BLOCKER', title: 'Token logged' };
const c = runContract([], { engineOutOfScope: [out] });
assert.notEqual(c.verdict, 'ALLOW');
});
// --- A4. the manifest's must_contain check survives a pattern that starts with "-"
test('A4: the must_contain recipe passes the pattern with -e and ends options with --', () => {
const md = read('commands/trekexecute.md');
assert.match(md, /`grep -E -e "\{pattern\}" -- \{path\}`/);
assert.doesNotMatch(md, /`grep -E "\{pattern\}" \{path\}`/);
const dir = mkdtempSync(join(tmpdir(), 'voyage-grep-'));
try {
writeFileSync(join(dir, 'README.md'), 'Use --sep to choose the separator.\n');
const r = spawnSync('/bin/bash', ['-c', 'grep -E -e "--sep" -- README.md'], { cwd: dir, encoding: 'utf8' });
assert.equal(r.status, 0, r.stderr);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
// --- A5. sub-points belong to their criterion -------------------------------
function brief(sections) {
return [
'---', 'type: trekbrief', 'brief_version: "2.2"', 'task: t', 'slug: s',
'research_topics: 0', 'research_status: skipped', 'framing: new-direction', 'phase_signals_partial: true', '---', '',
'## TL;DR', '', 'A toy.', '', '## Intent', '', 'Why.', '', '## Goal', '', 'What.', '',
...sections,
].join('\n');
}
const NESTED = [
'## Success Criteria', '',
'- `npm test` exits 0.',
'- The separator is honoured:',
' - `-` joins words',
' - `_` joins words',
' - `.` joins words',
'- `bash tests/e2e.sh` exits 0.', '',
];
test('A5: indented sub-points are part of their criterion, not criteria of their own', () => {
const sc = parseSuccessCriteria(brief(NESTED));
assert.deepEqual(sc.map((c) => c.label), ['SC1', 'SC2', 'SC3']);
assert.equal(sc[1].subpoints, 3);
assert.equal(sc[2].command, 'bash tests/e2e.sh');
});
test('A5: a criterion with sub-points and no command of its own is a warning that names it', () => {
const r = validateBriefContent(brief(NESTED));
const w = r.warnings.filter((x) => x.code === 'BRIEF_SC_SUBPOINTS');
assert.equal(w.length, 1, JSON.stringify(r.warnings));
assert.match(w[0].message, /SC2/);
});
// --- A6. a bash 3.2 constraint is measured under bash 3.2 --------------------
const BASH32 = ['## Constraints', '', '- Scripts must run under bash 3.2 (macOS system bash).', ''];
test('A6: a brief that requires bash 3.2 and runs a criterion with plain `bash` gets a warning pointing at /bin/bash', () => {
const r = validateBriefContent(brief([...BASH32, '## Success Criteria', '', '- `bash tests/contract.sh` exits 0.', '']));
const w = r.warnings.filter((x) => x.code === 'BRIEF_SC_BASH_NOT_PINNED');
assert.equal(w.length, 1, JSON.stringify(r.warnings));
assert.match(w[0].hint, /\/bin\/bash tests\//);
});
test('A6: `/bin/bash`, or no bash 3.2 constraint, raises no such warning', () => {
const pinned = validateBriefContent(brief([...BASH32, '## Success Criteria', '', '- `/bin/bash tests/contract.sh` exits 0.', '']));
assert.ok(!codes(pinned.warnings).includes('BRIEF_SC_BASH_NOT_PINNED'));
const free = validateBriefContent(brief(['## Success Criteria', '', '- `bash tests/contract.sh` exits 0.', '']));
assert.ok(!codes(free.warnings).includes('BRIEF_SC_BASH_NOT_PINNED'));
});
test('A6: /trekbrief proposes /bin/bash when the brief requires bash 3.2', () => {
const md = read('commands/trekbrief.md');
assert.match(md, /requires\s+bash\s+3\.2[^`]*`\/bin\/bash tests\/<script>\.sh`/);
assert.match(md, /BRIEF_SC_BASH_NOT_PINNED/);
assert.match(md, /BRIEF_SC_SUBPOINTS/);
});
// --- A7. the plan template's checkpoint stages what it commits --------------
test('A7: every checkpoint the plan template and /trekplan\'s example show stages the step\'s files first', () => {
const lines = [
...read('templates/plan-template.md').split('\n'),
...read('commands/trekplan.md').split('\n'),
].filter((l) => /\*\*Checkpoint:\*\*/.test(l) && /`git [^`]*commit -m /.test(l));
assert.ok(lines.length >= 3, `found ${lines.length}`);
for (const l of lines) assert.match(l, /`git add [^`]+ && git commit -m /, l);
});
// --- the independent review of these fixes (2026-10-08) ----------------------
test('A1 (review): a `reuse` finding is an improvement too', () => {
const r = toReviewerPayload({ engine: 'code-review', findings: [engineFinding({ category: 'Reuse ' })] });
assert.equal(r.payload.findings[0].severity, 'MINOR');
});
test('A5 (review): a sub-bullet that carries its own command stays a criterion; only command-less sub-points fold', () => {
const sc = parseSuccessCriteria(brief(['## Success Criteria', '',
'- Behaviour:', ' - `npm test` exits 0', ' - `bash tests/e2e.sh` exits 0', ' - reads well', '']));
assert.deepEqual(sc.map((c) => c.command), [null, 'npm test', 'bash tests/e2e.sh']);
assert.equal(sc[0].subpoints, 1);
});
test('A5 (review): an indented first bullet does not turn later top-level bullets into sub-points', () => {
const sc = parseSuccessCriteria(brief(['## Success Criteria', '',
' - The first, indented by accident.', '- The second:', ' - a sub-point of the second', '- `npm test` exits 0.', '']));
assert.equal(sc.length, 3, JSON.stringify(sc.map((c) => c.text)));
assert.equal(sc[1].subpoints, 1);
});
test('A6 (review): a negated bash 3.2 constraint, an env prefix and `bash -c` are handled', () => {
const neg = validateBriefContent(brief(['## Constraints', '', '- bash 3.2 is not required here.', '',
'## Success Criteria', '', '- `bash tests/contract.sh` exits 0.', '']));
assert.ok(!codes(neg.warnings).includes('BRIEF_SC_BASH_NOT_PINNED'), 'negated constraint');
const env = validateBriefContent(brief([...BASH32, '## Success Criteria', '', '- `LC_ALL=C bash tests/contract.sh` exits 0.', '']));
assert.ok(codes(env.warnings).includes('BRIEF_SC_BASH_NOT_PINNED'), 'env-prefixed bash');
const dashC = validateBriefContent(brief([...BASH32, '## Success Criteria', '', '- `bash -c true` exits 0.', '']));
for (const w of dashC.warnings.filter((x) => x.code === 'BRIEF_SC_BASH_NOT_PINNED')) {
assert.doesNotMatch(w.hint, /\/bin\/bash -c/, 'never suggests a form the runner refuses');
}
});
test('A7 (review): the session-spec template stages before it commits too', () => {
const l = read('templates/session-spec-template.md').split('\n').filter((x) => /\*\*Checkpoint:\*\*/.test(x) && /`git [^`]*commit -m /.test(x));
assert.ok(l.length >= 1);
for (const x of l) assert.match(x, /`git add [^`]+ && git commit -m /, x);
});
test('docs (review): the counts and the Declined-to-Judge rule match the code', () => {
assert.match(read('docs/architecture.md'), /rule catalogue \(17 keys\)/);
assert.match(read('docs/HANDOVER-CONTRACTS.md'), /three engine rule keys/);
assert.doesNotMatch(read('lib/review/engine-findings.mjs'), /it forbids ALLOW and is listed under/);
assert.match(read('agents/review-coordinator.md'), /every `\[unverified\]` one, and every\s+out-of-scope engine finding/);
});

View file

@ -1,130 +0,0 @@
// tests/lib/trial2-minor-prose-pins.test.mjs
// Prose pins for the minor points of the second voyage-lekerepo trial
// (2026-10-08, report parts B and C) whose fix is text a session reads.
// Counted as doc pins by lib/util/test-census.mjs (the file name).
// B8 minors left after the last critic round have a way to be fixed
// (operator decision 2026-10-08: listed in the summary, the operator picks).
// B9 K10 `Regression:` is described where plans are written and reviewed.
// B10 K11 the four `On failure:` words are named where plans are written.
// B12 /trekbrief Step 4c no longer claims a foreground, blocking spawn.
// C13 the codebase-size count in /trekplan Phase 4 counts .mjs/.cjs/.mts/.cts.
// C17 git-historian leaves the brief's frozen files alone and names the
// remote from `git remote -v`.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFileSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { findSection } from '../../lib/parsers/plan-schema.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const read = (p) => readFileSync(join(ROOT, p), 'utf8');
/** The text of a level-2 section, up to the next `## ` heading outside a fence. */
function section(text, heading) {
const s = findSection(text, heading.replace(/^## /, ''), 2);
assert.ok(s, `missing ${heading}`);
return s.text;
}
// --- B8 ------------------------------------------------------------------------
test('B8: a PASS with minors sends them to the Phase 10 summary, numbered, for the operator to pick', () => {
const plan = read('commands/trekplan.md');
const p9 = section(plan, '## Phase 9 — Adversarial review (bounded critic loop)');
assert.match(p9, /\*\*PASS\*\*[\s\S]{0,600}minors[\s\S]{0,300}Phase 10/i);
const p10 = section(plan, '## Phase 10 — Present and refine');
assert.match(p10, /\*\*Open minors:\*\*/);
assert.match(p10, /numbered/i);
});
test('B8: fixing picked minors after the last round runs one more round with --extra-round, the pick being the yes', () => {
const p10 = section(read('commands/trekplan.md'), '## Phase 10 — Present and refine');
assert.match(p10, /--extra-round/);
assert.match(p10, /pick(ed|s)?\s+(is|counts as)\s+(the|their)\s+yes/i);
});
// --- B9 / B10 --------------------------------------------------------------------
test('B9: /trekplan Phase 8 names `Regression:` as an allowed optional field and says what it is for', () => {
const p8 = section(read('commands/trekplan.md'), '## Phase 8 — Deep planning');
assert.match(p8, /`Regression:`/);
assert.match(p8, /PLAN_STEP_REGRESSION_NOT_RUNNABLE/);
assert.match(p8, /\*\*Regression:\*\* `npm test`/, 'the canonical example shows the field');
assert.match(p8, /before (the|its) checkpoint/i);
});
test('B9: the plan template shows `Regression:` as optional', () => {
const t = read('templates/plan-template.md');
assert.match(t, /- \*\*Regression:\*\* `[^\n]*\*\(optional\)\*/);
assert.match(t, /`Regression:` \(ONE backticked command/);
});
test('B9: plan-critic knows the field and does not demand the suite in `Verify:`', () => {
const c = read('agents/plan-critic.md');
assert.match(c, /`Regression:`/);
assert.match(c, /(never|do\s+not)\s+(demand|require)\s+`npm test`\s+in\s+`Verify:`/i);
});
test('B9/B10: Handover 4 documents `Regression:` (K10) and the `On failure:` words (K11)', () => {
const h4 = section(read('docs/HANDOVER-CONTRACTS.md'), '## Handover 4 — `plan.md` → execute');
for (const code of ['PLAN_STEP_REGRESSION_NOT_RUNNABLE', 'PLAN_STEP_ON_FAILURE_UNKNOWN']) assert.match(h4, new RegExp(code));
assert.match(h4, /K10/);
assert.match(h4, /K11/);
assert.match(h4, /revert[^\n]*retry[^\n]*skip[^\n]*escalate/);
});
test('B10: /trekplan Phase 8 says `On failure:` opens with one of the four words', () => {
const p8 = section(read('commands/trekplan.md'), '## Phase 8 — Deep planning');
assert.match(p8, /On failure:\*\*[^\n]*(opens|starts) with/i);
assert.match(p8, /PLAN_STEP_ON_FAILURE_UNKNOWN/);
});
// --- B12 -------------------------------------------------------------------------
test('B12: /trekbrief Step 4c does not call the brief-reviewer spawn foreground or blocking, and waits for it', () => {
const t = read('commands/trekbrief.md');
const start = t.indexOf('**Step 4c — Launch brief-reviewer**');
const end = t.indexOf('**Step 4d — Parse JSON scores**');
assert.ok(start !== -1 && end > start);
const step = t.slice(start, end);
assert.doesNotMatch(step, /foreground, blocking/);
assert.match(step, /wait\s+for\s+(its|the agent's)\s+(result|completion)/i);
assert.match(step, /(background|asynchronous)/i, 'says how the spawn actually runs');
});
// --- C13 -------------------------------------------------------------------------
test('C13: the Phase 4 size count counts .mjs, .cjs, .mts and .cts files', () => {
const p4 = section(read('commands/trekplan.md'), '## Phase 4 — Codebase sizing');
const cmd = p4.match(/```\n(find [^\n]+)\n```/);
assert.ok(cmd, 'the find command stands in a fence of its own');
const dir = mkdtempSync(join(tmpdir(), 'voyage-sizing-'));
try {
for (const f of ['a.mjs', 'b.cjs', 'c.mts', 'd.cts', 'e.ts', 'skip.txt']) writeFileSync(join(dir, f), 'x\n');
const r = spawnSync('/bin/sh', ['-c', cmd[1]], { cwd: dir, encoding: 'utf8' });
assert.equal(r.status, 0, r.stderr);
assert.equal(r.stdout.trim(), '5');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
// --- C17 -------------------------------------------------------------------------
test('C17: git-historian reads the brief, never proposes changing a file it freezes', () => {
const g = read('agents/git-historian.md');
assert.match(g, /brief/i);
assert.match(g, /(never|do not) (propose|suggest|recommend)[^\n]*(change|edit)[^\n]*(frozen|unchanged)|(frozen|unchanged)[^\n]*(never|do not) (propose|suggest|recommend)/i);
const p5 = section(read('commands/trekplan.md'), '## Phase 5 — Parallel exploration (specialized agents + research)');
assert.match(p5, /\*\*git-historian\*\*[\s\S]{0,400}\{brief_path\}/, 'the spawn hands it the brief');
});
test('C17: git-historian names the remote from `git remote -v`, never by guess', () => {
const g = read('agents/git-historian.md');
assert.match(g, /`git remote -v`/);
assert.match(g, /local (path|repository|bare)/i);
});

View file

@ -1,253 +0,0 @@
// tests/lib/trial2-minor.test.mjs
// The minor points of the second voyage-lekerepo trial (2026-10-08, report
// parts B and C) that are behaviour, not prose. Each test was red before its
// fix.
// B9 K10 — optional step field `Regression:`: one runnable command, or the
// validator refuses it.
// B10 K11 — `On failure:` opens with revert | retry | skip | escalate, or the
// validator warns.
// B11 two findings on the same file:line with the same content merge (the
// trial's pair measured Jaccard 0.44 against a 0.7 threshold).
// C14 `--evidence` shows the first output line on a PASS row, not "—".
// C16 a file:line cited in a reviewer's prose is checked against the file.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { validatePlanContent } from '../../lib/validators/plan-validator.mjs';
import { onFailureAction, ON_FAILURE_ACTIONS } from '../../lib/parsers/plan-schema.mjs';
import { dedupFindings, DEFAULT_THRESHOLD } from '../../lib/review/plan-review-dedup.mjs';
import { formatCriteriaEvidence } from '../../lib/verification/criteria-runner.mjs';
import { validateFindings, validateReviewerOutput } from '../../lib/review/findings-schema.mjs';
import { buildPlan } from '../helpers/plan-v18-builder.mjs';
const codes = (list) => list.map((e) => e.code);
// Step 1's Verify line in the builder's plan; the new field goes right under it.
const STEP1_VERIFY = '- **Verify:** `node --test tests/mod1.test.mjs` → expected: pass';
const withRegression = (line, plan = buildPlan()) => plan.replace(STEP1_VERIFY, `${STEP1_VERIFY}\n${line}`);
const withOnFailure = (text, plan = buildPlan()) =>
plan.replace(/^- \*\*On failure:\*\* .*$/m, `- **On failure:** ${text}`);
// --- B9: K10 `Regression:` ----------------------------------------------------
test('K10: `Regression: npm test | tee x` is refused — one command, unpiped', () => {
const r = validatePlanContent(withRegression('- **Regression:** `npm test | tee x` → expected: exit 0'));
assert.equal(r.valid, false);
const e = r.errors.find((x) => x.code === 'PLAN_STEP_REGRESSION_NOT_RUNNABLE');
assert.ok(e, codes(r.errors).join(','));
assert.match(e.message, /Step 1/);
assert.match(e.message, /shell operator/);
});
test('K10: `Regression: npm test` is accepted', () => {
const r = validatePlanContent(withRegression('- **Regression:** `npm test` → expected: exit 0'));
assert.deepEqual(codes(r.errors), []);
assert.ok(!codes(r.warnings).includes('PLAN_STEP_REGRESSION_NOT_RUNNABLE'));
});
test('K10: without the field the plan validates exactly as before', () => {
const r = validatePlanContent(buildPlan());
assert.equal(r.valid, true, codes(r.errors).join(','));
assert.deepEqual(codes(r.warnings), []);
});
test('K10: `npm test && bash tests/e2e.sh`, a bare word, or no backticks is refused', () => {
for (const line of [
'- **Regression:** `npm test && bash tests/e2e.sh`',
'- **Regression:** `./run-all-tests`',
'- **Regression:** npm test',
'- **Regression:**',
]) {
const r = validatePlanContent(withRegression(line));
assert.ok(codes(r.errors).includes('PLAN_STEP_REGRESSION_NOT_RUNNABLE'), `${line}: ${codes(r.errors).join(',')}`);
}
});
test('K10: --soft reports an unrunnable Regression as a warning, not an error', () => {
const r = validatePlanContent(withRegression('- **Regression:** `npm test | tee x`'), { strict: false });
assert.equal(r.valid, true, codes(r.errors).join(','));
assert.ok(codes(r.warnings).includes('PLAN_STEP_REGRESSION_NOT_RUNNABLE'));
});
// --- B10: K11 `On failure:` ---------------------------------------------------
test('K11: `On failure: try again` warns, and the plan stays valid', () => {
const r = validatePlanContent(withOnFailure('try again'));
assert.equal(r.valid, true, codes(r.errors).join(','));
const w = r.warnings.find((x) => x.code === 'PLAN_STEP_ON_FAILURE_UNKNOWN');
assert.ok(w, codes(r.warnings).join(','));
assert.match(w.message, /Step 1/);
for (const word of ON_FAILURE_ACTIONS) assert.match(w.hint, new RegExp(word));
});
test('K11: `On failure: retry once, then escalate` gives no warning', () => {
const r = validatePlanContent(withOnFailure('retry once, then escalate'));
assert.ok(!codes(r.warnings).includes('PLAN_STEP_ON_FAILURE_UNKNOWN'), codes(r.warnings).join(','));
});
test('K11: the four words, in any case and with markdown around them, are read; a longer word is not', () => {
assert.deepEqual([...ON_FAILURE_ACTIONS], ['revert', 'retry', 'skip', 'escalate']);
assert.equal(onFailureAction('revert — `git checkout -- x`'), 'revert');
assert.equal(onFailureAction('Escalate: stop the run'), 'escalate');
assert.equal(onFailureAction('**skip** this step'), 'skip');
assert.equal(onFailureAction('`retry` once'), 'retry');
assert.equal(onFailureAction('retrying is fine'), null);
assert.equal(onFailureAction('try again'), null);
assert.equal(onFailureAction(''), null);
});
// --- B11: same file:line, same content ---------------------------------------
// The two findings from trial 2's round 1, verbatim (plan.md:248).
const PLAN_MD = '.claude/projects/2026-10-08-slug-sep-option/plan.md';
const CRITIC_248 = {
severity: 'minor', rule_key: 'verification/contract-brief-form-missing', file: PLAN_MD, line: 248,
text: "Verification runs `bash tests/e2e.sh` in the brief's own form but not `bash tests/contract.sh`, the form brief SC4 names.",
};
const GUARDIAN_248 = {
severity: 'minor', rule_key: 'gap-verification-command-form', file: PLAN_MD, line: 248,
text: "Brief SC4 runs 'bash tests/contract.sh'; Verification lists only '/bin/bash tests/contract.sh' (unlike e2e, where both forms are listed) - add the brief's form",
};
const sources = (a, b) => [
{ agent: 'plan-critic', payload: { findings: a } },
{ agent: 'scope-guardian', payload: { findings: b } },
];
test('B11: the two trial-2 findings on plan.md:248 merge into one, raised by both', () => {
const r = dedupFindings(sources([CRITIC_248], [GUARDIAN_248]));
assert.equal(r.findings.length, 1, JSON.stringify(r.findings, null, 2));
assert.deepEqual(r.findings[0].raised_by, ['plan-critic', 'scope-guardian']);
assert.equal(r.dedup_stats.anchor_dups, 1);
});
test('B11: two different findings on the same line stay two', () => {
const other = {
severity: 'minor', rule_key: 'verification/no-negative-case', file: PLAN_MD, line: 248,
text: 'No criterion feeds an invalid separator such as a letter and checks the exit code.',
};
const r = dedupFindings(sources([CRITIC_248], [other]));
assert.equal(r.findings.length, 2);
});
test('B11: a short finding held by a longer one on the same line stays its own (independent review)', () => {
const at = (text, severity = 'minor') => ({ severity, rule_key: `k-${text.length}`, file: PLAN_MD, line: 40, text });
for (const [a, b] of [
['The token is checked.', 'The token is not checked before the write.'],
['Step 3 test is missing.', 'Step 3 is missing a checkpoint; the test file already exists.'],
['Verify runs npm test.', 'Verify must not run npm test, it is green before the step.'],
]) {
assert.equal(dedupFindings(sources([at(a)], [at(b)])).findings.length, 2, `${a} | ${b}`);
}
});
test('B11: one reviewer\'s two findings on one line are never merged by the anchor rule', () => {
const r = dedupFindings([{ agent: 'plan-critic', payload: { findings: [CRITIC_248, { ...GUARDIAN_248, rule_key: 'other' }] } }]);
assert.equal(r.findings.length, 2);
assert.equal(r.dedup_stats.anchor_dups, 0);
});
test('B11: the same content on another line is not merged by the anchor rule; the threshold elsewhere is unchanged', () => {
assert.equal(DEFAULT_THRESHOLD, 0.7);
const r = dedupFindings(sources([CRITIC_248], [{ ...GUARDIAN_248, line: 249 }]));
assert.equal(r.findings.length, 2);
const noLine = dedupFindings(sources([{ ...CRITIC_248, line: 0 }], [{ ...GUARDIAN_248, line: 0 }]));
assert.equal(noLine.findings.length, 2, 'line 0 is file-scoped, not an anchor');
});
// --- C14: --evidence on a PASS row ---------------------------------------------
const evidenceRep = (results) => ({
source: 'brief.md',
heading: '## Success Criteria',
error: null,
results,
summary: { passed: 1, failed: 0, blocked: 0, unrunnable: 0, total: 1, ok: true },
});
test('C14: --evidence shows the first line of output on a passed criterion', () => {
const block = formatCriteriaEvidence(evidenceRep([
{ label: 'SC1', text: 't', command: 'npm test', status: 'passed', exitCode: 0, output: '\n# tests 12\n# pass 12\n' },
]));
assert.match(block, /\| SC1 \| `npm test` \| PASS \| exit 0 \| # tests 12 \|/);
});
test('C14: a passed criterion with no output still says "—"', () => {
const block = formatCriteriaEvidence(evidenceRep([
{ label: 'SC1', text: 't', command: 'npm test', status: 'passed', exitCode: 0, output: '' },
]));
assert.match(block, /\| SC1 \| `npm test` \| PASS \| exit 0 \| — \|/);
});
// --- C16: file:line in prose --------------------------------------------------
function repoWith33() {
const dir = mkdtempSync(join(tmpdir(), 'voyage-prose-cite-'));
mkdirSync(join(dir, 'tests'));
writeFileSync(join(dir, 'tests', 'x.sh'), Array.from({ length: 33 }, (_, i) => `echo ${i + 1}`).join('\n') + '\n');
return dir;
}
const finding = (over = {}) => ({
id: '0123456789abcdef0123456789abcdef01234567',
severity: 'MAJOR', rule_key: 'UNIMPLEMENTED_CRITERION', file: 'tests/x.sh', line: 3,
title: 'A title', detail: 'A detail.', recommended_action: 'Do one thing.', ...over,
});
test('C16: a finding whose prose cites `tests/x.sh:119` in a 33-line file is refused', () => {
const dir = repoWith33();
try {
const r = validateFindings(
{ reviewer: 'code-correctness-reviewer', findings: [finding({ detail: 'The check at `tests/x.sh:119` relies on git.' })] },
{ repoRoot: dir },
);
assert.equal(r.valid, false);
const e = r.errors.find((x) => x.code === 'FINDING_PROSE_LINE_OUT_OF_RANGE');
assert.ok(e, JSON.stringify(r.errors));
assert.match(e.message, /tests\/x\.sh:119/);
assert.match(e.message, /33 lines/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('C16: the same citation in prose outside the JSON block is a warning, never silent (a JSON-only re-ask cannot fix prose)', () => {
const dir = repoWith33();
try {
const raw = [
'### Risk noted but not raised as a finding',
'',
'`tests/x.sh:119` relies on `git` and on a fixed commit.',
'',
'```json',
JSON.stringify({ reviewer: 'code-correctness-reviewer', findings: [] }),
'```',
].join('\n');
const r = validateReviewerOutput(raw, { repoRoot: dir });
assert.equal(r.valid, true, JSON.stringify(r.errors));
const w = r.warnings.find((x) => x.code === 'FINDING_PROSE_LINE_OUT_OF_RANGE');
assert.ok(w, JSON.stringify(r.warnings));
assert.match(w.message, /reviewer prose cites `?tests\/x\.sh:119/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('C16: a line that exists, a range ending in the file, a file absent from the tree and no repo root all pass', () => {
const dir = repoWith33();
try {
const ok = (detail, opts = { repoRoot: dir }) =>
validateFindings({ reviewer: 'r', findings: [finding({ detail })] }, opts).valid;
assert.equal(ok('See tests/x.sh:33 and tests/x.sh:10-12.'), true);
assert.equal(ok('See src/absent.mjs:900.'), true);
assert.equal(ok('See tests/x.sh:119.', {}), true);
assert.equal(ok('See tests/x.sh:30-40.'), false, 'a range that runs past the end is out of range');
assert.equal(ok('Served on http://localhost:8080 and example.com:443.'), true);
assert.equal(ok('On Windows: C:\\work\\tests\\x.sh:119 and tests\\x.sh:119.'), true, 'a Windows path is not the root\'s file');
assert.equal(ok('See tests/x.sh:30\u201440.'), false, 'an em-dash range is read as a range');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

View file

@ -20,9 +20,11 @@
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { isAbsolute, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildHtml, renderMarkdown, render, parseArgs } from '../../scripts/annotate.mjs';
const SAMPLE = `---
@ -219,3 +221,18 @@ plain code
assert.match(html, /<pre data-anchor-id=/);
assert.match(html, /<blockquote data-anchor-id=/);
});
test('CLI: a relative input still prints an absolute .html path (a file:// link can use it)', () => {
const dir = mkdtempSync(join(tmpdir(), 'voyage-annot-'));
try {
writeFileSync(join(dir, 'brief.md'), '# Brief\n\nText.\n');
const script = fileURLToPath(new URL('../../scripts/annotate.mjs', import.meta.url));
const r = spawnSync(process.execPath, [script, 'brief.md'], { cwd: dir, encoding: 'utf8' });
assert.equal(r.status, 0, r.stderr);
const out = r.stdout.trim();
assert.ok(isAbsolute(out), out);
assert.ok(existsSync(out), out);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

View file

@ -116,9 +116,9 @@ test('plan determinism — forward-compat: synthetic v1.7 plan validates with --
// Sanity check that adding profile_used to manifest-yaml schema doesn't
// regress full plan-validator strict-mode behaviour on a v1.7 plan with
// standard step + manifest structure. Uses a committed synthetic fixture
// (plan-run-C.md) instead of a gitignored project plan so the assertion
// (tests/fixtures/plan-run-c.md) instead of a gitignored project plan so the assertion
// is stable across worktrees and headless runs.
const fixturePlan = 'tests/synthetic/plan-run-C.md';
const fixturePlan = 'tests/fixtures/plan-run-c.md';
const { validatePlan } = await import('../../lib/validators/plan-validator.mjs');
const result = await validatePlan(join(ROOT, fixturePlan), { strict: true });
assert.equal(result.valid, true, `synthetic plan must validate strict: ${JSON.stringify(result.errors)}`);

View file

@ -1,98 +0,0 @@
---
type: trekplan-synthetic
plan_version: "1.7"
created: 2026-05-10
slug: plan-run-C
task: "Synthetic v1.7 plan fixture for plan-validator forward-compat test"
profile: balanced
run_id: C
---
# Synthetic Plan-Run C — Minimal v1.7 Fixture
> **Plan quality: A** (95/100) — APPROVE
>
> Generated by trekplan v4.1.0 on 2026-05-10 — `plan_version: 1.7`
>
> Profile: `balanced`
## Context
Minimal synthetic fixture used by `tests/synthetic/plan-determinism.test.mjs`
forward-compat assertion: any v1.7 plan must validate cleanly under `--strict`
mode after the v4.1 schema additions (`profile_used`, `profile`, etc.).
## Implementation Plan
Each step targets one focused change. Three dummy steps satisfy the heading
+ manifest requirements without exercising real implementation.
### Step 1: Add config entry for verbose flag
- **Files:** `package.json`
- **Changes:** Add `verbose` boolean to config entry.
- **Reuses:** existing config-entry pattern.
- **Verify:** `grep -c "verbose" package.json` → expected: `1`
- **On failure:** revert
- **Checkpoint:** `git commit -m "feat(synth): add verbose config entry"`
- **Manifest:**
```yaml
manifest:
expected_paths:
- package.json
min_file_count: 1
commit_message_pattern: "^feat\\(synth\\): add verbose"
bash_syntax_check: []
forbidden_paths: []
must_contain: []
```
### Step 2: Define types for verbose mode
- **Files:** `types.ts`
- **Changes:** Export `VerboseMode` enum with `silent | normal | verbose`.
- **Reuses:** existing type-export pattern.
- **Verify:** `grep -c "VerboseMode" types.ts` → expected: `1`
- **On failure:** revert
- **Checkpoint:** `git commit -m "feat(synth): define VerboseMode enum"`
- **Manifest:**
```yaml
manifest:
expected_paths:
- types.ts
min_file_count: 1
commit_message_pattern: "^feat\\(synth\\): define VerboseMode"
bash_syntax_check: []
forbidden_paths: []
must_contain: []
```
### Step 3: Wire verbose flag into parseArgs
- **Files:** `cli.ts`
- **Changes:** Recognise `--verbose` flag in `parseArgs`, pass `VerboseMode` to logger.
- **Reuses:** parseArgs flag-recognition pattern.
- **Verify:** `grep -c "--verbose" cli.ts` → expected: `1`
- **On failure:** revert
- **Checkpoint:** `git commit -m "feat(synth): wire --verbose into parseArgs"`
- **Manifest:**
```yaml
manifest:
expected_paths:
- cli.ts
min_file_count: 1
commit_message_pattern: "^feat\\(synth\\): wire --verbose"
bash_syntax_check: []
forbidden_paths: []
must_contain: []
```
## Verification
- [ ] `node lib/validators/plan-validator.mjs --strict --json tests/synthetic/plan-run-C.md` → `valid: true`, no `PLAN_VERSION_MISMATCH` warning
## Estimated Scope
- **Files to modify:** 3
- **Files to create:** 0
- **Complexity:** low (synthetic fixture only)

View file

@ -451,3 +451,68 @@ test('CLI — --min-version still locates the file positional after the value to
rmSync(dir, { recursive: true, force: true });
}
});
const codes = (list) => list.map((e) => e.code);
function briefWith(sections) {
return [
'---', 'type: trekbrief', 'brief_version: "2.2"', 'task: t', 'slug: s',
'research_topics: 0', 'research_status: skipped', 'framing: new-direction', 'phase_signals_partial: true', '---', '',
'## TL;DR', '', 'A toy.', '', '## Intent', '', 'Why.', '', '## Goal', '', 'What.', '',
...sections,
].join('\n');
}
// ---- BRIEF_SC_SUBPOINTS: sub-points belong to their criterion -----------------
test('BRIEF_SC_SUBPOINTS: a criterion with sub-points and no command of its own is a warning that names it', () => {
const r = validateBriefContent(briefWith([
'## Success Criteria', '',
'- `npm test` exits 0.',
'- The separator is honoured:',
' - `-` joins words',
' - `_` joins words',
' - `.` joins words',
'- `bash tests/e2e.sh` exits 0.', '',
]));
const w = r.warnings.filter((x) => x.code === 'BRIEF_SC_SUBPOINTS');
assert.equal(w.length, 1, JSON.stringify(r.warnings));
assert.match(w[0].message, /SC2/);
});
// ---- BRIEF_SC_BASH_NOT_PINNED: a bash 3.2 constraint is measured under bash 3.2 ----
const BASH32 = ['## Constraints', '', '- Scripts must run under bash 3.2 (macOS system bash).', ''];
test('BRIEF_SC_BASH_NOT_PINNED: a brief that requires bash 3.2 and runs a criterion with plain `bash` gets a warning pointing at /bin/bash', () => {
const r = validateBriefContent(briefWith([...BASH32, '## Success Criteria', '', '- `bash tests/contract.sh` exits 0.', '']));
const w = r.warnings.filter((x) => x.code === 'BRIEF_SC_BASH_NOT_PINNED');
assert.equal(w.length, 1, JSON.stringify(r.warnings));
assert.match(w[0].hint, /\/bin\/bash tests\//);
});
test('BRIEF_SC_BASH_NOT_PINNED: `/bin/bash`, or no bash 3.2 constraint, raises no such warning', () => {
const pinned = validateBriefContent(briefWith([...BASH32, '## Success Criteria', '', '- `/bin/bash tests/contract.sh` exits 0.', '']));
assert.ok(!codes(pinned.warnings).includes('BRIEF_SC_BASH_NOT_PINNED'));
const free = validateBriefContent(briefWith(['## Success Criteria', '', '- `bash tests/contract.sh` exits 0.', '']));
assert.ok(!codes(free.warnings).includes('BRIEF_SC_BASH_NOT_PINNED'));
});
test('BRIEF_SC_BASH_NOT_PINNED: a negated bash 3.2 constraint, an env prefix and `bash -c` are handled', () => {
const neg = validateBriefContent(briefWith(['## Constraints', '', '- bash 3.2 is not required here.', '',
'## Success Criteria', '', '- `bash tests/contract.sh` exits 0.', '']));
assert.ok(!codes(neg.warnings).includes('BRIEF_SC_BASH_NOT_PINNED'), 'negated constraint');
const env = validateBriefContent(briefWith([...BASH32, '## Success Criteria', '', '- `LC_ALL=C bash tests/contract.sh` exits 0.', '']));
assert.ok(codes(env.warnings).includes('BRIEF_SC_BASH_NOT_PINNED'), 'env-prefixed bash');
const dashC = validateBriefContent(briefWith([...BASH32, '## Success Criteria', '', '- `bash -c true` exits 0.', '']));
for (const w of dashC.warnings.filter((x) => x.code === 'BRIEF_SC_BASH_NOT_PINNED')) {
assert.doesNotMatch(w.hint, /\/bin\/bash -c/, 'never suggests a form the runner refuses');
}
});
test('/trekbrief proposes /bin/bash when the brief requires bash 3.2, and names both warnings', () => {
const md = readFileSync(new URL('../../commands/trekbrief.md', import.meta.url), 'utf8');
assert.match(md, /requires\s+bash\s+3\.2[^`]*`\/bin\/bash tests\/<script>\.sh`/);
assert.match(md, /BRIEF_SC_BASH_NOT_PINNED/);
assert.match(md, /BRIEF_SC_SUBPOINTS/);
});

View file

@ -691,7 +691,6 @@ test('acceptOpen after round 3 lists only the real majors, never a lowered one',
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const GATE = join(ROOT, 'lib', 'validators', 'plan-gate.mjs');
const E2E = join(ROOT, 'tests', 'e2e-critic-diff-mode.sh');
const GIT_CLEAN = { GIT_DIR: undefined, GIT_WORK_TREE: undefined, GIT_INDEX_FILE: undefined };
function gate(cwd, args, input) {
@ -935,13 +934,63 @@ test('23 (CLI): --record on a plan outside any git repo keeps no snapshot anywhe
}
});
test('24: the e2e script is ASCII only and bash -n clean', () => {
assert.ok(existsSync(E2E), 'tests/e2e-critic-diff-mode.sh exists');
const buf = readFileSync(E2E);
assert.equal(buf.findIndex(b => b > 127), -1, 'ASCII only');
assert.match(buf.toString('utf-8'), /e2e: ok/);
const r = spawnSync('/bin/bash', ['-n', E2E], { encoding: 'utf-8', env: childEnv() });
assert.equal(r.status, 0, r.stderr);
// The pipe /trekplan Phase 9 runs: the raw reviewer payload through the dedup
// CLI's --stdin, its stdout into the gate's --record (no in-process dedup).
const DEDUP = join(ROOT, 'lib', 'review', 'plan-review-dedup.mjs');
/** One round through the two CLIs: --hash, dedup --stdin on `payload`, then --record on its output. */
function pipeRound(dir, p, payload) {
const reviewed = hashOf(dir, p);
const d = spawnSync(process.execPath, [DEDUP, '--stdin'], { cwd: dir, input: JSON.stringify(payload), encoding: 'utf-8', env: childEnv(GIT_CLEAN) });
assert.equal(d.status, 0, `plan-review-dedup --stdin: ${d.stdout}${d.stderr}`);
const r = gate(dir, ['--record', p, '--reviewed', reviewed], d.stdout);
const lines = r.out.split('\n').filter(l => l !== '');
return { ...r, reviewed, j: lines.length ? JSON.parse(lines[lines.length - 1]) : null };
}
test('CLI pipe: dedup --stdin into --record counts round 2 by rule A, and a refused and a malformed lowering reach the gate as blockers', () => {
withRepo(buildPlan(), (p, dir) => {
const t1 = readPlan(p);
const raw = (findings, previous) => ({ findings, ...(previous ? { previous } : {}) });
const r1 = pipeRound(dir, p, {
plan_critic: raw([f('major', at(t1, S1_VERIFIES), 'Step 1 verification never states the expected return value')]),
scope_guardian: raw([]),
});
assert.equal(r1.code, 0, r1.out + r1.err);
assert.equal(r1.j.verdict, 'REVISE', r1.out);
const id = verdictIn(p).findings[0].id;
assert.match(id, /^[0-9a-f]{40}$/);
// Round 2: the round-1 major resolved by both, a new major on a line outside the edit.
const t2 = editA(readPlan(p));
writeFileSync(p, t2);
const prev = [{ id, resolved: true }];
const r2 = pipeRound(dir, p, { plan_critic: raw([guard(t2)], prev), scope_guardian: raw([], prev) });
assert.equal(r2.code, 0, r2.out + r2.err);
assert.equal(r2.j.verdict, 'PASS', r2.out);
assert.equal(r2.j.diff_base, r1.reviewed, 'round 2 diffs against round 1');
const g2 = gate(dir, [p]);
assert.equal(g2.code, 0, g2.out + g2.err);
// Round 3 (plan unchanged): a minor merged with its copy that claims a lowering
// from blocker, and a minor whose lowered_from is not a severity.
const t3 = readPlan(p);
const M9 = 'Step 1 never names the exit code its verify command expects';
const G9 = 'The revert for step one is never run against a dirty tree';
const r3 = pipeRound(dir, p, {
plan_critic: raw([f('minor', at(t3, S1_VERIFIES), M9)]),
scope_guardian: raw([
f('minor', at(t3, S1_VERIFIES), M9, { lowered_from: 'blocker', caught_by: 'npm test' }),
{ ...f('minor', at(t3, S1_ROLLBACK), G9, { lowered_from: 'high', caught_by: 'npm test' }), rule_key: 'gap' },
]),
});
assert.equal(r3.code, 0, r3.out + r3.err);
assert.equal(r3.j.verdict, 'BLOCK', r3.out);
assert.match(r3.out, /"blocker":2/, `round 3 counts two blockers: ${r3.out}`);
const g3 = gate(dir, [p]);
assert.equal(g3.code, 1, g3.out + g3.err);
assert.match(g3.out + g3.err, /PLAN_GATE_OPEN_BLOCKER/);
});
});
// ---- the matcher needs overlapping texts; a new id never repeats an old one ----

View file

@ -1,6 +1,8 @@
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { validatePlanContent } from '../../lib/validators/plan-validator.mjs';
import { ON_FAILURE_ACTIONS } from '../../lib/parsers/plan-schema.mjs';
import { buildPlan } from '../helpers/plan-v18-builder.mjs';
const VALID_PLAN = `---
plan_version: "1.7"
@ -111,3 +113,87 @@ test('validatePlan — prose plan_version line parses, no PLAN_NO_VERSION (defec
assert.equal(r.parsed.planVersion, '1.7');
assert.equal(r.warnings.find(w => w.code === 'PLAN_NO_VERSION'), undefined);
});
const codes = (list) => list.map((e) => e.code);
// ---- PLAN_STEP_TESTS_ONLY: a step whose Files are all tests can never be proven red ----
// Step 2 changes only tests for behaviour step 1 delivered.
const TESTS_ONLY_PLAN = buildPlan({
steps: [{}, { file: 'tests/e2e.sh', test: 'tests/e2e-guard.test.mjs', endToEnd: 'bash tests/e2e.sh' }],
});
test('a step whose Files are all tests gives PLAN_STEP_TESTS_ONLY, a warning on that step', () => {
const r = validatePlanContent(TESTS_ONLY_PLAN);
const w = r.warnings.filter((x) => x.code === 'PLAN_STEP_TESTS_ONLY');
assert.equal(w.length, 1, codes(r.warnings).join(','));
assert.match(w[0].message, /Step 2/);
assert.ok(!codes(r.errors).includes('PLAN_STEP_TESTS_ONLY'), 'a warning, never an error');
assert.equal(r.valid, true, codes(r.errors).join(','));
});
test('a step with code and its test gives no PLAN_STEP_TESTS_ONLY', () => {
const r = validatePlanContent(buildPlan());
assert.ok(!codes(r.warnings).includes('PLAN_STEP_TESTS_ONLY'), codes(r.warnings).join(','));
});
// ---- K10 `Regression:` and K11 `On failure:` -----------------------------------
// Step 1's Verify line in the builder's plan; the new field goes right under it.
const STEP1_VERIFY = '- **Verify:** `node --test tests/mod1.test.mjs` → expected: pass';
const withRegression = (line, plan = buildPlan()) => plan.replace(STEP1_VERIFY, `${STEP1_VERIFY}\n${line}`);
const withOnFailure = (text, plan = buildPlan()) =>
plan.replace(/^- \*\*On failure:\*\* .*$/m, `- **On failure:** ${text}`);
test('K10: `Regression: npm test | tee x` is refused — one command, unpiped', () => {
const r = validatePlanContent(withRegression('- **Regression:** `npm test | tee x` → expected: exit 0'));
assert.equal(r.valid, false);
const e = r.errors.find((x) => x.code === 'PLAN_STEP_REGRESSION_NOT_RUNNABLE');
assert.ok(e, codes(r.errors).join(','));
assert.match(e.message, /Step 1/);
assert.match(e.message, /shell operator/);
});
test('K10: `Regression: npm test` is accepted', () => {
const r = validatePlanContent(withRegression('- **Regression:** `npm test` → expected: exit 0'));
assert.deepEqual(codes(r.errors), []);
assert.ok(!codes(r.warnings).includes('PLAN_STEP_REGRESSION_NOT_RUNNABLE'));
});
test('K10: without the field the plan validates exactly as before', () => {
const r = validatePlanContent(buildPlan());
assert.equal(r.valid, true, codes(r.errors).join(','));
assert.deepEqual(codes(r.warnings), []);
});
test('K10: `npm test && bash tests/e2e.sh`, a bare word, or no backticks is refused', () => {
for (const line of [
'- **Regression:** `npm test && bash tests/e2e.sh`',
'- **Regression:** `./run-all-tests`',
'- **Regression:** npm test',
'- **Regression:**',
]) {
const r = validatePlanContent(withRegression(line));
assert.ok(codes(r.errors).includes('PLAN_STEP_REGRESSION_NOT_RUNNABLE'), `${line}: ${codes(r.errors).join(',')}`);
}
});
test('K10: --soft reports an unrunnable Regression as a warning, not an error', () => {
const r = validatePlanContent(withRegression('- **Regression:** `npm test | tee x`'), { strict: false });
assert.equal(r.valid, true, codes(r.errors).join(','));
assert.ok(codes(r.warnings).includes('PLAN_STEP_REGRESSION_NOT_RUNNABLE'));
});
test('K11: `On failure: try again` warns, and the plan stays valid', () => {
const r = validatePlanContent(withOnFailure('try again'));
assert.equal(r.valid, true, codes(r.errors).join(','));
const w = r.warnings.find((x) => x.code === 'PLAN_STEP_ON_FAILURE_UNKNOWN');
assert.ok(w, codes(r.warnings).join(','));
assert.match(w.message, /Step 1/);
for (const word of ON_FAILURE_ACTIONS) assert.match(w.hint, new RegExp(word));
});
test('K11: `On failure: retry once, then escalate` gives no warning', () => {
const r = validatePlanContent(withOnFailure('retry once, then escalate'));
assert.ok(!codes(r.warnings).includes('PLAN_STEP_ON_FAILURE_UNKNOWN'), codes(r.warnings).join(','));
});