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>
This commit is contained in:
parent
2c89a6b580
commit
2ee8137e7c
31 changed files with 3278 additions and 107 deletions
126
tests/hooks/post-edit-check.test.mjs
Normal file
126
tests/hooks/post-edit-check.test.mjs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
// 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')));
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue