voyage/tests/hooks/merge-evidence.test.mjs
Kjell Tore Guttormsen 029e83117c
test(r8): move the live tests the move map missed
33 more tests of live behaviour from the deleted round files, each in
the test file named after its source and seen failing under a mutation
of that source (log in the project folder). Two stay dropped: one pinned
the removed gate states, one the old Phase 1.5 file comparison. The two
trekexecute.md sentences that still said /trekcontinue compares the two
prompt files now say it uses the project-folder file (line count kept).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 13:11:25 +01:00

528 lines
26 KiB
JavaScript

// tests/hooks/merge-evidence.test.mjs
// [MEV] fix round 4.5b2 part B, Step 11: the guard's merge question says how
// the run was tested and how it is undone. Phase 8 of /trekexecute writes an
// evidence file (lib/review/merge-evidence.mjs write) under the git common
// dir, named by the head sha of the branch being merged; the Bash guard
// (hooks/scripts/pre-bash-executor.mjs) reads it with hooks/lib/merge-evidence.mjs
// into two lines of the question it already asks:
// Tested by: End-to-end `<cmd>` exit <n>; Verification <passed>/<total>; review <verdict> at <sha8>.
// Undone by: `git revert -m 1 <merge>` (two-way). | (one-way: <text>). | (door not stated).
// Fail-safe: the `ask` decision is computed before the reader runs and never
// changes; a missing, stale, foreign or hostile file, or a field of the wrong
// type, reads "no run evidence" or "not measured", never a green line.
// The decision is `ask` at the base too, so it is a Control in each case,
// never a tagged case of its own. Every child gets childEnv(); every fixture
// that reaches the verdict gate keeps HEAD at the reviewed commit (R6).
import { after, test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import {
appendFileSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync,
symlinkSync, utimesSync, writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, isAbsolute, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { runHookWithEnv } from '../helpers/hook-helper.mjs';
import { childEnv } from '../helpers/child-env.mjs';
import { pluginCopy } from './guard-helpers.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const PRE_BASH = join(ROOT, 'hooks', 'scripts', 'pre-bash-executor.mjs');
const READER = join(ROOT, 'hooks', 'lib', 'merge-evidence.mjs');
const WRITER = join(ROOT, 'lib', 'review', 'merge-evidence.mjs');
// The permission mode in which Claude Code shows no prompts, read from the
// guard's own constant: this file only feeds the guard its own value back.
const NO_PROMPT_MODE = (() => {
const m = readFileSync(PRE_BASH, 'utf8').match(/const NO_PROMPT_MODE = '([A-Za-z]+)';/);
assert.ok(m, 'the guard names its no-prompt permission mode');
return m[1];
})();
const UNDONE_TWO_WAY = 'Undone by: `git revert -m 1 <merge>` (two-way).';
const UNDONE_NOT_STATED = 'Undone by: `git revert -m 1 <merge>` (door not stated).';
const NO_EVIDENCE = 'Tested by: no run evidence.\nUndone by: no run evidence.';
const UNREADABLE = 'Tested by: not measured (the run evidence file could not be read).\nUndone by: not measured.';
const made = [];
after(() => { for (const b of made) rmSync(b, { recursive: true, force: true }); });
function g(cwd, ...args) {
const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: childEnv() });
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
return r.stdout.trim();
}
const run = (args, opts = {}) => spawnSync(process.execPath, args, { encoding: 'utf8', env: childEnv(), ...opts });
const parse = (r) => { try { return JSON.parse(r.stdout); } catch { return {}; } };
/**
* A throwaway repo on `main` (README) with a branch `feat` one commit ahead
* (a.mjs), checked out on main. No hooks, no signing.
*/
function mevRepo() {
const base = realpathSync(mkdtempSync(join(tmpdir(), 'merge-evidence-mev-')));
made.push(base);
const repo = join(base, 'repo');
mkdirSync(repo, { recursive: true });
mkdirSync(join(base, 'hooks-none'));
g(repo, 'init', '-q', '-b', 'main');
appendFileSync(join(repo, '.git', 'config'), '[user]\n\tname = Test\n\temail = test@example.invalid\n'
+ `[commit]\n\tgpgsign = false\n[core]\n\thooksPath = "${join(base, 'hooks-none')}"\n`);
writeFileSync(join(repo, 'README.md'), '# t\n');
g(repo, 'add', 'README.md');
g(repo, 'commit', '-q', '-m', 'init');
const mainSha = g(repo, 'rev-parse', 'HEAD');
g(repo, 'checkout', '-q', '-b', 'feat');
writeFileSync(join(repo, 'a.mjs'), 'export const a = 1;\n');
g(repo, 'add', 'a.mjs');
g(repo, 'commit', '-q', '-m', 'feat: the work');
const featSha = g(repo, 'rev-parse', 'HEAD');
g(repo, 'checkout', '-q', 'main');
const common = join(repo, '.git');
const evDir = join(common, 'voyage', 'merge-evidence');
return {
base, repo, mainSha, featSha, common, evDir,
evPath: (sha = featSha) => join(evDir, `${sha}.json`),
/** Write an evidence file by hand (`raw` text, or an object as JSON). */
put: (body, sha = featSha) => {
mkdirSync(evDir, { recursive: true });
writeFileSync(join(evDir, `${sha}.json`), typeof body === 'string' ? body : JSON.stringify(body, null, 2));
},
};
}
/** A complete evidence object for `sha` on branch feat. */
function evidence(sha, over = {}) {
return {
v: 1, branch: 'feat', branch_head_sha: sha, written_at: '2026-11-02T10:00:00.000Z',
end_to_end: { command: '/bin/bash tests/e2e.sh', exit: 0 },
verification: { passed: 5, total: 5 },
review: { verdict: 'ALLOW', sha },
door: { type: 'two-way', text: 'a revert restores the old gate' },
...over,
};
}
const tested = (sha, { cmd = '/bin/bash tests/e2e.sh', exit = '0', ver = '5/5', verdict = 'ALLOW' } = {}) =>
`Tested by: End-to-end \`${cmd}\` exit ${exit}; Verification ${ver}; review ${verdict} at ${sha.slice(0, 8)}.`;
/** The guard on a Bash call, run in `cwd`; `script` and `env` pick a plugin copy and its environment. */
async function ask(cwd, { command = 'git merge --no-ff feat', mode, script = PRE_BASH, env = {} } = {}) {
const input = { hook_event_name: 'PreToolUse', tool_name: 'Bash', cwd, tool_input: { command } };
if (mode) input.permission_mode = mode;
const r = await runHookWithEnv(script, input, env);
let out = null;
try { out = JSON.parse(String(r.stdout).trim()); } catch { out = null; }
return {
...r,
decision: out?.hookSpecificOutput?.permissionDecision ?? null,
reason: String(out?.hookSpecificOutput?.permissionDecisionReason ?? ''),
};
}
/** A minimal ALLOW review of `sha` (shape copied from tests/lib/review-answers.test.mjs `review`). */
function review(sha) {
const id = '1'.padStart(40, 'a');
const json = JSON.stringify({
verdict: 'ALLOW',
counts: { BLOCKER: 0, MAJOR: 0, MINOR: 1, SUGGESTION: 0 },
allow_blocked_by: [],
findings: [{ id, severity: 'MINOR', rule_key: 'MISSING_TEST', file: 'a.mjs', line: 1, title: 't', detail: 'd', recommended_action: 'Add a test.' }],
}, null, 2);
return `---
type: trekreview
review_version: "1.1"
task: "t"
slug: s
project_dir: .claude/projects/2026-11-02-s/
brief_path: .claude/projects/2026-11-02-s/brief.md
scope_sha_end: ${sha}
reviewed_files_count: 1
verdict: ALLOW
findings:
- ${id}
---
# Review: t
## Executive Summary
x
## Coverage
| File | Treatment | Reason |
|------|-----------|--------|
| a.mjs | summary-only | default |
## Remediation Summary
x
\`\`\`json
${json}
\`\`\`
`;
}
/** A plan with an End-to-end step and an optional `## Doors` list (lines). */
function plan({ e2e = '/bin/bash tests/e2e-x.sh', doors } = {}) {
return `# Plan
> Generated by trekplan v6.5.0 on 2026-11-02 — \`plan_version: 1.9\`
## Implementation Plan
### Step 1: the work
- **Files:** \`a.mjs\`
- **Verify:** \`node --test tests/a.test.mjs\` → expected: pass
### Step 2: End to end
- **End-to-end:** \`${e2e}\` → expected: \`e2e: ok\`, exit 0.
${doors ? `\n## Doors\n\n${doors.join('\n')}\n` : ''}
## Verification
\`\`\`bash
${e2e}
\`\`\`
`;
}
/** A project folder outside the repo; files written only when given. */
function project(fx, { planText, progress, reviewText } = {}) {
const dir = mkdtempSync(join(fx.base, 'project-'));
if (planText !== undefined) writeFileSync(join(dir, 'plan.md'), planText);
if (progress !== undefined) writeFileSync(join(dir, 'progress.json'), JSON.stringify(progress, null, 2));
if (reviewText !== undefined) writeFileSync(join(dir, 'review.md'), reviewText);
return dir;
}
const PROGRESS = {
schema_version: '1', status: 'completed', end_to_end: 'verified',
plan_verification: { status: 'passed', summary: { total: 7, passed: 7, failed: 0, blocked: 0, unrunnable: 0 }, failed_criteria: [] },
};
/** The evidence writer's CLI; the module must exist (so a missing one fails on this assertion, not in the child). */
function write(repo, branch, proj) {
assert.ok(existsSync(WRITER), 'lib/review/merge-evidence.mjs must exist');
return run([WRITER, 'write', '--repo', repo, '--branch', branch, '--project', proj, '--json']);
}
// ---- [MEV] the lines ---------------------------------------------------------------
test('[MEV] the lines: the merge question holds the Tested by and Undone by lines from an evidence file of the merged branch head', async () => {
const fx = mevRepo();
fx.put(evidence(fx.featSha));
const r = await ask(fx.repo);
assert.equal(r.decision, 'ask', `Control: the merge into main is asked about: ${r.stdout}${r.stderr}`);
assert.ok(r.reason.includes(`${tested(fx.featSha)}\n${UNDONE_TWO_WAY}`), `the two lines, in order:\n${r.reason}`);
// Control: the question the guard already asks is still there, the lines after it.
assert.ok(r.reason.startsWith('Voyage asks before this command (P12 approval guard): merge into main.'), r.reason);
assert.ok(r.reason.indexOf('Way forward:') < r.reason.indexOf('Tested by:'), r.reason);
});
// ---- [MEV] the door ----------------------------------------------------------------
test('[MEV] the door: a one-way door in the plan\'s ## Doors prints it; none prints "door not stated"', async () => {
const fx = mevRepo();
const oneWay = project(fx, { planText: plan({ doors: ['- one-way: v1 files no longer read'] }), progress: PROGRESS });
const w = write(fx.repo, 'feat', oneWay);
assert.equal(w.status, 0, `writer: ${w.stdout}${w.stderr}`);
const r = await ask(fx.repo);
assert.equal(r.decision, 'ask', `Control: ${r.stdout}${r.stderr}`);
assert.ok(r.reason.includes('Undone by: `git revert -m 1 <merge>` (one-way: v1 files no longer read).'), r.reason);
const none = project(fx, { planText: plan(), progress: PROGRESS });
const w2 = write(fx.repo, 'feat', none);
assert.equal(w2.status, 0, `writer: ${w2.stdout}${w2.stderr}`);
const n = await ask(fx.repo);
assert.equal(n.decision, 'ask', `Control: ${n.stdout}${n.stderr}`);
assert.ok(n.reason.includes(UNDONE_NOT_STATED), n.reason);
// Control: with no door in the plan, the CHANGELOG's first section decides when it holds exactly one door line.
writeFileSync(join(fx.repo, 'CHANGELOG.md'), '# Changelog\n\n## Unreleased\n\n### Doors\n\n- two-way: the new lines can be reverted\n\n## v1.0.0\n\n### Doors\n\n- one-way: old\n');
assert.equal(write(fx.repo, 'feat', none).status, 0);
assert.ok((await ask(fx.repo)).reason.includes(UNDONE_TWO_WAY), 'Control: the CHANGELOG door line');
writeFileSync(join(fx.repo, 'CHANGELOG.md'), '# Changelog\n\n## Unreleased\n\n### Doors\n\n- two-way: a\n- one-way: b\n');
assert.equal(write(fx.repo, 'feat', none).status, 0);
assert.ok((await ask(fx.repo)).reason.includes(UNDONE_NOT_STATED), 'Control: two door lines in the CHANGELOG state no door');
});
// ---- [MEV] fail-safe ---------------------------------------------------------------
test('[MEV] fail-safe: no file, a stale or foreign file, an unreadable field or a reader that will not load says "no run evidence" or "not measured", and still asks', async () => {
const fx = mevRepo();
// No file.
const none = await ask(fx.repo);
assert.equal(none.decision, 'ask', `Control: ${none.stdout}${none.stderr}`);
assert.ok(none.reason.includes(NO_EVIDENCE), `no file: ${none.reason}`);
// Stale: the file at the branch head's name holds another head.
fx.put(evidence(fx.mainSha));
const stale = await ask(fx.repo);
assert.equal(stale.decision, 'ask', `Control: ${stale.stdout}${stale.stderr}`);
assert.ok(stale.reason.includes(NO_EVIDENCE), `stale head sha: ${stale.reason}`);
assert.ok(!stale.reason.includes('Tested by: End-to-end'), stale.reason);
// Stale: evidence for the old head, then the branch moved on.
fx.put(evidence(fx.featSha));
g(fx.repo, 'checkout', '-q', 'feat');
writeFileSync(join(fx.repo, 'b.mjs'), 'export const b = 2;\n');
g(fx.repo, 'add', 'b.mjs');
g(fx.repo, 'commit', '-q', '-m', 'feat: more');
g(fx.repo, 'checkout', '-q', 'main');
const moved = await ask(fx.repo);
assert.equal(moved.decision, 'ask', `Control: ${moved.stdout}${moved.stderr}`);
assert.ok(moved.reason.includes(NO_EVIDENCE), `the branch moved on: ${moved.reason}`);
const head = g(fx.repo, 'rev-parse', 'feat');
// Foreign: another format version, or another branch's name.
fx.put(evidence(head, { v: 2 }), head);
assert.ok((await ask(fx.repo)).reason.includes(NO_EVIDENCE), 'a file of another version');
fx.put(evidence(head, { branch: 'other' }), head);
assert.ok((await ask(fx.repo)).reason.includes(NO_EVIDENCE), 'a file for another branch');
// Unreadable fields: each reads "not measured", none prints a number or a verdict.
fx.put(evidence(head, {
end_to_end: { command: 42, exit: '0' }, verification: { passed: '7', total: 7 },
review: { verdict: 'GREEN', sha: 'abc' }, door: { type: 'sideways', text: 'x' },
}), head);
const bad = await ask(fx.repo);
assert.equal(bad.decision, 'ask', `Control: ${bad.stdout}${bad.stderr}`);
assert.ok(bad.reason.includes('Tested by: End-to-end command not measured exit not measured; Verification not measured; review not measured at not measured.'), bad.reason);
assert.ok(bad.reason.includes(UNDONE_NOT_STATED), bad.reason);
// Missing fields read the same.
fx.put({ v: 1, branch: 'feat', branch_head_sha: head }, head);
const missing = await ask(fx.repo);
assert.ok(missing.reason.includes('Tested by: End-to-end command not measured exit not measured; Verification not measured; review not measured at not measured.'), missing.reason);
// More passed than total is no count.
fx.put(evidence(head, { verification: { passed: 9, total: 7 } }), head);
assert.ok((await ask(fx.repo)).reason.includes('Verification not measured;'), 'passed > total');
// Non-printable and non-ASCII characters print as `?`.
fx.put(evidence(head, { end_to_end: { command: 'e2e\u0007ü\nrm', exit: 0 } }), head);
const odd = await ask(fx.repo);
assert.ok(odd.reason.includes('End-to-end `e2e???rm` exit 0;'), odd.reason);
// Bad JSON.
fx.put('{"v": 1, "branch": ', head);
const json = await ask(fx.repo);
assert.equal(json.decision, 'ask', `Control: ${json.stdout}${json.stderr}`);
assert.ok(json.reason.includes(UNREADABLE), `bad JSON: ${json.reason}`);
// A merge with no single branch named reads "no run evidence".
fx.put(evidence(head), head);
for (const command of ['git merge --no-ff', 'git merge --no-ff feat other', 'git merge -m feat']) {
const r = await ask(fx.repo, { command });
assert.equal(r.decision, 'ask', `Control: ${command}`);
assert.ok(r.reason.includes(NO_EVIDENCE), `${command}: ${r.reason}`);
}
// Control: option values are skipped, so the branch is still found.
const opts = await ask(fx.repo, { command: 'git merge --no-ff -s ort -X ours feat' });
assert.ok(opts.reason.includes(tested(head)), opts.reason);
// A reader that will not load: the question still asks and says so.
const plugin = pluginCopy({ 'hooks/lib/merge-evidence.mjs': 'missing' });
made.push(plugin);
const gone = await ask(fx.repo, { script: join(plugin, 'hooks', 'scripts', 'pre-bash-executor.mjs') });
assert.equal(gone.decision, 'ask', `Control: ${gone.stdout}${gone.stderr}`);
assert.ok(gone.reason.includes('(run evidence not loaded)'), gone.reason);
});
test('[MEV] a hostile evidence path still asks: a FIFO, a symlink and a 10 MiB file read "not measured"', async () => {
const fx = mevRepo();
// A good file elsewhere: followed, the symlink would print the green line.
const elsewhere = join(fx.base, 'good.json');
writeFileSync(elsewhere, JSON.stringify(evidence(fx.featSha)));
mkdirSync(fx.evDir, { recursive: true });
symlinkSync(elsewhere, fx.evPath());
const link = await ask(fx.repo);
assert.equal(link.decision, 'ask', `Control: ${link.stdout}${link.stderr}`);
assert.ok(link.reason.includes(UNREADABLE), `a symlink: ${link.reason}`);
assert.ok(!link.reason.includes(tested(fx.featSha)), link.reason);
rmSync(fx.evPath());
const fifo = spawnSync('mkfifo', [fx.evPath()], { encoding: 'utf8', env: childEnv() });
assert.equal(fifo.status, 0, `mkfifo: ${fifo.stderr}`);
const pipe = await ask(fx.repo);
assert.equal(pipe.decision, 'ask', `Control: a FIFO does not hang the guard: ${pipe.code} ${pipe.stdout}${pipe.stderr}`);
assert.ok(pipe.reason.includes(UNREADABLE), `a FIFO: ${pipe.reason}`);
rmSync(fx.evPath());
// Valid JSON with the good fields, padded past 10 MiB.
writeFileSync(fx.evPath(), JSON.stringify(evidence(fx.featSha)) + ' '.repeat(10 * 1024 * 1024));
const big = await ask(fx.repo);
assert.equal(big.decision, 'ask', `Control: ${big.stdout}${big.stderr}`);
assert.ok(big.reason.includes(UNREADABLE), `a 10 MiB file: ${big.reason}`);
assert.ok(!big.reason.includes(tested(fx.featSha)), big.reason);
// A directory at the evidence path.
rmSync(fx.evPath());
mkdirSync(fx.evPath());
const dir = await ask(fx.repo);
assert.equal(dir.decision, 'ask', `Control: ${dir.stdout}${dir.stderr}`);
assert.ok(dir.reason.includes(UNREADABLE), `a directory: ${dir.reason}`);
rmSync(fx.evPath(), { recursive: true });
// Control: unattended, the merge is still refused, evidence or not.
fx.put(evidence(fx.featSha));
const unattended = await ask(fx.repo, { mode: NO_PROMPT_MODE });
assert.equal(unattended.code, 2, `Control: unattended refuses the merge: ${unattended.stdout}${unattended.stderr}`);
assert.equal(unattended.decision, null, 'Control: no ask where no one can be asked');
assert.match(unattended.stderr, /BLOCKED: merge into main with no one to ask/);
});
// ---- [MEV] round trip --------------------------------------------------------------
test('[MEV] round trip: a file written by merge-evidence.mjs write is read back by the guard into the same two lines', async () => {
const fx = mevRepo();
const proj = project(fx, {
planText: plan({ doors: ['- two-way: a revert restores the old gate'] }),
progress: PROGRESS,
reviewText: review(fx.featSha),
});
// HEAD at the reviewed commit while the writer asks the verdict gate (R6).
g(fx.repo, 'checkout', '-q', 'feat');
const w = write(fx.repo, 'feat', proj);
assert.equal(w.status, 0, `writer: ${w.stdout}${w.stderr}`);
const out = parse(w);
assert.equal(out.written, true, w.stdout);
assert.equal(out.path, fx.evPath(), w.stdout);
const file = JSON.parse(readFileSync(fx.evPath(), 'utf8'));
assert.equal(file.v, 1);
assert.equal(file.branch, 'feat');
assert.equal(file.branch_head_sha, fx.featSha);
assert.match(String(file.written_at), /^\d{4}-\d\d-\d\dT/);
assert.deepEqual(file.end_to_end, { command: '/bin/bash tests/e2e-x.sh', exit: 0 });
assert.deepEqual(file.verification, { passed: 7, total: 7 });
assert.deepEqual(file.review, { verdict: 'ALLOW', sha: fx.featSha });
assert.deepEqual(file.door, { type: 'two-way', text: 'a revert restores the old gate' });
g(fx.repo, 'checkout', '-q', 'main');
const r = await ask(fx.repo);
assert.equal(r.decision, 'ask', `Control: ${r.stdout}${r.stderr}`);
assert.ok(r.reason.includes(`${tested(fx.featSha, { cmd: '/bin/bash tests/e2e-x.sh', ver: '7/7' })}\n${UNDONE_TWO_WAY}`), r.reason);
// Control: an End-to-end that is not verified writes no exit; a review the gate does not pass writes no verdict.
g(fx.repo, 'checkout', '-q', 'feat');
writeFileSync(join(proj, 'progress.json'), JSON.stringify({ ...PROGRESS, end_to_end: 'not-verified' }));
writeFileSync(join(proj, 'review.md'), review(fx.mainSha));
assert.equal(write(fx.repo, 'feat', proj).status, 0);
g(fx.repo, 'checkout', '-q', 'main');
const nv = await ask(fx.repo);
assert.ok(nv.reason.includes('Tested by: End-to-end `/bin/bash tests/e2e-x.sh` exit not measured; Verification 7/7; review not measured at '), nv.reason);
// Control: a branch name that is not a plain name is refused, and nothing is written.
for (const branch of ['..feat', 'fe..at', '-feat', 'fe\u0001at', 'nope']) {
const bad = write(fx.repo, branch, proj);
assert.equal(bad.status, 1, `refused ${JSON.stringify(branch)}: ${bad.stdout}${bad.stderr}`);
assert.equal(parse(bad).written, false, bad.stdout);
assert.equal(typeof parse(bad).reason, 'string', bad.stdout);
}
});
// ---- [MEV] where the file lives ----------------------------------------------------
test('[MEV] the evidence file sits under the git common dir, and git status --porcelain in the fixture shows nothing new', async () => {
const fx = mevRepo();
// The run in a linked worktree, main in the first one (Phase 8 merges with `git -C`).
const wt = join(fx.base, 'wt');
g(fx.repo, 'worktree', 'add', '-q', wt, 'feat');
const proj = project(fx, { planText: plan(), progress: PROGRESS, reviewText: review(fx.featSha) });
const before = [g(fx.repo, 'status', '--porcelain', '--untracked-files=all'), g(wt, 'status', '--porcelain', '--untracked-files=all')];
const w = write(wt, 'feat', proj);
assert.equal(w.status, 0, `writer: ${w.stdout}${w.stderr}`);
const raw = g(wt, 'rev-parse', '--git-common-dir');
const common = realpathSync(isAbsolute(raw) ? raw : resolve(wt, raw));
const path = parse(w).path;
assert.equal(realpathSync(path), join(common, 'voyage', 'merge-evidence', `${fx.featSha}.json`), w.stdout);
assert.ok(!realpathSync(path).startsWith(`${realpathSync(wt)}/`), 'not in the worktree');
const afterStatus = [g(fx.repo, 'status', '--porcelain', '--untracked-files=all'), g(wt, 'status', '--porcelain', '--untracked-files=all')];
assert.deepEqual(afterStatus, before, 'git status shows nothing new in either tree');
// Control: the guard in main's tree finds the file the run's tree wrote.
const r = await ask(fx.repo);
assert.equal(r.decision, 'ask', `Control: ${r.stdout}${r.stderr}`);
assert.ok(r.reason.includes(`${tested(fx.featSha, { cmd: '/bin/bash tests/e2e-x.sh', ver: '7/7' })}\n${UNDONE_NOT_STATED}`), r.reason);
});
// ---- [MEV] the reader's architecture rule ------------------------------------------
/** What the reader must never hold: a process spawn, a `../../lib/` import, a non-ASCII byte. */
function scanReader(path) {
const buf = readFileSync(path);
const text = buf.toString('latin1');
const found = [];
if (/child_process/.test(text)) found.push('child_process');
if (/['"`]\.\.\/\.\.\/lib\//.test(text)) found.push('../../lib/ import');
if (buf.some((b) => b > 0x7f)) found.push('non-ASCII byte');
return found;
}
test('[MEV] the reader is ASCII and spawns nothing', () => {
assert.ok(existsSync(READER), 'hooks/lib/merge-evidence.mjs must exist');
assert.deepEqual(scanReader(READER), [], 'the real reader');
const dir = mkdtempSync(join(tmpdir(), 'merge-evidence-scan-'));
made.push(dir);
const copy = join(dir, 'merge-evidence.mjs');
const plants = [
["import { spawnSync } from 'node:child_process';\n", 'child_process'],
["const x = require('node:child_process');\n", 'child_process'],
["import { parseDoors } from '../../lib/util/doors.mjs';\n", '../../lib/ import'],
['// café\n', 'non-ASCII byte'],
];
for (const [line, want] of plants) {
copyFileSync(READER, copy);
appendFileSync(copy, line);
assert.deepEqual(scanReader(copy), [want], `planted: ${line.trim()}`);
}
copyFileSync(READER, copy);
assert.deepEqual(scanReader(copy), [], 'the clean copy');
});
// ---- [MEV] the writer's clean-up ---------------------------------------------------
test('[MEV] the writer removes an evidence file older than 30 days and keeps a fresh one', () => {
const fx = mevRepo();
mkdirSync(fx.evDir, { recursive: true });
const old = join(fx.evDir, `${'a'.repeat(40)}.json`);
const fresh = join(fx.evDir, `${'b'.repeat(40)}.json`);
const notes = join(fx.evDir, 'notes.txt');
for (const p of [old, fresh, notes]) writeFileSync(p, '{}\n');
const longAgo = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
utimesSync(old, longAgo, longAgo);
utimesSync(notes, longAgo, longAgo);
const w = write(fx.repo, 'feat', project(fx, { planText: plan(), progress: PROGRESS }));
assert.equal(w.status, 0, `writer: ${w.stdout}${w.stderr}`);
assert.ok(!existsSync(old), 'the evidence file older than 30 days is removed');
assert.ok(existsSync(fresh), 'the fresh evidence file is kept');
assert.ok(existsSync(notes), 'Control: a file not named <hex>.json is never removed');
assert.ok(existsSync(fx.evPath()), 'the new evidence file is written');
});
/** The text from `heading` up to the next `## ` heading; the heading must exist. */
function untilNextH2(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);
}
test('[MEV] docs/HANDOVER-CONTRACTS.md documents the evidence file as internal', () => {
const text = readFileSync(join(ROOT, 'docs', 'HANDOVER-CONTRACTS.md'), 'utf8');
const note = untilNextH2(text, '## Internal files');
const f = note.replace(/\s+/g, ' ').trim();
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(untilNextH2(text, '## Handover 5'), /merge-evidence/, 'Control: Handover 5 is unchanged');
});