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
104
tests/lib/test-lock.test.mjs
Normal file
104
tests/lib/test-lock.test.mjs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
// tests/lib/test-lock.test.mjs
|
||||
// K8 — the test lock CLI (lib/util/test-lock.mjs). The executor (wave-1 order
|
||||
// 1) locks a step's test files once the test is written and seen failing, and
|
||||
// unlocks them when the test passes. The lock lives inside the repository's
|
||||
// git directory, so it can never be committed.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { makeRepo, gitIn, tmp } from '../hooks/guard-helpers.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const CLI = join(HERE, '..', '..', 'lib', 'util', 'test-lock.mjs');
|
||||
|
||||
function run(cwd, args, env = {}) {
|
||||
const r = spawnSync(process.execPath, [CLI, ...args], { cwd, encoding: 'utf8', env: { ...process.env, ...env } });
|
||||
return { code: r.status, stdout: r.stdout, stderr: r.stderr };
|
||||
}
|
||||
|
||||
function repoWithTests() {
|
||||
const repo = makeRepo('main');
|
||||
mkdirSync(join(repo, 'tests'), { recursive: true });
|
||||
writeFileSync(join(repo, 'tests', 'a.test.js'), 'a\n');
|
||||
writeFileSync(join(repo, 'tests', 'b.test.js'), 'b\n');
|
||||
return repo;
|
||||
}
|
||||
|
||||
test('K8 lock: exit 0, status names the step and the files', () => {
|
||||
const repo = repoWithTests();
|
||||
const r = run(repo, ['lock', '--files', 'tests/a.test.js,tests/b.test.js', '--step', '3']);
|
||||
assert.equal(r.code, 0, r.stderr);
|
||||
const s = run(repo, ['status']);
|
||||
assert.equal(s.code, 0);
|
||||
const st = JSON.parse(s.stdout);
|
||||
assert.equal(st.locked, true);
|
||||
assert.equal(st.step, 3);
|
||||
assert.equal(st.files.length, 2);
|
||||
assert.ok(st.files.every((f) => f.endsWith('.test.js')));
|
||||
});
|
||||
|
||||
test('K8 lock: stored outside what can be committed (git status stays clean)', () => {
|
||||
const repo = repoWithTests();
|
||||
gitIn(repo, 'add', '-A');
|
||||
gitIn(repo, 'commit', '-q', '-m', 'tests');
|
||||
assert.equal(run(repo, ['lock', '--files', 'tests/a.test.js', '--step', '1']).code, 0);
|
||||
assert.equal(gitIn(repo, 'status', '--porcelain'), '', 'the lock must not appear as a change in the repo');
|
||||
const st = JSON.parse(run(repo, ['status']).stdout);
|
||||
assert.ok(st.lock_path.includes(`${'.git'}`), `lock path should be inside the git dir: ${st.lock_path}`);
|
||||
});
|
||||
|
||||
test('K8 lock records the Claude Code session it was taken in', () => {
|
||||
const repo = repoWithTests();
|
||||
run(repo, ['lock', '--files', 'tests/a.test.js', '--step', '2'], { CLAUDE_CODE_SESSION_ID: 'sess-123' });
|
||||
assert.equal(JSON.parse(run(repo, ['status']).stdout).session, 'sess-123');
|
||||
});
|
||||
|
||||
test('K8 unlock: exit 0, status unlocked', () => {
|
||||
const repo = repoWithTests();
|
||||
run(repo, ['lock', '--files', 'tests/a.test.js', '--step', '1']);
|
||||
assert.equal(run(repo, ['unlock']).code, 0);
|
||||
assert.equal(JSON.parse(run(repo, ['status']).stdout).locked, false);
|
||||
});
|
||||
|
||||
test('K8 lock refuses (exit 1) while another step holds the lock', () => {
|
||||
const repo = repoWithTests();
|
||||
run(repo, ['lock', '--files', 'tests/a.test.js', '--step', '1']);
|
||||
const r = run(repo, ['lock', '--files', 'tests/b.test.js', '--step', '2']);
|
||||
assert.equal(r.code, 1);
|
||||
assert.match(r.stderr, /step 1/);
|
||||
});
|
||||
|
||||
test('K8: --cwd points at another directory', () => {
|
||||
const repo = repoWithTests();
|
||||
assert.equal(run(tmp(), ['lock', '--cwd', repo, '--files', 'tests/a.test.js', '--step', '4']).code, 0);
|
||||
assert.equal(JSON.parse(run(tmp(), ['status', '--cwd', repo]).stdout).step, 4);
|
||||
});
|
||||
|
||||
test('K8 could not run (exit 2): not a git repository', () => {
|
||||
const r = run(tmp(), ['lock', '--files', 'x.test.js', '--step', '1']);
|
||||
assert.equal(r.code, 2);
|
||||
});
|
||||
|
||||
test('K8 could not run (exit 2): bad usage', () => {
|
||||
const repo = repoWithTests();
|
||||
assert.equal(run(repo, ['lock', '--step', '1']).code, 2, 'no --files');
|
||||
assert.equal(run(repo, ['lock', '--files', 'tests/a.test.js']).code, 2, 'no --step');
|
||||
assert.equal(run(repo, ['lock', '--files', 'tests/a.test.js', '--step', 'x']).code, 2, 'step not a number');
|
||||
assert.equal(run(repo, ['frobnicate']).code, 2);
|
||||
});
|
||||
|
||||
test('K8 status on a corrupt lock: exit 2, never "unlocked"', () => {
|
||||
const repo = repoWithTests();
|
||||
run(repo, ['lock', '--files', 'tests/a.test.js', '--step', '1']);
|
||||
const path = JSON.parse(run(repo, ['status']).stdout).lock_path;
|
||||
writeFileSync(path, '{not json');
|
||||
const s = run(repo, ['status']);
|
||||
assert.equal(s.code, 2);
|
||||
assert.ok(existsSync(path));
|
||||
assert.ok(!/"locked":\s*false/.test(s.stdout));
|
||||
assert.ok(readFileSync(path, 'utf8').startsWith('{not'), 'status never rewrites the lock');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue