// 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