voyage/tests/hooks/post-edit-check.test.mjs
Kjell Tore Guttormsen 2ee8137e7c
feat(hooks): wave-1 guards -- approval ask, plan gate, test lock, post-edit check
Wave-1 order 4 (P7, P12, the P9 test lock, the P3 plan-approval guard).
All command hooks; none runs a model.

- P12: pre-bash-executor answers permissionDecision "ask" before merge
  into main, push, tag/publish and destructive git (holds in auto mode).
  post-approval-log writes exactly what the person was asked to the K2
  approval log: merge/push to main as `merge`, tag/publish as `release`,
  with a one-hour expiry; nothing when no person was asked.
- P3: pre-plan-gate runs the K3 plan gate on a Voyage plan leaving plan
  mode and sends a refused plan back; "not measured" asks, never passes.
- P9: lib/util/test-lock.mjs (K8). pre-write-executor refuses edits to
  locked test files; stop-test-lock refuses "done" for the locking
  session (stop_hook_active guarded).
- P7: post-edit-check runs the target repo's own formatter/linter per
  edit, only during a Voyage plan run or after opt-in
  (.claude/voyage-guards.json); lib/verification/step-check.mjs (K9) fails
  the step on findings; no tool reads "not measured", never green.
- STORM cap matcher also names `Agent` (the tool's name since 2.1.63).
- Mod precondition stated in README, SECURITY.md and every PreToolUse stop
  guard: Voyage's guards are deterministic only when no installed mod
  handles `tool.check`.

K2, K3 and K5 are stubbed in tests until orders 2 and 3 are merged.
Two independent review rounds; every finding has a regression test.
Suite 1600 (1598 pass / 0 fail / 2 skipped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:09:32 +02:00

126 lines
5.1 KiB
JavaScript

// 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')));
});