Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 176 additions & 0 deletions .claude/scripts/hooks-parity.test.sh
Original file line number Diff line number Diff line change
@@ -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
112 changes: 112 additions & 0 deletions .claude/self/checks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}

Expand Down
Loading