From 3f5d6faf08c2da836df712f1d48e4652e2a8a0d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 01:59:23 +0000 Subject: [PATCH] test(hooks): add the missing self-test matrix for guard-main-checkout.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `guard-main-checkout.sh` was the only PreToolUse guard in this repo with no self-test — and it is the one both CLAUDE.md and AGENTS.md §9 name by path as the mechanism that enforces worktree-first. Its Bash-side follow-on and guard-shared-stash both ship hermetic matrices that `hook-selftests.yml` runs on every PR touching `.claude/hooks/**`; nothing exercised the primary guard at all, in either direction (a fail-open regression stops guarding the shared checkout in silence; a fail-closed one reads to an agent as a broken environment). Ported from objectstack's copy of the same matrix. The port is verbatim: the two repos' hooks differ by 9 diff lines, all inside one comment block, no executable line differs, and both settings.json route the same Edit|Write|NotebookEdit matcher — the sibling file was run here byte-for-byte unmodified first and returned 87 passed, 0 failed. The only edits are the two KNOWN HOLE sections, re-pointed at this repo's own cards for the same defects. 87 cases, 0 failed. No wiring edit is owed: `hook-selftests.yml` discovers matrices with `find` since #7019, and its header states that its recorded case counts are a dated measurement that must not be refreshed. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b --- .claude/hooks/guard-main-checkout.selftest.sh | 333 ++++++++++++++++++ 1 file changed, 333 insertions(+) create mode 100755 .claude/hooks/guard-main-checkout.selftest.sh diff --git a/.claude/hooks/guard-main-checkout.selftest.sh b/.claude/hooks/guard-main-checkout.selftest.sh new file mode 100755 index 0000000000..ca05d34340 --- /dev/null +++ b/.claude/hooks/guard-main-checkout.selftest.sh @@ -0,0 +1,333 @@ +#!/usr/bin/env bash +# Self-test for guard-main-checkout.sh — run it after touching that hook: +# +# .claude/hooks/guard-main-checkout.selftest.sh +# +# Feeds the hook the same JSON payload shape Claude Code delivers on PreToolUse and asserts +# the block/allow verdict per case. Hermetic: it builds its OWN throwaway git repos, a linked +# worktree of each and a non-repo directory under $TMPDIR, so the matrix never depends on +# which machine or which checkout it runs from. Needs jq and git and nothing else — no +# install, no build, no network. Exit 0 = all cases hold. +# +# Companion to guard-main-checkout-bash.selftest.sh, which covers the Bash half of the same +# worktree-first pair. That matrix is mostly SHELL SPLITTING; this hook parses no shell at +# all — it reads .tool_input.file_path and makes a PATH-AND-WORKTREE decision — so these +# cases are derived from what this hook actually decides, not ported from the sibling. +# +# PORTED from objectstack's copy of this matrix (objectstack-ai/objectstack, .claude/hooks/ +# guard-main-checkout.selftest.sh @ d63c8a2) under objectui#6451, and the port is VERBATIM: +# the two repos' guard-main-checkout.sh differ by 9 diff lines that are all inside one +# comment block, no executable line differs, and both settings.json route the identical +# Edit|Write|NotebookEdit matcher — so the sibling file was first run here BYTE-FOR-BYTE +# unmodified (via the two env vars below) and returned 87 passed, 0 failed. Not one case +# needed adapting. The only edits below the header are the two KNOWN HOLE sections, whose +# issue references are re-pointed at this repo's own cards for the same two defects. +# ⛔ Keep the two copies converged: a case that has to differ is evidence the HOOKS have +# drifted, and that drift is the finding — not something to paper over here. +# +# Fail-open by default, on purpose: the process cwd AND CLAUDE_PROJECT_DIR both default to a +# directory in no repo at all, which is the input on which this hook allows everything. A +# case that expects `block` therefore cannot pass by accident — the verdict can only have +# come from the path in the payload. Sections that need a different default say so. +# +# GUARD_MAIN_CHECKOUT_HOOK points the matrix at a scratch copy of the hook, and +# GUARD_MAIN_CHECKOUT_SETTINGS at a scratch copy of settings.json; that is how the mutation +# runs that prove these cases can fail are driven (see NON-VACUITY at the foot of this file). +# Both default to the real files, so a plain invocation checks the real hook and real wiring. + +set -uo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +hook="${GUARD_MAIN_CHECKOUT_HOOK:-$here/guard-main-checkout.sh}" +settings="${GUARD_MAIN_CHECKOUT_SETTINGS:-$here/../settings.json}" +pass=0 +fail=0 +skip=0 + +command -v jq >/dev/null 2>&1 || { echo "selftest needs jq to build payloads" >&2; exit 1; } +command -v git >/dev/null 2>&1 || { echo "selftest needs git to build the fixture" >&2; exit 1; } +[ -x "$hook" ] || { echo "hook not executable: $hook" >&2; exit 1; } + +# --- fixture --------------------------------------------------------------------------- +# MAIN a shared PRIMARY checkout WT a linked worktree of MAIN +# SIB a SECOND primary checkout SIBWT a linked worktree of SIB +# PLAIN a directory inside no repo at all ODD a PRIMARY checkout whose own path +# carries a literal `worktrees` segment +tmp="$(mktemp -d)" +nojq="" +trap 'rm -rf "$tmp" ${nojq:+"$nojq"}' EXIT INT TERM +MAIN="$tmp/mainrepo" +WT="$tmp/mainrepo-task" +SIB="$tmp/siblingrepo" +SIBWT="$tmp/siblingrepo-task" +PLAIN="$tmp/plain" +ODD="$tmp/worktrees/oddrepo" +mkdir -p "$MAIN/pkg/deep" "$SIB/pkg" "$PLAIN" "$ODD/pkg" +( + for r in "$MAIN" "$SIB" "$ODD"; do + cd "$r" || exit 1 + git init -q . + git config user.email selftest@example.com + git config user.name selftest + : > README.md + mkdir -p pkg + : > pkg/x.ts + : > pkg/x.ipynb + git add -A + git commit -qm init + done + cd "$MAIN" && git worktree add -q "$WT" -b selftest-wt + cd "$SIB" && git worktree add -q "$SIBWT" -b selftest-sib-wt +) >/dev/null 2>&1 || { echo "could not build the git fixture" >&2; exit 1; } + +CWD="$PLAIN" # the hook's PROCESS cwd for the cases that follow; reassigned per section +PROJ="$PLAIN" # CLAUDE_PROJECT_DIR for the cases that follow; reassigned per section + +short() { # short -> fixture paths rendered as their variable names + local s="$1" + # longest paths first: $WT and $SIBWT have $MAIN and $SIB as prefixes + s="${s//$SIBWT/\$SIBWT}"; s="${s//$WT/\$WT}"; s="${s//$SIB/\$SIB}" + s="${s//$MAIN/\$MAIN}"; s="${s//$PLAIN/\$PLAIN}"; s="${s//$ODD/\$ODD}" + s="${s//$tmp/\$tmp}" + printf '%s' "$s" +} + +verdict() { # verdict [env…] -> block | allow | exitN + local payload="$1"; shift + local rc + ( cd "$CWD" && printf '%s' "$payload" | env CLAUDE_PROJECT_DIR="$PROJ" "$@" "$hook" >/dev/null 2>&1 ) + rc=$? + case "$rc" in + 0) printf 'allow' ;; + 2) printf 'block' ;; + *) printf 'exit%s' "$rc" ;; + esac +} + +check() { # check