// tests/hooks/post-edit-check.test.mjs // P7 — the guard after edits (PostToolUse Edit|Write). It runs the target // repo's OWN formatter and linter on the edited file (the fast tier), logs the // result for the step check (K9), and says "not measured" when the repo has no // tool for that file — never green. // // Operator decision 06.10: it is on only while Voyage executes a plan in that // repo, or when the repo has switched it on (.claude/voyage-guards.json with // "post_edit_check": true). Every other session sees nothing. // // Tested both ways: a file the fake formatter rejects must be reported (the // "must stop" side — PostToolUse cannot undo the edit, so the stop happens at // step end, via K9), and a clean file must pass. import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { writeFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { runHook } from '../helpers/hook-helper.mjs'; import { ROOT, scriptsFor, makeRepo, fakeTool, parseJsonOut } from './guard-helpers.mjs'; const HOOK = join(ROOT, 'hooks', 'scripts', 'post-edit-check.mjs'); function optIn(repo) { mkdirSync(join(repo, '.claude'), { recursive: true }); writeFileSync(join(repo, '.claude', 'voyage-guards.json'), JSON.stringify({ post_edit_check: true })); } function executing(repo, status = 'in_progress') { const dir = join(repo, '.claude', 'projects', '2026-10-06-x'); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, 'progress.json'), JSON.stringify({ status, steps: {} })); } function prettierRepo() { const repo = makeRepo('main'); writeFileSync(join(repo, '.prettierrc'), '{}\n'); fakeTool(join(repo, 'node_modules', '.bin'), 'prettier'); return repo; } async function afterEdit(repo, rel, content) { const file = join(repo, rel); mkdirSync(join(file, '..'), { recursive: true }); writeFileSync(file, content); const r = await runHook(HOOK, { hook_event_name: 'PostToolUse', tool_name: 'Edit', cwd: repo, tool_input: { file_path: file, old_string: 'a', new_string: 'b' }, tool_response: { filePath: file }, }); return { ...r, out: parseJsonOut(r.stdout) }; } const logOf = (repo) => { const p = join(repo, '.git', 'voyage', 'post-edit.jsonl'); return existsSync(p) ? readFileSync(p, 'utf8').trim().split('\n').map((l) => JSON.parse(l)) : []; }; test('P7 off: a repo that has not switched it on and runs no Voyage plan -> silent, no log', async () => { const repo = prettierRepo(); const r = await afterEdit(repo, 'src/a.js', 'BAD\n'); assert.equal(r.code, 0); assert.equal(r.out, null); assert.equal(logOf(repo).length, 0); }); test('P7 must report: switched on, the repo\'s formatter rejects the file', async () => { const repo = prettierRepo(); optIn(repo); const r = await afterEdit(repo, 'src/a.js', 'BAD\n'); assert.equal(r.code, 0, r.stderr); assert.equal(r.out?.decision, 'block', `findings go back to Claude next to the tool result: ${r.stdout}`); assert.match(r.out.reason, /prettier/); const log = logOf(repo); assert.equal(log.length, 1); assert.equal(log[0].status, 'finding'); }); test('P7 must pass: switched on, the formatter accepts the file', async () => { const repo = prettierRepo(); optIn(repo); const r = await afterEdit(repo, 'src/a.js', 'ok\n'); assert.equal(r.code, 0); assert.notEqual(r.out?.decision, 'block'); assert.equal(logOf(repo)[0].status, 'clean'); }); test('P7 on during a Voyage plan run (progress.json in_progress), without the switch', async () => { const repo = prettierRepo(); executing(repo); const r = await afterEdit(repo, 'src/a.js', 'BAD\n'); assert.equal(r.out?.decision, 'block'); }); test('P7 off once the plan run is completed', async () => { const repo = prettierRepo(); executing(repo, 'completed'); const r = await afterEdit(repo, 'src/a.js', 'BAD\n'); assert.equal(r.code, 0, r.stderr); assert.equal(r.out, null); }); test('P7 not measured: switched on in a repo with no tools -> "not measured", never green', async () => { const repo = makeRepo('main'); optIn(repo); const r = await afterEdit(repo, 'src/a.js', 'whatever\n'); assert.equal(r.code, 0); assert.match(r.out?.hookSpecificOutput?.additionalContext || '', /not measured/i); assert.doesNotMatch(r.out.hookSpecificOutput.additionalContext, /\bclean\b/i); assert.equal(logOf(repo)[0].status, 'not_measured'); }); test('P7 not measured: a configured tool that is not installed is not a pass', async () => { const repo = makeRepo('main'); optIn(repo); writeFileSync(join(repo, '.prettierrc'), '{}\n'); // config, but no node_modules/.bin/prettier const r = await afterEdit(repo, 'src/a.js', 'BAD\n'); assert.match(r.out?.hookSpecificOutput?.additionalContext || '', /not measured/i); }); test('wiring: hooks.json runs post-edit-check on PostToolUse for Edit and Write', () => { for (const tool of ['Edit', 'Write']) { assert.ok(scriptsFor('PostToolUse', tool).some((p) => p.endsWith('post-edit-check.mjs')), tool); } assert.ok(!scriptsFor('PostToolUse', 'Bash').some((p) => p.endsWith('post-edit-check.mjs'))); });