diff --git a/.claude/scripts/hooks-parity.test.sh b/.claude/scripts/hooks-parity.test.sh new file mode 100755 index 0000000..595d49b --- /dev/null +++ b/.claude/scripts/hooks-parity.test.sh @@ -0,0 +1,176 @@ +#!/usr/bin/env bash +# hooks-parity.test.sh — offline smoke test for the hooks-parity check +# (issue #140) added to .claude/self/checks.sh's `do_hooks_parity` (run as +# part of `do_build`, and therefore under `gate.sh build`/`gate.sh test`). +# +# Asserts: +# 1. Born-green: the check PASSES against the real repo's +# .claude/settings.json / .claude/hooks/hooks.json, with +# log-worker-tool.sh's deliberate self-only entry allowlisted. +# 2. Fail-on-divergence: feeding the check a synthetic hooks.json missing +# a non-allowlisted settings.json hook makes it fail (non-zero). +# 3. Fail-on-divergence (other direction): feeding the check a synthetic +# hooks.json with an extra hook absent from settings.json also fails. +# +# Uses the HOOKS_SETTINGS_FILE / HOOKS_PLUGIN_FILE env-var seam in checks.sh +# to point at hermetic temp-dir fixtures instead of mutating real repo files. +# No gh/network. Exit 0 on success, non-zero if any assertion fails. Runnable +# bare: +# bash .claude/scripts/hooks-parity.test.sh +set -uo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/../.." && pwd)" +checks_sh="$repo_root/.claude/self/checks.sh" + +work="$(mktemp -d "${TMPDIR:-/tmp}/hooks-parity-test.XXXXXX")" +trap 'rm -rf "$work"' EXIT + +fail=0 +ok=0 +check() { + local desc="$1"; shift + if "$@"; then + ok=$((ok + 1)) + echo "ok - $desc" + else + fail=1 + echo "FAIL - $desc" + fi +} + +# --------------------------------------------------------------------------- +# 1. Born-green: real repo files pass as-is. +# --------------------------------------------------------------------------- +check "check passes on the real repo settings.json/hooks.json (born-green)" \ + bash "$checks_sh" build + +# --------------------------------------------------------------------------- +# 2. Synthetic divergence: settings.json has a non-allowlisted hook that +# hooks.json lacks -> the check must fail. +# --------------------------------------------------------------------------- +missing_dir="$work/missing-from-plugin" +mkdir -p "$missing_dir" +settings_missing="$missing_dir/settings.json" +plugin_missing="$missing_dir/hooks.json" + +node -e ' + const fs = require("fs"); + fs.writeFileSync(process.argv[1], JSON.stringify({ + hooks: { + PreToolUse: [ + { matcher: "Bash", hooks: [{ type: "command", command: "python3 \"$CLAUDE_PROJECT_DIR/.claude/scripts/guard-git-add.py\"" }] }, + ], + PostToolUse: [ + // Non-allowlisted, self-only-in-this-fixture hook: no counterpart + // in hooks.json below, and NOT in the real ALLOWLIST -> must fail. + { matcher: "Edit|Write", hooks: [{ type: "command", command: "bash \"$CLAUDE_PROJECT_DIR/.claude/scripts/totally-new-hook.sh\"" }] }, + ], + }, + }, null, 2)); +' "$settings_missing" + +node -e ' + const fs = require("fs"); + fs.writeFileSync(process.argv[1], JSON.stringify({ + hooks: { + PreToolUse: [ + { matcher: "Bash", hooks: [{ type: "command", command: "python3 ${CLAUDE_PLUGIN_ROOT}/scripts/guard-git-add.py" }] }, + ], + }, + }, null, 2)); +' "$plugin_missing" + +HOOKS_SETTINGS_FILE="$settings_missing" HOOKS_PLUGIN_FILE="$plugin_missing" \ + bash "$checks_sh" build >/dev/null 2>&1 +check "check FAILS when settings.json has a non-allowlisted hook absent from hooks.json" \ + [ "$?" -ne 0 ] + +# --------------------------------------------------------------------------- +# 3. Synthetic divergence, other direction: hooks.json has a hook absent +# from settings.json -> the check must fail (consumers can never get a +# hook the self-hosted repo itself doesn't run). +# --------------------------------------------------------------------------- +extra_dir="$work/extra-in-plugin" +mkdir -p "$extra_dir" +settings_extra="$extra_dir/settings.json" +plugin_extra="$extra_dir/hooks.json" + +node -e ' + const fs = require("fs"); + fs.writeFileSync(process.argv[1], JSON.stringify({ + hooks: { + PreToolUse: [ + { matcher: "Bash", hooks: [{ type: "command", command: "python3 \"$CLAUDE_PROJECT_DIR/.claude/scripts/guard-git-add.py\"" }] }, + ], + }, + }, null, 2)); +' "$settings_extra" + +node -e ' + const fs = require("fs"); + fs.writeFileSync(process.argv[1], JSON.stringify({ + hooks: { + PreToolUse: [ + { matcher: "Bash", hooks: [{ type: "command", command: "python3 ${CLAUDE_PLUGIN_ROOT}/scripts/guard-git-add.py" }] }, + ], + PostToolUse: [ + { matcher: "Edit|Write", hooks: [{ type: "command", command: "bash ${CLAUDE_PLUGIN_ROOT}/scripts/only-in-consumers.sh" }] }, + ], + }, + }, null, 2)); +' "$plugin_extra" + +HOOKS_SETTINGS_FILE="$settings_extra" HOOKS_PLUGIN_FILE="$plugin_extra" \ + bash "$checks_sh" build >/dev/null 2>&1 +check "check FAILS when hooks.json has a hook absent from settings.json" \ + [ "$?" -ne 0 ] + +# --------------------------------------------------------------------------- +# 4. Sanity: a matching pair with no allowlist needed passes. +# --------------------------------------------------------------------------- +matching_dir="$work/matching" +mkdir -p "$matching_dir" +settings_matching="$matching_dir/settings.json" +plugin_matching="$matching_dir/hooks.json" + +node -e ' + const fs = require("fs"); + fs.writeFileSync(process.argv[1], JSON.stringify({ + hooks: { + PreToolUse: [ + { matcher: "Bash", hooks: [{ type: "command", command: "python3 \"$CLAUDE_PROJECT_DIR/.claude/scripts/guard-git-add.py\"" }] }, + ], + Stop: [ + { hooks: [{ type: "command", command: "bash \"$CLAUDE_PROJECT_DIR/.claude/scripts/gate.sh\" test_affected" }] }, + ], + }, + }, null, 2)); +' "$settings_matching" + +node -e ' + const fs = require("fs"); + fs.writeFileSync(process.argv[1], JSON.stringify({ + hooks: { + PreToolUse: [ + { matcher: "Bash", hooks: [{ type: "command", command: "python3 ${CLAUDE_PLUGIN_ROOT}/scripts/guard-git-add.py" }] }, + ], + Stop: [ + { hooks: [{ type: "command", command: "bash ${CLAUDE_PLUGIN_ROOT}/scripts/gate.sh test_affected" }] }, + ], + }, + }, null, 2)); +' "$plugin_matching" + +check "check passes on a synthetic matching pair (no allowlist needed)" \ + bash -c 'HOOKS_SETTINGS_FILE="$1" HOOKS_PLUGIN_FILE="$2" bash "$3" build' \ + _ "$settings_matching" "$plugin_matching" "$checks_sh" + +echo "" +if [ "$fail" -eq 0 ]; then + echo "hooks-parity.test.sh: PASS ($ok checks)" + exit 0 +else + echo "hooks-parity.test.sh: FAIL (see FAIL lines above)" + exit 1 +fi diff --git a/.claude/self/checks.sh b/.claude/self/checks.sh index 6be0160..9ba1ddf 100644 --- a/.claude/self/checks.sh +++ b/.claude/self/checks.sh @@ -34,6 +34,118 @@ do_build() { } ' || rc=1 [ "$rc" -eq 0 ] && echo "build: JSON configs valid + adapters well-shaped" + do_hooks_parity || rc=1 + return "$rc" +} + +# do_hooks_parity (issue #140) — the repo-local .claude/settings.json (self- +# hosted sessions, $CLAUDE_PROJECT_DIR paths) and the shipped +# .claude/hooks/hooks.json (consumers, ${CLAUDE_PLUGIN_ROOT} paths) hand- +# duplicate the same hook set under two different path prefixes. Nothing +# checked they stayed in sync, which is how a hook ended up reCode-only for +# weeks (empty cockpit workers panel in every consumer). This normalizes +# both files' `.hooks` into (event, matcher, command) triples — stripping the +# environment-specific path prefix so the two forms collapse to the same +# value — and fails on any divergence that isn't explicitly allowlisted as +# deliberately self-only. +# +# File paths are overridable via HOOKS_SETTINGS_FILE / HOOKS_PLUGIN_FILE so +# tests can point this at hermetic temp-dir fixtures instead of the real repo +# files. +do_hooks_parity() { + local rc=0 + local settings_file="${HOOKS_SETTINGS_FILE:-.claude/settings.json}" + local plugin_file="${HOOKS_PLUGIN_FILE:-.claude/hooks/hooks.json}" + + if [ ! -f "$settings_file" ]; then echo "build: hooks-parity — missing $settings_file"; return 1; fi + if [ ! -f "$plugin_file" ]; then echo "build: hooks-parity — missing $plugin_file"; return 1; fi + + node -e ' + const fs = require("fs"); + const settingsFile = process.argv[1]; + const pluginFile = process.argv[2]; + + // Deliberate self-only hooks: present in settings.json (self-hosted repo) + // but intentionally absent from hooks.json (shipped to consumers). + // Key format: "event|matcher|normalizedCommand". + const ALLOWLIST = new Set([ + // log-worker-tool.sh mirrors a worker session'"'"'s tool calls into the + // cockpit workers panel. That panel — and the whole notion of a + // worker session to mirror — only exists in reCode'"'"'s own + // self-hosted orchestration loop; consumer projects have no cockpit + // worker-session view to feed, so this hook is deliberately + // self-only and must never be added to hooks.json. + "PostToolUse|Bash|Edit|Write|bash scripts/log-worker-tool.sh", + ]); + + // Collapse the two environment-specific path prefixes + // ($CLAUDE_PROJECT_DIR/.claude/... in settings.json, quoted, vs. + // ${CLAUDE_PLUGIN_ROOT}/... in hooks.json, unquoted) down to the same + // plugin-root-relative form, and drop any remaining quoting, so + // "bash \"$CLAUDE_PROJECT_DIR/.claude/scripts/gate.sh\" lint" and + // "bash ${CLAUDE_PLUGIN_ROOT}/scripts/gate.sh lint" both normalize to + // "bash scripts/gate.sh lint". + function normalizeCommand(cmd) { + return cmd + .replace(/"\$CLAUDE_PROJECT_DIR\/\.claude\//g, "") + .replace(/\$CLAUDE_PROJECT_DIR\/\.claude\//g, "") + .replace(/\$\{CLAUDE_PLUGIN_ROOT\}\//g, "") + .replace(/"/g, "") + .trim() + .replace(/\s+/g, " "); + } + + function triples(file) { + const data = JSON.parse(fs.readFileSync(file, "utf8")); + const hooksObj = data.hooks || {}; + const out = []; + for (const [event, entries] of Object.entries(hooksObj)) { + for (const entry of entries || []) { + const matcher = entry.matcher || ""; + for (const h of entry.hooks || []) { + const cmd = normalizeCommand(h.command || ""); + out.push({ event, matcher, cmd, key: `${event}|${matcher}|${cmd}` }); + } + } + } + return out; + } + + const settingsTriples = triples(settingsFile); + const pluginTriples = triples(pluginFile); + const settingsKeys = new Set(settingsTriples.map((t) => t.key)); + const pluginKeys = new Set(pluginTriples.map((t) => t.key)); + + let failed = false; + + // In settings.json (self) but not hooks.json (consumers) — OK only if + // explicitly allowlisted as deliberately self-only. + for (const t of settingsTriples) { + if (!pluginKeys.has(t.key) && !ALLOWLIST.has(t.key)) { + console.error( + `build: hooks-parity — event="${t.event}" matcher="${t.matcher}" cmd="${t.cmd}" ` + + `present in ${settingsFile} but missing from ${pluginFile} (not in allowlist)` + ); + failed = true; + } + } + + // In hooks.json (consumers) but not settings.json (self) — never + // allowed: every hook shipped to consumers must also run self-hosted. + for (const t of pluginTriples) { + if (!settingsKeys.has(t.key)) { + console.error( + `build: hooks-parity — event="${t.event}" matcher="${t.matcher}" cmd="${t.cmd}" ` + + `present in ${pluginFile} but missing from ${settingsFile}` + ); + failed = true; + } + } + + if (failed) process.exit(1); + ' "$settings_file" "$plugin_file" || rc=1 + + [ "$rc" -eq 0 ] && echo "build: hooks parity OK — settings.json and hooks.json hook triples match (mod allowlist)" return "$rc" }