voyage/tests/hooks/merge-evidence.test.mjs

504 lines
24 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');
});