voyage/tests/hooks/guard-helpers.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

143 lines
5.2 KiB
JavaScript

// tests/hooks/guard-helpers.mjs
// Shared helpers for the wave-1 guard tests (P3, P7, P9 lock, P12). Not a test
// file itself (no `.test.mjs`), so `npm test` does not run it.
//
// - Resolves hooks.json the way Claude Code does (matcher semantics:
// code.claude.com/docs/en/hooks § Matcher patterns), so a wiring gap fails
// even when a script is correct. Same rules as write-guard-coverage.test.mjs.
// - Builds throwaway git repos and a throwaway copy of the plugin in which the
// contract CLIs another wave-1 order owns (K2 approval log, K3 plan gate, K5
// arch rules) are stubs that record their argv and exit with a chosen code.
import { readFileSync, mkdtempSync, mkdirSync, writeFileSync, copyFileSync, readdirSync, chmodSync, existsSync, rmSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
export const ROOT = join(HERE, '..', '..');
export const HOOKS_JSON = join(ROOT, 'hooks', 'hooks.json');
// "*", "" or omitted match all; a value of only letters, digits, _, -, spaces,
// `,` and `|` is a list of exact names; anything else is an unanchored regex.
export function matcherFires(matcher, name) {
if (matcher === undefined || matcher === '' || matcher === '*') return true;
if (/^[A-Za-z0-9_\- ,|]+$/.test(matcher)) {
return matcher.split(/[|,]/).map((s) => s.trim()).includes(name);
}
return new RegExp(matcher).test(name);
}
// Every script hooks.json would run for this event (and tool, when the event
// has a matcher), with ${CLAUDE_PLUGIN_ROOT} resolved to `root`.
export function scriptsFor(event, name, root = ROOT) {
const cfg = JSON.parse(readFileSync(join(root, 'hooks', 'hooks.json'), 'utf8'));
const out = [];
for (const entry of cfg.hooks[event] || []) {
if (name !== undefined && !matcherFires(entry.matcher, name)) continue;
for (const h of entry.hooks || []) {
const arg = (h.args || []).find((a) => a.endsWith('.mjs'));
if (arg) out.push(arg.replace('${CLAUDE_PLUGIN_ROOT}', root));
}
}
return out;
}
export function tmp(prefix = 'voyage-guard-') {
return mkdtempSync(join(tmpdir(), prefix));
}
function git(cwd, ...args) {
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
}
// A git repo with one commit on `branch`.
export function makeRepo(branch = 'main') {
const dir = tmp('voyage-repo-');
git(dir, 'init', '-q', '-b', branch);
git(dir, 'config', 'user.email', 't@example.invalid');
git(dir, 'config', 'user.name', 'T');
git(dir, 'config', 'commit.gpgsign', 'false');
git(dir, 'config', 'tag.gpgsign', 'false');
writeFileSync(join(dir, 'README.md'), '# t\n');
git(dir, 'add', 'README.md');
git(dir, 'commit', '-q', '-m', 'init');
return dir;
}
export function gitIn(cwd, ...args) {
return git(cwd, ...args);
}
// A stub CLI: records its argv and cwd as one JSON line in $STUB_LOG, prints
// $STUB_STDOUT, exits with $STUB_EXIT (default 0).
const STUB = `#!/usr/bin/env node
import { appendFileSync } from 'node:fs';
const log = process.env.STUB_LOG;
if (log) appendFileSync(log, JSON.stringify({ argv: process.argv.slice(2), cwd: process.cwd() }) + '\\n');
if (process.env.STUB_STDOUT) process.stdout.write(process.env.STUB_STDOUT);
process.exit(Number(process.env.STUB_EXIT || 0));
`;
function copyTree(src, dst) {
mkdirSync(dst, { recursive: true });
for (const e of readdirSync(src, { withFileTypes: true })) {
const s = join(src, e.name);
const d = join(dst, e.name);
if (e.isDirectory()) copyTree(s, d);
else copyFileSync(s, d);
}
}
// A copy of the plugin's hooks/ and the lib modules the guards import, with
// the named contract CLIs replaced by the stub (or left out when `missing`).
// stubs: { 'lib/util/approval-log.mjs': 'stub' | 'missing', ... }
export function pluginCopy(stubs = {}) {
const root = tmp('voyage-plugin-');
copyTree(join(ROOT, 'hooks'), join(root, 'hooks'));
copyTree(join(ROOT, 'lib'), join(root, 'lib'));
for (const [rel, mode] of Object.entries(stubs)) {
const p = join(root, rel);
if (mode === 'missing') {
rmSync(p, { force: true });
continue;
}
mkdirSync(dirname(p), { recursive: true });
writeFileSync(p, STUB);
}
return root;
}
export function readStubLog(path) {
if (!existsSync(path)) return [];
return readFileSync(path, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
}
// An executable shell stub at <dir>/<name>: exits 1 and prints a line when any
// file argument contains the text BAD, else exits 0. Used as a fake formatter
// or linter so the post-edit check can be shown to stop AND to pass.
export function fakeTool(dir, name, { findingExit = 1 } = {}) {
mkdirSync(dir, { recursive: true });
const p = join(dir, name);
writeFileSync(
p,
`#!/bin/sh
found=0
for a in "$@"; do
case "$a" in -*) continue ;; esac
if [ -f "$a" ] && grep -q BAD "$a"; then echo "${name}: problem in $a"; found=1; fi
done
if [ "$found" = 1 ]; then exit ${findingExit}; fi
exit 0
`,
);
chmodSync(p, 0o755);
return p;
}
export function parseJsonOut(stdout) {
const s = String(stdout || '').trim();
if (!s) return null;
return JSON.parse(s);
}