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>
126 lines
5.1 KiB
JavaScript
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')));
|
|
});
|