merge: wave 1 order 4 (guards) into main
CLAUDE.md: the review paragraph (order 5) and the guards paragraph (order 4) are both kept (planned union line). The launch gate went red on the merged tree: post-approval-log reads the session's permission_mode, and its regression test feeds that value. Both were untracked when the branch ran its suite, so the gate never saw them there. Operator decision 2026-10-06: a counted exemption in a new class (mode-read: 3 + 1 hits), and the binding-rule sentence in CLAUDE.md names it. An extra hit in either file turns the gate red again (checked). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
47864f91b4
32 changed files with 3284 additions and 110 deletions
126
tests/hooks/approval-ask.test.mjs
Normal file
126
tests/hooks/approval-ask.test.mjs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
// tests/hooks/approval-ask.test.mjs
|
||||
// P12 — approval as a guard. The Bash guard answers `permissionDecision: "ask"`
|
||||
// for merge into main, push, tag/release and destructive git, so a person sees
|
||||
// the call before it runs — also in auto mode (code.claude.com/docs/en/hooks
|
||||
// § PreToolUse decision control: «A hook's "ask" also forces a permission
|
||||
// prompt in auto mode»). Tested both ways per event: every "must ask" has a
|
||||
// "must pass" neighbour, so a guard that asks about everything fails here too.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { join } from 'node:path';
|
||||
import { runHook } from '../helpers/hook-helper.mjs';
|
||||
import { ROOT, scriptsFor, makeRepo, gitIn, parseJsonOut } from './guard-helpers.mjs';
|
||||
|
||||
const PRE_BASH = join(ROOT, 'hooks', 'scripts', 'pre-bash-executor.mjs');
|
||||
const { classifyGitApproval } = await import(join(ROOT, 'hooks', 'lib', 'git-approval.mjs')).catch(() => ({}));
|
||||
|
||||
const MAIN_REPO = makeRepo('main');
|
||||
const FEATURE_REPO = makeRepo('main');
|
||||
gitIn(FEATURE_REPO, 'checkout', '-q', '-b', 'feat');
|
||||
|
||||
async function decide(command, cwd = FEATURE_REPO) {
|
||||
const r = await runHook(PRE_BASH, { hook_event_name: 'PreToolUse', tool_name: 'Bash', cwd, tool_input: { command } });
|
||||
let out = null;
|
||||
try { out = parseJsonOut(r.stdout); } catch { out = null; }
|
||||
return { ...r, out, decision: out?.hookSpecificOutput?.permissionDecision ?? null };
|
||||
}
|
||||
|
||||
const MUST_ASK = [
|
||||
// [command, cwd, why]
|
||||
['git push origin main', FEATURE_REPO, 'push to main'],
|
||||
['git push', FEATURE_REPO, 'push (any branch)'],
|
||||
['git push origin feat', FEATURE_REPO, 'push of a feature branch'],
|
||||
['git push --force-with-lease origin feat', FEATURE_REPO, 'force push'],
|
||||
['git push --tags', FEATURE_REPO, 'push of tags'],
|
||||
['git -C /tmp/x push origin main', FEATURE_REPO, 'git with -C before the subcommand'],
|
||||
['FOO=1 git push origin main', FEATURE_REPO, 'env-prefixed push'],
|
||||
['npm test && git push origin main', FEATURE_REPO, 'push after a test'],
|
||||
['sh -c "git push origin main"', FEATURE_REPO, 'push inside a shell wrapper'],
|
||||
['git merge --no-ff worktree-x', MAIN_REPO, 'merge while on main'],
|
||||
['git merge --ff-only feat', MAIN_REPO, 'fast-forward merge while on main'],
|
||||
['git checkout main && git merge feat', FEATURE_REPO, 'switch to main, then merge'],
|
||||
['git switch master; git merge feat', FEATURE_REPO, 'switch to master, then merge'],
|
||||
['git tag v1.2.3', FEATURE_REPO, 'create a tag'],
|
||||
['git tag -a v1 -m "release"', FEATURE_REPO, 'annotated tag'],
|
||||
['git tag -d v1', FEATURE_REPO, 'delete a tag'],
|
||||
['npm publish', FEATURE_REPO, 'publish a package'],
|
||||
['git reset --hard HEAD~1', FEATURE_REPO, 'hard reset'],
|
||||
['git clean -fd', FEATURE_REPO, 'clean with force'],
|
||||
['git clean -xdf', FEATURE_REPO, 'clean with combined flags'],
|
||||
['git branch -D feat', FEATURE_REPO, 'force-delete a branch'],
|
||||
['git branch --delete --force feat', FEATURE_REPO, 'force-delete, long flags'],
|
||||
['git stash drop', FEATURE_REPO, 'drop a stash'],
|
||||
['git stash clear', FEATURE_REPO, 'clear all stashes'],
|
||||
['git checkout -- src/a.js', FEATURE_REPO, 'discard changes to a file'],
|
||||
['git checkout .', FEATURE_REPO, 'discard all changes'],
|
||||
['git restore src/a.js', FEATURE_REPO, 'restore the working tree'],
|
||||
['git filter-branch --tree-filter x HEAD', FEATURE_REPO, 'history rewrite'],
|
||||
];
|
||||
|
||||
const MUST_PASS = [
|
||||
['git status', FEATURE_REPO, 'read-only'],
|
||||
['git log --oneline -5', FEATURE_REPO, 'read-only'],
|
||||
['git merge-base --is-ancestor main HEAD', MAIN_REPO, 'merge-base is not a merge'],
|
||||
['git merge feat', FEATURE_REPO, 'merge into a feature branch'],
|
||||
['git merge --abort', MAIN_REPO, 'aborting a merge'],
|
||||
['git tag', FEATURE_REPO, 'list tags'],
|
||||
['git tag -l "v*"', FEATURE_REPO, 'list tags with a pattern'],
|
||||
['git tag --list', FEATURE_REPO, 'list tags, long flag'],
|
||||
['echo "git push origin main"', FEATURE_REPO, 'quoted data, not a command'],
|
||||
['git commit -m "never git push to main"', FEATURE_REPO, 'commit message naming push'],
|
||||
['git restore --staged src/a.js', FEATURE_REPO, 'unstage only'],
|
||||
['git branch -d feat', FEATURE_REPO, 'safe branch delete'],
|
||||
['git stash list', FEATURE_REPO, 'list stashes'],
|
||||
['git checkout -b new-branch', FEATURE_REPO, 'create a branch'],
|
||||
['git checkout main', FEATURE_REPO, 'switch branch'],
|
||||
['git fetch origin', FEATURE_REPO, 'fetch'],
|
||||
['npm test', FEATURE_REPO, 'tests'],
|
||||
['git worktree remove /tmp/wt --force', FEATURE_REPO, 'worktree cleanup (Hard Rule 12)'],
|
||||
['git clean -n', FEATURE_REPO, 'clean dry run'],
|
||||
];
|
||||
|
||||
for (const [cmd, cwd, why] of MUST_ASK) {
|
||||
test(`P12 must ask: ${cmd} (${why})`, async () => {
|
||||
const r = await decide(cmd, cwd);
|
||||
assert.equal(r.code, 0, `ask is a JSON answer on exit 0, not a block; stderr: ${r.stderr}`);
|
||||
assert.equal(r.decision, 'ask', `expected permissionDecision "ask" for \`${cmd}\`; stdout: ${r.stdout}`);
|
||||
assert.equal(r.out.hookSpecificOutput.hookEventName, 'PreToolUse');
|
||||
const reason = r.out.hookSpecificOutput.permissionDecisionReason;
|
||||
assert.match(reason, /Voyage/, 'the prompt names Voyage as the asker');
|
||||
assert.match(reason, /Way forward:/, 'the prompt explains the way forward');
|
||||
});
|
||||
}
|
||||
|
||||
for (const [cmd, cwd, why] of MUST_PASS) {
|
||||
test(`P12 must pass: ${cmd} (${why})`, async () => {
|
||||
const r = await decide(cmd, cwd);
|
||||
assert.equal(r.code, 0, `stderr: ${r.stderr}`);
|
||||
assert.equal(r.decision, null, `no ask expected for \`${cmd}\`; stdout: ${r.stdout}`);
|
||||
});
|
||||
}
|
||||
|
||||
test('P12: a BLOCK rule still wins over ask (exit 2, no JSON)', async () => {
|
||||
const r = await decide('git push origin main; rm -rf /');
|
||||
assert.equal(r.code, 2);
|
||||
});
|
||||
|
||||
test('P12: the Bash guard that asks is the one hooks.json runs for Bash', () => {
|
||||
const scripts = scriptsFor('PreToolUse', 'Bash');
|
||||
assert.ok(scripts.some((s) => s.endsWith('pre-bash-executor.mjs')), JSON.stringify(scripts));
|
||||
});
|
||||
|
||||
test('P12: approval kinds follow the operator decision (06.10): merge/push to main = merge, tag/publish = release, the rest unlogged', () => {
|
||||
assert.equal(typeof classifyGitApproval, 'function', 'hooks/lib/git-approval.mjs must export classifyGitApproval');
|
||||
const kinds = (cmd, branch) => classifyGitApproval(cmd, { branch }).map((m) => m.kind);
|
||||
assert.deepEqual(kinds('git merge feat', 'main'), ['merge']);
|
||||
assert.deepEqual(kinds('git push origin main', 'feat'), ['merge']);
|
||||
assert.deepEqual(kinds('git push origin HEAD:refs/heads/main', 'feat'), ['merge']);
|
||||
assert.deepEqual(kinds('git push', 'main'), ['merge'], 'a bare push from main pushes main');
|
||||
assert.deepEqual(kinds('git push origin feat', 'feat'), [null]);
|
||||
assert.deepEqual(kinds('git tag v1', 'feat'), ['release']);
|
||||
assert.deepEqual(kinds('git push --tags', 'feat'), ['release']);
|
||||
assert.deepEqual(kinds('npm publish', 'feat'), ['release']);
|
||||
assert.deepEqual(kinds('git reset --hard', 'feat'), [null]);
|
||||
assert.deepEqual(kinds('git status', 'main'), []);
|
||||
});
|
||||
135
tests/hooks/approval-log.test.mjs
Normal file
135
tests/hooks/approval-log.test.mjs
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
// tests/hooks/approval-log.test.mjs
|
||||
// P12 + P3 — an approval a person gave through Claude Code's prompt is written
|
||||
// to the approval log (contract K2, lib/util/approval-log.mjs, owned by wave-1
|
||||
// order 2). The log CLI is a STUB here until order 2 is merged: the stub
|
||||
// records its argv. When the CLI is missing, the guard says "not logged" —
|
||||
// never green, never silent.
|
||||
//
|
||||
// Operator decision 06.10: merge/push to main is logged as `merge`, tag and
|
||||
// publish as `release`; push of other branches and destructive git are asked
|
||||
// about but not logged. P12 approvals carry an expiry (`--expires`).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { join } from 'node:path';
|
||||
import { runHookWithEnv } from '../helpers/hook-helper.mjs';
|
||||
import { scriptsFor, pluginCopy, makeRepo, gitIn, tmp, readStubLog, parseJsonOut } from './guard-helpers.mjs';
|
||||
|
||||
const K2 = 'lib/util/approval-log.mjs';
|
||||
const STUBBED = pluginCopy({ [K2]: 'stub' });
|
||||
const MISSING = pluginCopy({ [K2]: 'missing' });
|
||||
const hookIn = (root) => join(root, 'hooks', 'scripts', 'post-approval-log.mjs');
|
||||
|
||||
const REPO = makeRepo('main');
|
||||
gitIn(REPO, 'checkout', '-q', '-b', 'feat');
|
||||
|
||||
const sha = (s) => createHash('sha256').update(s).digest('hex');
|
||||
|
||||
let callNo = 0;
|
||||
|
||||
// A Bash call goes through the same two hooks as in a session: the guard asks
|
||||
// (and records what it asked), the call runs, then the approval is logged.
|
||||
async function post(root, input, env = {}) {
|
||||
const logFile = join(tmp(), 'stub.jsonl');
|
||||
const ids = { session_id: 'sess-log', tool_use_id: `toolu_log_${(callNo += 1)}` };
|
||||
if (input.tool_name === 'Bash') {
|
||||
await runHookWithEnv(join(root, 'hooks', 'scripts', 'pre-bash-executor.mjs'),
|
||||
{ hook_event_name: 'PreToolUse', cwd: REPO, ...ids, tool_name: 'Bash', tool_input: input.tool_input }, env);
|
||||
}
|
||||
const r = await runHookWithEnv(hookIn(root), { hook_event_name: 'PostToolUse', cwd: REPO, ...ids, ...input }, { STUB_LOG: logFile, ...env });
|
||||
return { ...r, calls: readStubLog(logFile), out: parseJsonOut(r.stdout) };
|
||||
}
|
||||
|
||||
const bash = (command) => ({ tool_name: 'Bash', tool_input: { command }, tool_response: { stdout: '', stderr: '' } });
|
||||
|
||||
const VOYAGE_PLAN = '---\nplan_version: 1.7\n---\n\n# Plan\n\n### Step 1: x\n';
|
||||
const OTHER_PLAN = '# Refactor auth\n\n1. Extract the helper\n';
|
||||
const exitPlan = (plan, filePath = '/tmp/plans/p.md') => ({
|
||||
tool_name: 'ExitPlanMode',
|
||||
tool_input: { plan, planFilePath: filePath },
|
||||
tool_response: { plan, filePath },
|
||||
});
|
||||
|
||||
function arg(call, flag) {
|
||||
const i = call.argv.indexOf(flag);
|
||||
return i === -1 ? undefined : call.argv[i + 1];
|
||||
}
|
||||
|
||||
test('K2: push to main is logged as merge, with subject, sha256 and an expiry', async () => {
|
||||
const r = await post(STUBBED, bash('git push origin main'));
|
||||
assert.equal(r.code, 0, r.stderr);
|
||||
assert.equal(r.calls.length, 1, `one append expected; got ${JSON.stringify(r.calls)}`);
|
||||
const c = r.calls[0];
|
||||
assert.equal(c.argv[0], 'append');
|
||||
assert.equal(arg(c, '--kind'), 'merge');
|
||||
assert.ok(arg(c, '--subject'), 'subject (a path) is given');
|
||||
assert.match(arg(c, '--sha256'), /^[0-9a-f]{64}$/);
|
||||
const exp = Date.parse(arg(c, '--expires'));
|
||||
assert.ok(Number.isFinite(exp) && exp > Date.now(), 'expires is a future ISO time');
|
||||
assert.match(r.out.hookSpecificOutput.additionalContext, /approval logged/i);
|
||||
});
|
||||
|
||||
test('K2: a tag is logged as release', async () => {
|
||||
const r = await post(STUBBED, bash('git tag v9.9.9'));
|
||||
assert.equal(r.calls.length, 1);
|
||||
assert.equal(arg(r.calls[0], '--kind'), 'release');
|
||||
});
|
||||
|
||||
test('K2: push of a feature branch is asked about but NOT logged', async () => {
|
||||
const r = await post(STUBBED, bash('git push origin feat'));
|
||||
assert.equal(r.code, 0);
|
||||
assert.equal(r.calls.length, 0);
|
||||
});
|
||||
|
||||
test('K2: destructive git is asked about but NOT logged', async () => {
|
||||
const r = await post(STUBBED, bash('git reset --hard HEAD~1'));
|
||||
assert.equal(r.code, 0, r.stderr);
|
||||
assert.equal(r.calls.length, 0);
|
||||
});
|
||||
|
||||
test('K2: an ordinary command logs nothing and says nothing', async () => {
|
||||
const r = await post(STUBBED, bash('npm test'));
|
||||
assert.equal(r.code, 0, r.stderr);
|
||||
assert.equal(r.calls.length, 0);
|
||||
assert.equal(r.out, null);
|
||||
});
|
||||
|
||||
test('K2 missing: the approval is reported "not logged", never green', async () => {
|
||||
const r = await post(MISSING, bash('git push origin main'));
|
||||
assert.equal(r.code, 0);
|
||||
assert.match(r.out.hookSpecificOutput.additionalContext, /not logged/i);
|
||||
});
|
||||
|
||||
test('K2 refuses (exit 1): reported "not logged"', async () => {
|
||||
const r = await post(STUBBED, bash('git push origin main'), { STUB_EXIT: '1' });
|
||||
assert.match(r.out.hookSpecificOutput.additionalContext, /not logged/i);
|
||||
});
|
||||
|
||||
test('K2 could not run (exit 2): reported "not logged"', async () => {
|
||||
const r = await post(STUBBED, bash('git push origin main'), { STUB_EXIT: '2' });
|
||||
assert.match(r.out.hookSpecificOutput.additionalContext, /not logged/i);
|
||||
});
|
||||
|
||||
test('P3: an approved Voyage plan is logged as plan, bound to the sha256 of the approved text', async () => {
|
||||
const r = await post(STUBBED, exitPlan(VOYAGE_PLAN, '/tmp/plans/voyage-plan.md'));
|
||||
assert.equal(r.calls.length, 1, JSON.stringify(r.calls));
|
||||
const c = r.calls[0];
|
||||
assert.equal(arg(c, '--kind'), 'plan');
|
||||
assert.equal(arg(c, '--subject'), '/tmp/plans/voyage-plan.md');
|
||||
assert.equal(arg(c, '--sha256'), sha(VOYAGE_PLAN));
|
||||
});
|
||||
|
||||
test('P3: an approved plan without plan_version is not Voyage\'s and is not logged', async () => {
|
||||
const r = await post(STUBBED, exitPlan(OTHER_PLAN));
|
||||
assert.equal(r.code, 0, r.stderr);
|
||||
assert.equal(r.calls.length, 0);
|
||||
assert.equal(r.out, null);
|
||||
});
|
||||
|
||||
test('wiring: hooks.json runs post-approval-log on PostToolUse for Bash and ExitPlanMode', () => {
|
||||
for (const tool of ['Bash', 'ExitPlanMode']) {
|
||||
const s = scriptsFor('PostToolUse', tool);
|
||||
assert.ok(s.some((p) => p.endsWith('post-approval-log.mjs')), `${tool}: ${JSON.stringify(s)}`);
|
||||
}
|
||||
});
|
||||
143
tests/hooks/guard-helpers.mjs
Normal file
143
tests/hooks/guard-helpers.mjs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
// 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);
|
||||
}
|
||||
301
tests/hooks/guards-review-regressions.test.mjs
Normal file
301
tests/hooks/guards-review-regressions.test.mjs
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
// tests/hooks/guards-review-regressions.test.mjs
|
||||
// Regressions from the independent review of the wave-1 guards (06.10). Each
|
||||
// test reproduces one finding the first test set did not cover; all failed
|
||||
// before the fix.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { writeFileSync, mkdirSync, realpathSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { runHook, runHookWithEnv } from '../helpers/hook-helper.mjs';
|
||||
import { ROOT, makeRepo, gitIn, fakeTool, tmp, pluginCopy, readStubLog, parseJsonOut } from './guard-helpers.mjs';
|
||||
|
||||
const PRE_BASH = join(ROOT, 'hooks', 'scripts', 'pre-bash-executor.mjs');
|
||||
const PRE_WRITE = join(ROOT, 'hooks', 'scripts', 'pre-write-executor.mjs');
|
||||
const STOP = join(ROOT, 'hooks', 'scripts', 'stop-test-lock.mjs');
|
||||
const LOCK = join(ROOT, 'lib', 'util', 'test-lock.mjs');
|
||||
const STEP = join(ROOT, 'lib', 'verification', 'step-check.mjs');
|
||||
const { classifyGitApproval } = await import(join(ROOT, 'hooks', 'lib', 'git-approval.mjs'));
|
||||
|
||||
const FEAT = makeRepo('main');
|
||||
gitIn(FEAT, 'checkout', '-q', '-b', 'feat');
|
||||
const MAIN = makeRepo('main');
|
||||
|
||||
async function decision(command, cwd = FEAT) {
|
||||
const r = await runHook(PRE_BASH, { hook_event_name: 'PreToolUse', tool_name: 'Bash', cwd, tool_input: { command } });
|
||||
let out = null;
|
||||
try { out = parseJsonOut(r.stdout); } catch { out = null; }
|
||||
return out?.hookSpecificOutput?.permissionDecision ?? (r.code === 2 ? 'block' : null);
|
||||
}
|
||||
|
||||
// 1. Quotes that are not quotes
|
||||
for (const cmd of [
|
||||
"# push it, it's ready\ngit push origin main",
|
||||
"echo don\\'t; git push origin main",
|
||||
"echo 'unterminated; git push origin main",
|
||||
]) {
|
||||
test(`review 1: an apostrophe that is not a closed quote does not hide a push: ${JSON.stringify(cmd)}`, async () => {
|
||||
assert.equal(await decision(cmd), 'ask');
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Quoted program, subcommand or ref
|
||||
for (const cmd of ['git "push" origin feat', '"git" push', "'git' push", "git 'push' origin feat"]) {
|
||||
test(`review 2: a quoted word is still the word: ${cmd}`, async () => {
|
||||
assert.equal(await decision(cmd), 'ask');
|
||||
});
|
||||
}
|
||||
test('review 2: a quoted ref keeps its kind (push to "main" is a merge approval)', () => {
|
||||
assert.deepEqual(classifyGitApproval('git push origin "main"', { branch: 'feat' }).map((m) => m.kind), ['merge']);
|
||||
});
|
||||
test('review 2: quoted data with spaces stays data', async () => {
|
||||
assert.equal(await decision('echo "git push origin main"'), null);
|
||||
assert.equal(await decision('git commit -m "wip"'), null);
|
||||
});
|
||||
|
||||
// 3. Wrappers
|
||||
for (const cmd of ['timeout 60 git push', 'nice git push', 'nice -n 5 git push', 'env -i git push', 'env -u HOME git push',
|
||||
'sudo -u bob git push', 'command -p git push', 'time -p git push', 'eval git push', 'xargs -n 1 git push']) {
|
||||
test(`review 3: wrapper seen through: ${cmd}`, async () => {
|
||||
assert.equal(await decision(cmd), 'ask');
|
||||
});
|
||||
}
|
||||
|
||||
// 8. One branch model for the whole command
|
||||
test('review 8: switching to main then pushing is a push to main', () => {
|
||||
assert.deepEqual(classifyGitApproval('git checkout main && git push', { branch: 'feat' }).map((m) => m.kind), ['merge']);
|
||||
});
|
||||
test('review 8: switching away from main then merging is not a merge into main', () => {
|
||||
assert.deepEqual(classifyGitApproval('git checkout feat && git merge x', { branch: 'main' }), []);
|
||||
});
|
||||
test('review 8: git -C <dir> reads that repo\'s branch', () => {
|
||||
assert.deepEqual(classifyGitApproval(`git -C ${MAIN} merge x`, { cwd: FEAT }).map((m) => m.kind), ['merge']);
|
||||
});
|
||||
test('review 8: cd <dir> && ... reads that repo\'s branch', () => {
|
||||
assert.deepEqual(classifyGitApproval(`cd ${MAIN} && git merge x`, { cwd: FEAT }).map((m) => m.kind), ['merge']);
|
||||
});
|
||||
|
||||
test('review 8: the log records what the person was asked, not a re-reading after the command', async () => {
|
||||
const plugin = pluginCopy({ 'lib/util/approval-log.mjs': 'stub' });
|
||||
const repo = makeRepo('main');
|
||||
gitIn(repo, 'checkout', '-q', '-b', 'feat');
|
||||
const cmd = 'git checkout main && git merge feat';
|
||||
const base = { session_id: 's-8', tool_use_id: 'toolu_8', cwd: repo, tool_name: 'Bash', tool_input: { command: cmd } };
|
||||
const pre = await runHook(join(plugin, 'hooks', 'scripts', 'pre-bash-executor.mjs'), { ...base, hook_event_name: 'PreToolUse' });
|
||||
assert.match(pre.stdout, /merge into main/);
|
||||
gitIn(repo, 'checkout', '-q', 'main'); // the command ran
|
||||
const log = join(tmp(), 'stub.jsonl');
|
||||
await runHookWithEnv(join(plugin, 'hooks', 'scripts', 'post-approval-log.mjs'), { ...base, hook_event_name: 'PostToolUse' }, { STUB_LOG: log });
|
||||
const calls = readStubLog(log);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].argv[calls[0].argv.indexOf('--kind') + 1], 'merge');
|
||||
});
|
||||
|
||||
test('review 8: a command nobody was asked about is not logged, even if it reads like one afterwards', async () => {
|
||||
const plugin = pluginCopy({ 'lib/util/approval-log.mjs': 'stub' });
|
||||
const repo = makeRepo('main');
|
||||
const log = join(tmp(), 'stub.jsonl');
|
||||
await runHookWithEnv(join(plugin, 'hooks', 'scripts', 'post-approval-log.mjs'), {
|
||||
hook_event_name: 'PostToolUse', session_id: 's-9', tool_use_id: 'toolu_never_asked', cwd: repo,
|
||||
tool_name: 'Bash', tool_input: { command: 'git push origin main' },
|
||||
}, { STUB_LOG: log });
|
||||
assert.equal(readStubLog(log).length, 0);
|
||||
});
|
||||
|
||||
test('uncertain (bypass mode): an approval that no person gave is not logged', async () => {
|
||||
const plugin = pluginCopy({ 'lib/util/approval-log.mjs': 'stub' });
|
||||
const repo = makeRepo('main');
|
||||
const base = { session_id: 's-b', tool_use_id: 'toolu_b', cwd: repo, tool_name: 'Bash', tool_input: { command: 'git push origin main' }, permission_mode: 'bypassPermissions' };
|
||||
await runHook(join(plugin, 'hooks', 'scripts', 'pre-bash-executor.mjs'), { ...base, hook_event_name: 'PreToolUse' });
|
||||
const log = join(tmp(), 'stub.jsonl');
|
||||
const r = await runHookWithEnv(join(plugin, 'hooks', 'scripts', 'post-approval-log.mjs'), { ...base, hook_event_name: 'PostToolUse' }, { STUB_LOG: log });
|
||||
assert.equal(readStubLog(log).length, 0);
|
||||
assert.match(parseJsonOut(r.stdout).hookSpecificOutput.additionalContext, /not logged/i);
|
||||
});
|
||||
|
||||
// 9. Classification
|
||||
const kinds = (cmd, branch = 'feat', cwd) => classifyGitApproval(cmd, { branch, cwd }).map((m) => m.kind);
|
||||
for (const cmd of ['git tag -v v1', 'git tag --verify v1', 'git push -n', 'git push --dry-run origin main']) {
|
||||
test(`review 9: not asked: ${cmd}`, () => assert.deepEqual(kinds(cmd), []));
|
||||
}
|
||||
for (const cmd of ['npm --tag beta publish', 'pnpm -r publish', 'yarn npm publish', 'npx npm publish', 'gh release create v1']) {
|
||||
test(`review 9: release: ${cmd}`, () => assert.deepEqual(kinds(cmd), ['release']));
|
||||
}
|
||||
test('review 9: pushing an existing tag by name is a release', () => {
|
||||
const repo = makeRepo('main');
|
||||
gitIn(repo, 'tag', 'v1.0');
|
||||
assert.deepEqual(kinds('git push origin v1.0', 'main', repo), ['release']);
|
||||
});
|
||||
for (const cmd of ['git checkout HEAD file.txt', 'git switch -f main', 'git switch --discard-changes main', 'git branch -M main']) {
|
||||
test(`review 9: destructive: ${cmd}`, () => assert.deepEqual(kinds(cmd, 'feat'), [null]));
|
||||
}
|
||||
test('review 9: deleting main on the remote is destructive, not a merge approval', () => {
|
||||
assert.deepEqual(kinds('git push origin :main'), [null]);
|
||||
assert.deepEqual(kinds('git push origin --delete main'), [null]);
|
||||
});
|
||||
|
||||
// 5 + 6 + 7. The write guard
|
||||
test('review 5: a lock module that cannot load blocks the write (fail closed), the path rules still run', async () => {
|
||||
const plugin = pluginCopy({ 'lib/util/test-lock.mjs': 'missing' });
|
||||
const hook = join(plugin, 'hooks', 'scripts', 'pre-write-executor.mjs');
|
||||
const r = await runHook(hook, { tool_name: 'Edit', tool_input: { file_path: join(makeRepo('main'), 'a.js') } });
|
||||
assert.equal(r.code, 2);
|
||||
assert.match(r.stderr, /could not load/i);
|
||||
});
|
||||
test('review 5: a guard module that cannot load makes the Bash guard ask, never pass silently', async () => {
|
||||
const plugin = pluginCopy({ 'hooks/lib/git-approval.mjs': 'missing' });
|
||||
const r = await runHook(join(plugin, 'hooks', 'scripts', 'pre-bash-executor.mjs'), { tool_name: 'Bash', tool_input: { command: 'ls' } });
|
||||
assert.equal(parseJsonOut(r.stdout)?.hookSpecificOutput?.permissionDecision, 'ask');
|
||||
});
|
||||
|
||||
function lockIn(repo, rel, env = {}) {
|
||||
const r = spawnSync(process.execPath, [LOCK, 'lock', '--files', rel, '--step', '1'], { cwd: repo, encoding: 'utf8', env: { ...process.env, ...env } });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
}
|
||||
|
||||
test('review 6: another letter case does not get past the lock (case-insensitive file systems)', async () => {
|
||||
const repo = makeRepo('main');
|
||||
mkdirSync(join(repo, 'tests'));
|
||||
writeFileSync(join(repo, 'tests', 'a.test.mjs'), 'x');
|
||||
lockIn(repo, 'tests/a.test.mjs');
|
||||
const upper = join(repo, 'Tests', 'a.test.mjs');
|
||||
let caseInsensitive = false;
|
||||
try { realpathSync(upper); caseInsensitive = true; } catch { caseInsensitive = false; }
|
||||
const r = await runHook(PRE_WRITE, { tool_name: 'Edit', tool_input: { file_path: upper } });
|
||||
if (caseInsensitive) assert.equal(r.code, 2, 'same file, other case');
|
||||
else assert.equal(r.code, 0, 'a different file on a case-sensitive file system');
|
||||
});
|
||||
|
||||
test('review 7: the lock file itself cannot be written with the file tools (repo and worktree git dirs)', async () => {
|
||||
for (const p of ['/r/.git/voyage/test-lock.json', '/r/.git/worktrees/wt/voyage/test-lock.json', '/r/.git/voyage/post-edit.jsonl']) {
|
||||
const r = await runHook(PRE_WRITE, { tool_name: 'Write', tool_input: { file_path: p, content: '{}' } });
|
||||
assert.equal(r.code, 2, p);
|
||||
}
|
||||
});
|
||||
|
||||
// 10. The stop guard finds the session's lock wherever the session now stands
|
||||
const SESS_MOVED = `sess-moved-${process.pid}-${Date.now()}`;
|
||||
test('review 10: the stop guard finds the lock taken by this session from another directory', async () => {
|
||||
const repo = makeRepo('main');
|
||||
writeFileSync(join(repo, 'a.test.js'), 'x');
|
||||
lockIn(repo, 'a.test.js', { CLAUDE_CODE_SESSION_ID: SESS_MOVED });
|
||||
const r = await runHook(STOP, { hook_event_name: 'Stop', session_id: SESS_MOVED, cwd: tmp(), stop_hook_active: false });
|
||||
assert.equal(parseJsonOut(r.stdout)?.decision, 'block');
|
||||
spawnSync(process.execPath, [LOCK, 'unlock'], { cwd: repo });
|
||||
const after = await runHook(STOP, { hook_event_name: 'Stop', session_id: SESS_MOVED, cwd: tmp(), stop_hook_active: false });
|
||||
assert.equal(parseJsonOut(after.stdout), null, 'unlock clears it');
|
||||
});
|
||||
|
||||
// 4, 11, 12. The step check
|
||||
function runStep(repo) {
|
||||
const r = spawnSync(process.execPath, [STEP, '--cwd', repo], { encoding: 'utf8', env: { ...process.env, PATH: '/usr/bin:/bin' } });
|
||||
return { code: r.status, json: JSON.parse(r.stdout) };
|
||||
}
|
||||
function jsRepo() {
|
||||
const repo = makeRepo('main');
|
||||
writeFileSync(join(repo, '.prettierrc'), '{}');
|
||||
writeFileSync(join(repo, '.gitignore'), 'node_modules/\n');
|
||||
gitIn(repo, 'add', '.prettierrc', '.gitignore');
|
||||
gitIn(repo, 'commit', '-q', '-m', 'tools');
|
||||
fakeTool(join(repo, 'node_modules', '.bin'), 'prettier');
|
||||
return repo;
|
||||
}
|
||||
test('review 4: a configured tool that crashed is not green', () => {
|
||||
const repo = jsRepo();
|
||||
writeFileSync(join(repo, 'eslint.config.js'), 'export default []');
|
||||
// eslint is configured and installed, but crashes (exit 2 = config error) on every call
|
||||
writeFileSync(join(repo, 'node_modules', '.bin', 'eslint'), '#!/bin/sh\nexit 2\n', { mode: 0o755 });
|
||||
writeFileSync(join(repo, 'a.js'), 'ok'); // prettier: clean
|
||||
const r = runStep(repo);
|
||||
assert.equal(r.code, 2, JSON.stringify(r.json));
|
||||
assert.equal(r.json.status, 'not_measured');
|
||||
});
|
||||
test('review 4: changed files no tool covers are listed, never silently dropped', () => {
|
||||
const repo = jsRepo();
|
||||
writeFileSync(join(repo, 'a.js'), 'ok');
|
||||
writeFileSync(join(repo, 'b.py'), 'x = 1');
|
||||
const r = runStep(repo);
|
||||
assert.deepEqual(r.json.not_covered, ['b.py']);
|
||||
});
|
||||
test('review 11: a changed file whose name starts with "-" reaches the tool as a file, not a flag', () => {
|
||||
const repo = jsRepo();
|
||||
writeFileSync(join(repo, '-x.js'), 'BAD');
|
||||
const r = runStep(repo);
|
||||
assert.equal(r.code, 1, JSON.stringify(r.json));
|
||||
});
|
||||
test('review 12: a changed file with a non-ASCII name is checked', () => {
|
||||
const repo = jsRepo();
|
||||
writeFileSync(join(repo, 'blåbær.js'), 'BAD');
|
||||
const r = runStep(repo);
|
||||
assert.equal(r.code, 1, JSON.stringify(r.json));
|
||||
});
|
||||
|
||||
test('uncertain (relative plan path): the plan gate resolves planFilePath against the session cwd', async () => {
|
||||
const plugin = pluginCopy({ 'lib/validators/plan-gate.mjs': 'stub' });
|
||||
const dir = tmp();
|
||||
writeFileSync(join(dir, 'plan.md'), '---\nplan_version: 1.7\n---\n');
|
||||
const log = join(tmp(), 'stub.jsonl');
|
||||
const r = await runHookWithEnv(join(plugin, 'hooks', 'scripts', 'pre-plan-gate.mjs'), {
|
||||
hook_event_name: 'PreToolUse', tool_name: 'ExitPlanMode', cwd: dir,
|
||||
tool_input: { plan: '---\nplan_version: 1.7\n---\n', planFilePath: 'plan.md' },
|
||||
}, { STUB_LOG: log, STUB_EXIT: '1' });
|
||||
assert.equal(r.code, 2, r.stdout);
|
||||
assert.equal(readStubLog(log)[0].argv[0], join(dir, 'plan.md'));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Second review round (06.10): what the first fixes left open or introduced.
|
||||
// ---------------------------------------------------------------------------
|
||||
for (const cmd of [
|
||||
'git commit -m "fix \\"x\\" bug" && git push origin main && echo "ok"',
|
||||
'echo "a\\"b" ; git push origin main ; echo "c"',
|
||||
'if git push origin main; then echo ok; fi',
|
||||
'while git push origin main; do sleep 1; done',
|
||||
"eval 'git push origin main'",
|
||||
'echo a\\ #b; git push origin main',
|
||||
]) {
|
||||
test(`review 2nd round: still asks: ${JSON.stringify(cmd)}`, async () => {
|
||||
assert.equal(await decision(cmd), 'ask');
|
||||
});
|
||||
}
|
||||
|
||||
test('review 2nd round (7): the .git/voyage block ignores letter case', async () => {
|
||||
for (const p of ['/r/.GIT/voyage/test-lock.json', '/r/.git/VOYAGE/test-lock.json', '/r/.Git/hooks/pre-commit']) {
|
||||
const r = await runHook(PRE_WRITE, { tool_name: 'Write', tool_input: { file_path: p, content: '{}' } });
|
||||
assert.equal(r.code, 2, p);
|
||||
}
|
||||
});
|
||||
|
||||
test('review 2nd round (N1): redirections are not file arguments', () => {
|
||||
assert.deepEqual(kinds('git checkout main 2>/dev/null && git merge x', 'feat'), ['merge']);
|
||||
assert.deepEqual(kinds('git checkout feat2 2>&1', 'feat'), []);
|
||||
assert.deepEqual(kinds('git push origin 2>&1', 'main'), ['merge']);
|
||||
assert.deepEqual(kinds('git checkout main > /dev/null && git merge x', 'feat'), ['merge']);
|
||||
});
|
||||
|
||||
for (const cmd of ['npm publish --dry-run', 'cargo publish --dry-run', 'npm view x publish', 'yarn info publish']) {
|
||||
test(`review 2nd round (N2): not a release: ${cmd}`, () => assert.deepEqual(kinds(cmd), []));
|
||||
}
|
||||
|
||||
test('review 2nd round (N5): a detached switch leaves no branch to merge into', () => {
|
||||
assert.deepEqual(kinds('git switch --detach main && git merge x', 'feat'), []);
|
||||
assert.deepEqual(kinds('git checkout --detach main && git merge x', 'feat'), []);
|
||||
});
|
||||
|
||||
test('review 2nd round: a push option value is not the remote', () => {
|
||||
assert.deepEqual(kinds('git push -o ci.skip origin main', 'feat'), ['merge']);
|
||||
});
|
||||
|
||||
test('review 2nd round (N4): when the record cannot be written, the prompt says the approval will not be logged', async () => {
|
||||
const plugin = pluginCopy({});
|
||||
// TMPDIR points at a file, so the record directory cannot be created.
|
||||
const fileAsDir = join(tmp(), 'not-a-dir');
|
||||
writeFileSync(fileAsDir, 'x');
|
||||
const r = await runHookWithEnv(join(plugin, 'hooks', 'scripts', 'pre-bash-executor.mjs'), {
|
||||
hook_event_name: 'PreToolUse', session_id: 's-n4', tool_use_id: 'toolu_n4', cwd: makeRepo('main'),
|
||||
tool_name: 'Bash', tool_input: { command: 'git push origin main' },
|
||||
}, { TMPDIR: fileAsDir });
|
||||
const reason = parseJsonOut(r.stdout).hookSpecificOutput.permissionDecisionReason;
|
||||
assert.match(reason, /will not be logged/i);
|
||||
});
|
||||
95
tests/hooks/plan-gate-hook.test.mjs
Normal file
95
tests/hooks/plan-gate-hook.test.mjs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
// tests/hooks/plan-gate-hook.test.mjs
|
||||
// P3 — Claude Code's plan approval, wrapped. A PreToolUse hook on ExitPlanMode
|
||||
// receives the plan text and the plan file path (code.claude.com/docs/en/hooks
|
||||
// § ExitPlanMode: «Claude Code injects the plan content and file path before
|
||||
// passing the input to hooks»), runs Voyage's plan gate (contract K3,
|
||||
// lib/validators/plan-gate.mjs, owned by wave-1 order 3; a STUB here until it
|
||||
// is merged) and sends an invalid Voyage plan back. Only Voyage plans (they
|
||||
// carry `plan_version`) are gated: the hook runs in every session, so every
|
||||
// other plan passes untouched.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { runHookWithEnv } from '../helpers/hook-helper.mjs';
|
||||
import { scriptsFor, pluginCopy, tmp, readStubLog, parseJsonOut } from './guard-helpers.mjs';
|
||||
|
||||
const K3 = 'lib/validators/plan-gate.mjs';
|
||||
const STUBBED = pluginCopy({ [K3]: 'stub' });
|
||||
const MISSING = pluginCopy({ [K3]: 'missing' });
|
||||
const hookIn = (root) => join(root, 'hooks', 'scripts', 'pre-plan-gate.mjs');
|
||||
|
||||
const VOYAGE_PLAN = '---\nplan_version: 1.7\n---\n\n# Plan\n\n### Step 1: x\n';
|
||||
const OTHER_PLAN = '# Refactor auth\n\n1. Extract the helper\n';
|
||||
|
||||
function planFile(text) {
|
||||
const dir = join(tmp(), 'plans');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const p = join(dir, 'plan.md');
|
||||
writeFileSync(p, text);
|
||||
return p;
|
||||
}
|
||||
|
||||
async function exitPlanMode(root, plan, env = {}, { withPath = true } = {}) {
|
||||
const logFile = join(tmp(), 'stub.jsonl');
|
||||
const input = { plan };
|
||||
if (withPath) input.planFilePath = planFile(plan);
|
||||
const r = await runHookWithEnv(
|
||||
hookIn(root),
|
||||
{ hook_event_name: 'PreToolUse', tool_name: 'ExitPlanMode', cwd: tmp(), tool_input: input },
|
||||
{ STUB_LOG: logFile, ...env },
|
||||
);
|
||||
let out = null;
|
||||
try { out = parseJsonOut(r.stdout); } catch { out = null; }
|
||||
return { ...r, out, calls: readStubLog(logFile), path: input.planFilePath };
|
||||
}
|
||||
|
||||
test('P3 must stop: a Voyage plan the gate refuses (K3 exit 1, open BLOCKER) is sent back', async () => {
|
||||
const r = await exitPlanMode(STUBBED, VOYAGE_PLAN, { STUB_EXIT: '1', STUB_STDOUT: 'BLOCKER open: step 2 has no Verify' });
|
||||
assert.equal(r.code, 2, `deny = exit 2; stdout ${r.stdout}`);
|
||||
assert.match(r.stderr, /Voyage/);
|
||||
assert.match(r.stderr, /BLOCKER open/, 'the gate\'s own words reach Claude');
|
||||
assert.equal(r.calls.length, 1);
|
||||
assert.equal(r.calls[0].argv[0], r.path, 'the gate runs on the plan file Claude Code attached');
|
||||
});
|
||||
|
||||
test('P3 must pass: a Voyage plan the gate accepts (K3 exit 0) goes on', async () => {
|
||||
const r = await exitPlanMode(STUBBED, VOYAGE_PLAN, { STUB_EXIT: '0' });
|
||||
assert.equal(r.code, 0, r.stderr);
|
||||
assert.equal(r.out, null, 'no decision: Claude Code\'s own approval prompt follows');
|
||||
assert.equal(r.calls.length, 1);
|
||||
});
|
||||
|
||||
test('P3 must pass: a plan without plan_version is not Voyage\'s; the gate is not even run', async () => {
|
||||
const r = await exitPlanMode(STUBBED, OTHER_PLAN, { STUB_EXIT: '1' });
|
||||
assert.equal(r.code, 0);
|
||||
assert.equal(r.out, null);
|
||||
assert.equal(r.calls.length, 0);
|
||||
});
|
||||
|
||||
test('P3 not measured: K3 could not run (exit 2) -> ask, never a silent pass', async () => {
|
||||
const r = await exitPlanMode(STUBBED, VOYAGE_PLAN, { STUB_EXIT: '2' });
|
||||
assert.equal(r.code, 0);
|
||||
assert.equal(r.out?.hookSpecificOutput?.permissionDecision, 'ask');
|
||||
assert.match(r.out.hookSpecificOutput.permissionDecisionReason, /not measured/i);
|
||||
});
|
||||
|
||||
test('P3 not measured: K3 missing (order 3 not merged) -> ask "not measured"', async () => {
|
||||
const r = await exitPlanMode(MISSING, VOYAGE_PLAN);
|
||||
assert.equal(r.out?.hookSpecificOutput?.permissionDecision, 'ask');
|
||||
assert.match(r.out.hookSpecificOutput.permissionDecisionReason, /not measured/i);
|
||||
});
|
||||
|
||||
test('P3 not measured: a Voyage plan with no plan file path -> ask "not measured"', async () => {
|
||||
const r = await exitPlanMode(STUBBED, VOYAGE_PLAN, {}, { withPath: false });
|
||||
assert.equal(r.out?.hookSpecificOutput?.permissionDecision, 'ask');
|
||||
assert.equal(r.calls.length, 0);
|
||||
});
|
||||
|
||||
test('wiring: hooks.json runs pre-plan-gate on PreToolUse for ExitPlanMode only', () => {
|
||||
assert.ok(scriptsFor('PreToolUse', 'ExitPlanMode').some((p) => p.endsWith('pre-plan-gate.mjs')));
|
||||
for (const tool of ['Bash', 'Write', 'Edit', 'Agent', 'EnterPlanMode']) {
|
||||
assert.ok(!scriptsFor('PreToolUse', tool).some((p) => p.endsWith('pre-plan-gate.mjs')), tool);
|
||||
}
|
||||
});
|
||||
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')));
|
||||
});
|
||||
26
tests/hooks/storm-matcher.test.mjs
Normal file
26
tests/hooks/storm-matcher.test.mjs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// tests/hooks/storm-matcher.test.mjs
|
||||
// The STORM cap (pre-agent-cap.mjs) must see subagent spawns. The tool was
|
||||
// renamed Task -> Agent in Claude Code 2.1.63; the docs state the old name
|
||||
// still works as an alias «in settings and agent definitions»
|
||||
// (code.claude.com/docs/en/sub-agents, the rename note) and say nothing about
|
||||
// hook matchers, which compare against the tool name the hook input carries.
|
||||
// So the matcher must name `Agent` itself. Resolved with Claude Code's matcher
|
||||
// rules (hooks § Matcher patterns), as write-guard-coverage.test.mjs does.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { scriptsFor } from './guard-helpers.mjs';
|
||||
|
||||
const fires = (tool) => scriptsFor('PreToolUse', tool).some((p) => p.endsWith('pre-agent-cap.mjs'));
|
||||
|
||||
test('STORM cap fires on Agent (the tool name since 2.1.63)', () => {
|
||||
assert.ok(fires('Agent'), 'pre-agent-cap.mjs must fire for tool_name "Agent"');
|
||||
});
|
||||
|
||||
test('STORM cap still fires on WebSearch, WebFetch and the old Task name', () => {
|
||||
for (const t of ['WebSearch', 'WebFetch', 'Task']) assert.ok(fires(t), t);
|
||||
});
|
||||
|
||||
test('STORM cap does not fire on unrelated tools', () => {
|
||||
for (const t of ['Bash', 'Edit', 'Write', 'Read', 'AgentOutput', 'ExitPlanMode']) assert.ok(!fires(t), t);
|
||||
});
|
||||
131
tests/hooks/test-lock-guard.test.mjs
Normal file
131
tests/hooks/test-lock-guard.test.mjs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
// tests/hooks/test-lock-guard.test.mjs
|
||||
// P9 (3) — the lock on the tests. Two guards read the K8 lock:
|
||||
// - the write guard (pre-write-executor.mjs, PreToolUse Write|Edit|
|
||||
// NotebookEdit) refuses a change to a locked test file;
|
||||
// - the stop guard (stop-test-lock.mjs, Stop) refuses "done" while the step
|
||||
// that took the lock in THIS session still holds it, with the
|
||||
// stop_hook_active guard (code.claude.com/docs/en/hooks § Stop input).
|
||||
// Pattern: Claude Code's own hooks (hooks-guide «Block edits to protected
|
||||
// files»; hooks reference § TaskCompleted/Stop decision control).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { writeFileSync, mkdirSync, realpathSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { runHook } from '../helpers/hook-helper.mjs';
|
||||
import { ROOT, scriptsFor, makeRepo, parseJsonOut } from './guard-helpers.mjs';
|
||||
|
||||
const CLI = join(ROOT, 'lib', 'util', 'test-lock.mjs');
|
||||
const PRE_WRITE = join(ROOT, 'hooks', 'scripts', 'pre-write-executor.mjs');
|
||||
const STOP = join(ROOT, 'hooks', 'scripts', 'stop-test-lock.mjs');
|
||||
|
||||
const RUN = `${process.pid}-${Date.now()}`;
|
||||
const SESS_A = `sess-A-${RUN}`;
|
||||
const SESS_B = `sess-B-${RUN}`;
|
||||
|
||||
function lockedRepo({ session = SESS_A, step = 2 } = {}) {
|
||||
const repo = makeRepo('main');
|
||||
mkdirSync(join(repo, 'tests'), { recursive: true });
|
||||
writeFileSync(join(repo, 'tests', 'locked.test.js'), 'x\n');
|
||||
writeFileSync(join(repo, 'tests', 'free.test.js'), 'y\n');
|
||||
writeFileSync(join(repo, 'nb.ipynb'), '{}\n');
|
||||
const r = spawnSync(process.execPath, [CLI, 'lock', '--files', 'tests/locked.test.js,nb.ipynb', '--step', String(step)], {
|
||||
cwd: repo, encoding: 'utf8', env: { ...process.env, CLAUDE_CODE_SESSION_ID: session },
|
||||
});
|
||||
assert.equal(r.status, 0, `lock failed: ${r.stderr}`);
|
||||
return repo;
|
||||
}
|
||||
|
||||
const edit = (file_path) => ({ hook_event_name: 'PreToolUse', tool_name: 'Edit', tool_input: { file_path, old_string: 'x', new_string: 'z' } });
|
||||
|
||||
test('P9 lock must stop: Edit on a locked test file -> exit 2, names the step', async () => {
|
||||
const repo = lockedRepo();
|
||||
const r = await runHook(PRE_WRITE, edit(join(repo, 'tests', 'locked.test.js')));
|
||||
assert.equal(r.code, 2, r.stderr);
|
||||
assert.match(r.stderr, /locked/i);
|
||||
assert.match(r.stderr, /step 2/);
|
||||
assert.match(r.stderr, /operator/, 'the way forward: a wrong test goes to the operator');
|
||||
});
|
||||
|
||||
test('P9 lock must stop: the same file reached through a symlinked path (/tmp vs /private/tmp)', async () => {
|
||||
const repo = lockedRepo();
|
||||
const real = realpathSync(repo);
|
||||
const alias = real.startsWith('/private/') ? real.slice('/private'.length) : real;
|
||||
const r = await runHook(PRE_WRITE, edit(join(alias, 'tests', 'locked.test.js')));
|
||||
assert.equal(r.code, 2);
|
||||
});
|
||||
|
||||
test('P9 lock must stop: Write and NotebookEdit too', async () => {
|
||||
const repo = lockedRepo();
|
||||
const w = await runHook(PRE_WRITE, { tool_name: 'Write', tool_input: { file_path: join(repo, 'tests', 'locked.test.js'), content: '' } });
|
||||
assert.equal(w.code, 2);
|
||||
const n = await runHook(PRE_WRITE, { tool_name: 'NotebookEdit', tool_input: { notebook_path: join(repo, 'nb.ipynb'), new_source: '' } });
|
||||
assert.equal(n.code, 2);
|
||||
});
|
||||
|
||||
test('P9 lock must pass: Edit on an unlocked file in the same repo', async () => {
|
||||
const repo = lockedRepo();
|
||||
const r = await runHook(PRE_WRITE, edit(join(repo, 'tests', 'free.test.js')));
|
||||
assert.equal(r.code, 0, r.stderr);
|
||||
});
|
||||
|
||||
test('P9 lock must pass: a repo with no lock at all', async () => {
|
||||
const repo = makeRepo('main');
|
||||
const r = await runHook(PRE_WRITE, edit(join(repo, 'README.md')));
|
||||
assert.equal(r.code, 0, r.stderr);
|
||||
});
|
||||
|
||||
test('P9 lock must pass: after unlock', async () => {
|
||||
const repo = lockedRepo();
|
||||
spawnSync(process.execPath, [CLI, 'unlock'], { cwd: repo });
|
||||
const r = await runHook(PRE_WRITE, edit(join(repo, 'tests', 'locked.test.js')));
|
||||
assert.equal(r.code, 0, r.stderr);
|
||||
});
|
||||
|
||||
test('P9 lock fails closed: an unreadable lock blocks writes in that repo', async () => {
|
||||
const repo = lockedRepo();
|
||||
const st = JSON.parse(spawnSync(process.execPath, [CLI, 'status'], { cwd: repo, encoding: 'utf8' }).stdout);
|
||||
writeFileSync(st.lock_path, '{not json');
|
||||
const r = await runHook(PRE_WRITE, edit(join(repo, 'README.md')));
|
||||
assert.equal(r.code, 2);
|
||||
assert.ok(r.stderr.includes(st.lock_path), 'the message names the lock file to inspect');
|
||||
});
|
||||
|
||||
const stop = (repo, extra = {}) => ({ hook_event_name: 'Stop', session_id: SESS_A, cwd: repo, stop_hook_active: false, ...extra });
|
||||
|
||||
test('P9 stop must stop: "done" refused while this session\'s step holds the lock', async () => {
|
||||
const repo = lockedRepo();
|
||||
const r = await runHook(STOP, stop(repo));
|
||||
assert.equal(r.code, 0);
|
||||
const out = parseJsonOut(r.stdout);
|
||||
assert.equal(out?.decision, 'block', r.stdout);
|
||||
assert.match(out.reason, /step 2/);
|
||||
});
|
||||
|
||||
test('P9 stop must pass: stop_hook_active is true (no endless loop)', async () => {
|
||||
const repo = lockedRepo();
|
||||
const r = await runHook(STOP, stop(repo, { stop_hook_active: true }));
|
||||
assert.equal(r.code, 0);
|
||||
assert.equal(parseJsonOut(r.stdout), null);
|
||||
});
|
||||
|
||||
test('P9 stop must pass: the lock belongs to another session', async () => {
|
||||
const repo = lockedRepo({ session: SESS_B });
|
||||
// a session that holds no lock anywhere stops in a repo another session locked
|
||||
const r = await runHook(STOP, stop(repo, { session_id: `sess-free-${RUN}` }));
|
||||
assert.equal(parseJsonOut(r.stdout), null);
|
||||
});
|
||||
|
||||
test('P9 stop must pass: no lock', async () => {
|
||||
const r = await runHook(STOP, stop(makeRepo('main'), { session_id: `sess-none-${RUN}` }));
|
||||
assert.equal(r.code, 0);
|
||||
assert.equal(parseJsonOut(r.stdout), null);
|
||||
});
|
||||
|
||||
test('wiring: the write guard covers the lock for Write, Edit, NotebookEdit; the stop guard runs on Stop', () => {
|
||||
for (const tool of ['Write', 'Edit', 'NotebookEdit']) {
|
||||
assert.ok(scriptsFor('PreToolUse', tool).some((p) => p.endsWith('pre-write-executor.mjs')), tool);
|
||||
}
|
||||
assert.ok(scriptsFor('Stop').some((p) => p.endsWith('stop-test-lock.mjs')));
|
||||
});
|
||||
71
tests/lib/guards-prose-pins.test.mjs
Normal file
71
tests/lib/guards-prose-pins.test.mjs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
// tests/lib/guards-prose-pins.test.mjs
|
||||
// Prose pins owned by the guards process (wave-1 order 4). Counted as doc pins
|
||||
// by lib/util/test-census.mjs (PIN_FILE_RE matches `prose-pins.test.mjs`).
|
||||
//
|
||||
// The mod precondition: a Claude Code mod that handles `tool.check` answers
|
||||
// AFTER the rules and the PreToolUse hooks, and «its answer can replace
|
||||
// theirs» — it can approve a call a plugin PreToolUse hook blocked
|
||||
// (code.claude.com/docs/en/permissions § Extend permissions with hooks,
|
||||
// verified verbatim 06.10). So Voyage's stop guards are deterministic only
|
||||
// under a stated condition, and the condition is written where the guards are
|
||||
// described: README § Security hardening, SECURITY.md, and the head of every
|
||||
// PreToolUse stop guard.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const read = (rel) => readFileSync(join(ROOT, rel), 'utf8');
|
||||
|
||||
export const MOD_PRECONDITION = "Voyage's guards are deterministic only when no installed mod handles `tool.check`.";
|
||||
|
||||
const STOP_GUARDS = [
|
||||
'hooks/scripts/pre-bash-executor.mjs',
|
||||
'hooks/scripts/pre-write-executor.mjs',
|
||||
'hooks/scripts/pre-agent-cap.mjs',
|
||||
'hooks/scripts/pre-plan-gate.mjs',
|
||||
];
|
||||
|
||||
test('mod precondition: README § Security hardening states it', () => {
|
||||
const md = read('README.md');
|
||||
const start = md.indexOf('### Security hardening');
|
||||
assert.ok(start !== -1);
|
||||
const end = md.indexOf('\n### ', start + 1);
|
||||
assert.ok(md.slice(start, end).includes(MOD_PRECONDITION), 'README § Security hardening must state the mod precondition');
|
||||
});
|
||||
|
||||
test('mod precondition: SECURITY.md states it', () => {
|
||||
assert.ok(read('SECURITY.md').includes(MOD_PRECONDITION));
|
||||
});
|
||||
|
||||
test('mod precondition: the head of every PreToolUse stop guard states it', () => {
|
||||
for (const rel of STOP_GUARDS) {
|
||||
const head = read(rel).split('\n').slice(0, 40).join('\n').replace(/^\/\/ ?/gm, '').replace(/\n/g, ' ');
|
||||
assert.ok(head.replace(/\s+/g, ' ').includes(MOD_PRECONDITION), `${rel}: the first 40 lines must state the mod precondition`);
|
||||
}
|
||||
});
|
||||
|
||||
test('mod precondition: the guards cannot see a mod; the docs say so where it is stated', () => {
|
||||
// Looked up 06.10: no hook input field names installed mods; the documented
|
||||
// ways to see them are `/plugin` and a CLI subcommand, both for a person.
|
||||
assert.match(read('SECURITY.md'), /cannot tell whether such a mod is installed/);
|
||||
});
|
||||
|
||||
test('P12 guard: README names the classes it asks about and that it holds in auto mode', () => {
|
||||
const md = read('README.md');
|
||||
const sec = md.slice(md.indexOf('### Security hardening'), md.indexOf('#### Recommended: disable Skill shell execution'));
|
||||
for (const w of ['merge into `main`', 'push', 'tag', 'destructive git', 'auto mode']) {
|
||||
assert.ok(sec.includes(w), `README § Security hardening must mention ${w}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('borrowings are credited where the guards are described (PLAN fixed limit 3)', () => {
|
||||
const md = read('README.md');
|
||||
const sec = md.slice(md.indexOf('### Security hardening'), md.indexOf('#### Recommended: disable Skill shell execution'));
|
||||
for (const src of ['Superpowers', 'agent-skills', 'ai-native-sdlc', 'Pocock', 'Kiro', 'Cherny', 'Claude Code']) {
|
||||
assert.ok(sec.includes(src), `credit missing for ${src}`);
|
||||
}
|
||||
});
|
||||
|
|
@ -65,6 +65,8 @@ const EXEMPT = new Map([
|
|||
['tests/fixtures/synthesis/exploration/dependency-tracer.md', ['c', 2, 'recorded exploration fixture']],
|
||||
['docs/proevesett.md', ['operator-instrument', 3, "the operator's own eval run through Anthropic's `claude plugin eval`; Voyage starts nothing - kept by operator decision 2026-10-02"]],
|
||||
['.gitignore', ['operator-instrument', 1, 'ignores the output of that same operator-run eval']],
|
||||
['hooks/scripts/post-approval-log.mjs', ['mode-read', 3, "reads the session's permission_mode from hook input so an approval is never logged when no person was asked; starts nothing - operator decision 2026-10-06"]],
|
||||
['tests/hooks/guards-review-regressions.test.mjs', ['mode-read', 1, 'feeds that same permission_mode value to the hook under test; starts nothing']],
|
||||
]);
|
||||
|
||||
function trackedFiles(root) {
|
||||
|
|
@ -137,7 +139,7 @@ test('every EXEMPT entry is tracked, has a reason, and hits exactly its recorded
|
|||
const files = new Set(trackedFiles(ROOT));
|
||||
for (const [rel, [cls, count, reason]] of EXEMPT) {
|
||||
assert.ok(files.has(rel), `EXEMPT names untracked ${rel}`);
|
||||
assert.ok(['c', 'self', 'operator-instrument'].includes(cls), `${rel}: unknown class ${cls}`);
|
||||
assert.ok(['c', 'self', 'operator-instrument', 'mode-read'].includes(cls), `${rel}: unknown class ${cls}`);
|
||||
assert.ok(reason && reason.length > 10, `${rel}: an exemption needs a reason`);
|
||||
const n = scan(ROOT, [rel]).length;
|
||||
assert.equal(n, count, `${rel}: ${n} hit(s), EXEMPT records ${count} - a new hit is a launch, a lost one is a stale entry`);
|
||||
|
|
|
|||
96
tests/lib/repo-tools.test.mjs
Normal file
96
tests/lib/repo-tools.test.mjs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
// tests/lib/repo-tools.test.mjs
|
||||
// lib/verification/repo-tools.mjs finds the target repo's OWN formatter,
|
||||
// linter and type checker. A tool counts only when the repo configures it AND
|
||||
// it is installed locally (node_modules/.bin) or on PATH; nothing is ever
|
||||
// downloaded. Exit codes are mapped per tool to clean / finding / error, and an
|
||||
// error is never read as clean.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { writeFileSync, mkdirSync, realpathSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { makeRepo, fakeTool, tmp } from '../hooks/guard-helpers.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const mod = await import(join(HERE, '..', '..', 'lib', 'verification', 'repo-tools.mjs'));
|
||||
const { resolveRepo, detectTools, toolsForFile, runTool } = mod;
|
||||
|
||||
test('resolveRepo: finds the root and git dir from a file path that does not exist yet', () => {
|
||||
const repo = makeRepo('main');
|
||||
const r = resolveRepo(join(repo, 'src', 'new', 'file.js'));
|
||||
assert.equal(r.root, realpathSync(repo));
|
||||
assert.equal(r.gitDir, realpathSync(join(repo, '.git')));
|
||||
});
|
||||
|
||||
test('resolveRepo: null outside a repository', () => {
|
||||
assert.equal(resolveRepo(tmp()), null);
|
||||
});
|
||||
|
||||
test('detectTools: prettier counts only with config AND a local binary', () => {
|
||||
const repo = makeRepo('main');
|
||||
writeFileSync(join(repo, '.prettierrc'), '{}');
|
||||
let tools = detectTools(repo, { env: { PATH: '/nonexistent' } });
|
||||
const p = tools.find((t) => t.name === 'prettier');
|
||||
assert.ok(p && !p.available, 'configured but not installed is listed as unavailable');
|
||||
fakeTool(join(repo, 'node_modules', '.bin'), 'prettier');
|
||||
tools = detectTools(repo, { env: { PATH: '/nonexistent' } });
|
||||
assert.ok(tools.find((t) => t.name === 'prettier').available);
|
||||
});
|
||||
|
||||
test('detectTools: a repo with nothing configured has no tools', () => {
|
||||
assert.deepEqual(detectTools(makeRepo('main'), { env: { PATH: '/nonexistent' } }), []);
|
||||
});
|
||||
|
||||
test('detectTools: eslint and tsc (repo tier) from config + local binaries', () => {
|
||||
const repo = makeRepo('main');
|
||||
writeFileSync(join(repo, 'eslint.config.js'), 'export default [];');
|
||||
writeFileSync(join(repo, 'tsconfig.json'), '{}');
|
||||
fakeTool(join(repo, 'node_modules', '.bin'), 'eslint');
|
||||
fakeTool(join(repo, 'node_modules', '.bin'), 'tsc');
|
||||
const tools = detectTools(repo, { env: { PATH: '/nonexistent' } });
|
||||
const eslint = tools.find((t) => t.name === 'eslint');
|
||||
const tsc = tools.find((t) => t.name === 'tsc');
|
||||
assert.equal(eslint.kind, 'lint');
|
||||
assert.equal(eslint.tier, 'file');
|
||||
assert.equal(tsc.kind, 'typecheck');
|
||||
assert.equal(tsc.tier, 'repo');
|
||||
});
|
||||
|
||||
test('detectTools: ruff from pyproject [tool.ruff] and a binary on PATH', () => {
|
||||
const repo = makeRepo('main');
|
||||
writeFileSync(join(repo, 'pyproject.toml'), '[tool.ruff]\nline-length = 100\n');
|
||||
const bin = join(tmp(), 'bin');
|
||||
fakeTool(bin, 'ruff');
|
||||
const tools = detectTools(repo, { env: { PATH: bin } });
|
||||
assert.ok(tools.some((t) => t.name === 'ruff-check' && t.available));
|
||||
assert.ok(tools.some((t) => t.name === 'ruff-format' && t.available));
|
||||
});
|
||||
|
||||
test('toolsForFile: by extension, file tier only', () => {
|
||||
const repo = makeRepo('main');
|
||||
writeFileSync(join(repo, '.prettierrc'), '{}');
|
||||
writeFileSync(join(repo, 'tsconfig.json'), '{}');
|
||||
fakeTool(join(repo, 'node_modules', '.bin'), 'prettier');
|
||||
fakeTool(join(repo, 'node_modules', '.bin'), 'tsc');
|
||||
const tools = detectTools(repo, { env: { PATH: '/nonexistent' } });
|
||||
assert.deepEqual(toolsForFile(tools, 'src/a.ts').map((t) => t.name), ['prettier']);
|
||||
assert.deepEqual(toolsForFile(tools, 'image.png').map((t) => t.name), []);
|
||||
});
|
||||
|
||||
test('runTool: clean, finding and error are three different answers', () => {
|
||||
const repo = makeRepo('main');
|
||||
writeFileSync(join(repo, '.prettierrc'), '{}');
|
||||
fakeTool(join(repo, 'node_modules', '.bin'), 'prettier');
|
||||
mkdirSync(join(repo, 'src'));
|
||||
writeFileSync(join(repo, 'src', 'good.js'), 'ok');
|
||||
writeFileSync(join(repo, 'src', 'bad.js'), 'BAD');
|
||||
const tool = detectTools(repo, { env: { PATH: '/nonexistent' } }).find((t) => t.name === 'prettier');
|
||||
assert.equal(runTool(tool, ['src/good.js'], { cwd: repo }).status, 'clean');
|
||||
const f = runTool(tool, ['src/bad.js'], { cwd: repo });
|
||||
assert.equal(f.status, 'finding');
|
||||
assert.match(f.output, /problem/);
|
||||
// exit 7 is neither clean nor a finding for prettier
|
||||
fakeTool(join(repo, 'node_modules', '.bin'), 'prettier', { findingExit: 7 });
|
||||
assert.equal(runTool(tool, ['src/bad.js'], { cwd: repo }).status, 'error');
|
||||
});
|
||||
106
tests/lib/step-check.test.mjs
Normal file
106
tests/lib/step-check.test.mjs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
// tests/lib/step-check.test.mjs
|
||||
// K9 — the step check (lib/verification/step-check.mjs --cwd <dir>). At step
|
||||
// end it runs the repo's own tools over the changed files (and the repo-wide
|
||||
// ones, such as the type checker), reads the post-edit guard's log, and
|
||||
// answers 0 = green, 1 = findings, 2 = "not measured" (never green). JSON on
|
||||
// stdout. The architecture-rule runner (K5, wave-1 order 3) is optional: when
|
||||
// it is missing, it is listed as not measured.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { writeFileSync, mkdirSync, appendFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { ROOT, makeRepo, gitIn, fakeTool, tmp, pluginCopy } from '../hooks/guard-helpers.mjs';
|
||||
|
||||
const CLI = join(ROOT, 'lib', 'verification', 'step-check.mjs');
|
||||
|
||||
function run(cli, args, env = {}) {
|
||||
const r = spawnSync(process.execPath, [cli, ...args], { encoding: 'utf8', env: { ...process.env, PATH: '/usr/bin:/bin', ...env } });
|
||||
let json = null;
|
||||
try { json = JSON.parse(r.stdout); } catch { json = null; }
|
||||
return { code: r.status, stdout: r.stdout, stderr: r.stderr, json };
|
||||
}
|
||||
|
||||
function toolRepo() {
|
||||
const repo = makeRepo('main');
|
||||
writeFileSync(join(repo, '.prettierrc'), '{}');
|
||||
writeFileSync(join(repo, '.gitignore'), 'node_modules/\n');
|
||||
gitIn(repo, 'add', '.prettierrc', '.gitignore');
|
||||
gitIn(repo, 'commit', '-q', '-m', 'tools');
|
||||
fakeTool(join(repo, 'node_modules', '.bin'), 'prettier');
|
||||
return repo;
|
||||
}
|
||||
|
||||
test('K9 green (0): the changed files pass the repo\'s own tools', () => {
|
||||
const repo = toolRepo();
|
||||
writeFileSync(join(repo, 'a.js'), 'ok\n');
|
||||
const r = run(CLI, ['--cwd', repo]);
|
||||
assert.equal(r.code, 0, r.stdout + r.stderr);
|
||||
assert.equal(r.json.status, 'green');
|
||||
assert.ok(r.json.checks.some((c) => c.name === 'prettier' && c.status === 'clean'));
|
||||
});
|
||||
|
||||
test('K9 findings (1): a changed file the formatter rejects', () => {
|
||||
const repo = toolRepo();
|
||||
writeFileSync(join(repo, 'a.js'), 'BAD\n');
|
||||
const r = run(CLI, ['--cwd', repo]);
|
||||
assert.equal(r.code, 1, r.stdout);
|
||||
assert.equal(r.json?.status, 'findings');
|
||||
});
|
||||
|
||||
test('K9 findings (1): the post-edit log holds a finding for a changed file no tool can re-check', () => {
|
||||
const repo = makeRepo('main');
|
||||
writeFileSync(join(repo, 'a.js'), 'x\n');
|
||||
mkdirSync(join(repo, '.git', 'voyage'), { recursive: true });
|
||||
appendFileSync(join(repo, '.git', 'voyage', 'post-edit.jsonl'),
|
||||
JSON.stringify({ ts: new Date().toISOString(), file: 'a.js', status: 'finding', results: [{ name: 'prettier', status: 'finding' }] }) + '\n');
|
||||
const r = run(CLI, ['--cwd', repo]);
|
||||
assert.equal(r.code, 1, r.stdout);
|
||||
assert.equal(r.json?.status, 'findings', 'exit 1 must be a finding, not a crash');
|
||||
});
|
||||
|
||||
test('K9 not measured (2): a repo with no tools at all -> never green', () => {
|
||||
const repo = makeRepo('main');
|
||||
writeFileSync(join(repo, 'a.js'), 'BAD\n');
|
||||
const r = run(CLI, ['--cwd', repo]);
|
||||
assert.equal(r.code, 2, r.stdout);
|
||||
assert.equal(r.json.status, 'not_measured');
|
||||
});
|
||||
|
||||
test('K9 not measured (2): not a git repository', () => {
|
||||
const r = run(CLI, ['--cwd', tmp()]);
|
||||
assert.equal(r.code, 2);
|
||||
});
|
||||
|
||||
test('K9 usage error (2): no --cwd', () => {
|
||||
assert.equal(run(CLI, []).code, 2);
|
||||
});
|
||||
|
||||
test('K9 --full: checks tracked files, not only the diff', () => {
|
||||
const repo = toolRepo();
|
||||
writeFileSync(join(repo, 'old.js'), 'BAD\n');
|
||||
gitIn(repo, 'add', 'old.js');
|
||||
gitIn(repo, 'commit', '-q', '-m', 'old');
|
||||
assert.notEqual(run(CLI, ['--cwd', repo]).json.status, 'findings', 'old.js is not in the diff');
|
||||
assert.equal(run(CLI, ['--cwd', repo, '--full']).code, 1);
|
||||
});
|
||||
|
||||
test('K5 optional: arch-rules missing is listed as not measured and does not turn green into red', () => {
|
||||
const plugin = pluginCopy({ 'lib/verification/arch-rules.mjs': 'missing' });
|
||||
const repo = toolRepo();
|
||||
writeFileSync(join(repo, 'a.js'), 'ok\n');
|
||||
const r = run(join(plugin, 'lib', 'verification', 'step-check.mjs'), ['--cwd', repo]);
|
||||
assert.equal(r.code, 0, r.stdout);
|
||||
const arch = r.json.checks.find((c) => c.name === 'arch-rules');
|
||||
assert.equal(arch.status, 'not_measured');
|
||||
});
|
||||
|
||||
test('K5 optional: arch-rules stub reporting a breach (exit 1) makes the step red', () => {
|
||||
const plugin = pluginCopy({ 'lib/verification/arch-rules.mjs': 'stub' });
|
||||
const repo = toolRepo();
|
||||
writeFileSync(join(repo, 'a.js'), 'ok\n');
|
||||
const r = run(join(plugin, 'lib', 'verification', 'step-check.mjs'), ['--cwd', repo], { STUB_EXIT: '1' });
|
||||
assert.equal(r.code, 1, r.stdout);
|
||||
assert.equal(r.json?.checks.find((c) => c.name === 'arch-rules')?.status, 'finding');
|
||||
});
|
||||
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