feat(hooks): add deterministic validation layer via git hooks

Adds setup-hooks.sh and check-manifests.sh as the deterministic
enforcement layer described in docs/research/governance_principles/CONTROLS.md.

- commit-msg: conventional commits pattern check (hard block)
- pre-commit: shellcheck on .sh, jq on .json, yq on .yaml/.yml,
  SKILL.md frontmatter validation; optional tools degrade gracefully
- pre-push: full test suite + manifest cross-reference check
- check-manifests.sh: validates marketplace.json plugin sources,
  plugin.json skill/hooks/mcpServers path references
- Marker-based blocks (idempotent, composable with gitleaks)
- 33 integration tests across two test scripts

Run scripts/setup-hooks.sh to install into any repo's .git/hooks/.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TP4EGbBg3XMcyF28Lx78XJ
This commit is contained in:
2026-06-20 21:50:08 +00:00
parent d245807cae
commit ce673ca5e2
4 changed files with 559 additions and 0 deletions

View File

@@ -0,0 +1,147 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SCRIPT="$REPO_ROOT/scripts/check-manifests.sh"
PASS=0
FAIL=0
pass() { echo " PASS: $1"; PASS=$((PASS + 1)); }
fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
# Helper: make a minimal valid repo fixture with marketplace + plugin structure
make_valid_fixture() {
local dir
dir="$(mktemp -d)"
mkdir -p "$dir/.claude-plugin"
mkdir -p "$dir/plugins/myplugin/skills/my-skill"
mkdir -p "$dir/plugins/myplugin/agents"
cat > "$dir/.claude-plugin/marketplace.json" <<'JSON'
{
"name": "test-marketplace",
"plugins": [
{ "name": "myplugin", "source": "./plugins/myplugin" }
]
}
JSON
cat > "$dir/plugins/myplugin/plugin.json" <<'JSON'
{
"name": "myplugin",
"skills": ["skills/"],
"agents": "agents/",
"hooks": "hooks.json"
}
JSON
touch "$dir/plugins/myplugin/hooks.json"
echo "$dir"
}
# --- 1. Exits 0 against valid repo structure ---
echo ""
echo "--- exits 0 when all references are valid ---"
FIXTURE="$(make_valid_fixture)"
trap 'rm -rf "$FIXTURE"' EXIT
if bash "$SCRIPT" "$FIXTURE" > /dev/null 2>&1; then
pass "exits 0 when all manifest references resolve"
else
fail "exited non-zero against a valid fixture"
fi
# --- 2. Exits 1 when plugin source dir is missing ---
echo ""
echo "--- exits 1 when plugin source directory missing ---"
FIXTURE2="$(mktemp -d)"
trap 'rm -rf "$FIXTURE2"' EXIT
mkdir -p "$FIXTURE2/.claude-plugin"
cat > "$FIXTURE2/.claude-plugin/marketplace.json" <<'JSON'
{
"name": "test-marketplace",
"plugins": [
{ "name": "ghost", "source": "./plugins/ghost" }
]
}
JSON
if bash "$SCRIPT" "$FIXTURE2" > /dev/null 2>&1; then
fail "exited 0 when plugin source dir is missing — expected exit 1"
else
pass "exits non-zero when plugin source directory does not exist"
fi
# --- 3. Exits 1 when plugin.json is missing from plugin dir ---
echo ""
echo "--- exits 1 when plugin.json missing from plugin directory ---"
FIXTURE3="$(mktemp -d)"
trap 'rm -rf "$FIXTURE3"' EXIT
mkdir -p "$FIXTURE3/.claude-plugin"
mkdir -p "$FIXTURE3/plugins/nomanifest"
cat > "$FIXTURE3/.claude-plugin/marketplace.json" <<'JSON'
{
"name": "test-marketplace",
"plugins": [
{ "name": "nomanifest", "source": "./plugins/nomanifest" }
]
}
JSON
if bash "$SCRIPT" "$FIXTURE3" > /dev/null 2>&1; then
fail "exited 0 when plugin.json is missing — expected exit 1"
else
pass "exits non-zero when plugin.json is missing from plugin directory"
fi
# --- 4. Exits 1 when a skills directory listed in plugin.json does not exist ---
echo ""
echo "--- exits 1 when skills directory missing ---"
FIXTURE4="$(mktemp -d)"
trap 'rm -rf "$FIXTURE4"' EXIT
mkdir -p "$FIXTURE4/.claude-plugin"
mkdir -p "$FIXTURE4/plugins/myplugin"
cat > "$FIXTURE4/.claude-plugin/marketplace.json" <<'JSON'
{
"name": "test-marketplace",
"plugins": [
{ "name": "myplugin", "source": "./plugins/myplugin" }
]
}
JSON
cat > "$FIXTURE4/plugins/myplugin/plugin.json" <<'JSON'
{
"name": "myplugin",
"skills": ["skills/"]
}
JSON
if bash "$SCRIPT" "$FIXTURE4" > /dev/null 2>&1; then
fail "exited 0 when skills dir missing — expected exit 1"
else
pass "exits non-zero when skills directory referenced in plugin.json does not exist"
fi
# --- 5. Exits 1 when a file referenced in plugin.json (hooks) does not exist ---
echo ""
echo "--- exits 1 when referenced hooks file missing ---"
FIXTURE5="$(mktemp -d)"
trap 'rm -rf "$FIXTURE5"' EXIT
mkdir -p "$FIXTURE5/.claude-plugin"
mkdir -p "$FIXTURE5/plugins/myplugin/skills"
cat > "$FIXTURE5/.claude-plugin/marketplace.json" <<'JSON'
{
"name": "test-marketplace",
"plugins": [
{ "name": "myplugin", "source": "./plugins/myplugin" }
]
}
JSON
cat > "$FIXTURE5/plugins/myplugin/plugin.json" <<'JSON'
{
"name": "myplugin",
"hooks": "hooks.json"
}
JSON
if bash "$SCRIPT" "$FIXTURE5" > /dev/null 2>&1; then
fail "exited 0 when hooks file missing — expected exit 1"
else
pass "exits non-zero when file referenced in plugin.json does not exist"
fi
echo ""
echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]]

190
tests/test-setup-hooks.sh Normal file
View File

@@ -0,0 +1,190 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SCRIPT="$REPO_ROOT/scripts/setup-hooks.sh"
PASS=0
FAIL=0
pass() { echo " PASS: $1"; PASS=$((PASS + 1)); }
fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
# Helper: make a bare git repo with no hooks yet
make_repo() {
local dir
dir="$(mktemp -d)"
git -C "$dir" init -q
echo "$dir"
}
# Fake binaries for tools we don't want to install-check during tests
FAKE_BIN="$(mktemp -d)"
trap 'rm -rf "$FAKE_BIN"' EXIT
for tool in shellcheck jq yq; do
printf '#!/bin/sh\necho "fake %s"\n' "$tool" > "$FAKE_BIN/$tool"
chmod +x "$FAKE_BIN/$tool"
done
export PATH="$FAKE_BIN:$PATH"
# --- 1. Rejects non-git directory ---
echo ""
echo "--- rejects non-git directory ---"
NON_GIT="$(mktemp -d)"
trap 'rm -rf "$NON_GIT"' EXIT
if bash "$SCRIPT" "$NON_GIT" > /dev/null 2>&1; then
fail "exited 0 for non-git directory — expected exit 1"
else
pass "exits non-zero for non-git directory"
fi
# --- 2. Creates commit-msg hook ---
echo ""
echo "--- creates commit-msg hook ---"
REPO="$(make_repo)"
trap 'rm -rf "$REPO"' EXIT
bash "$SCRIPT" "$REPO" > /dev/null 2>&1
HOOK="$REPO/.git/hooks/commit-msg"
if [[ -f "$HOOK" ]]; then
pass "commit-msg hook file created"
else
fail "commit-msg hook not created"
fi
if [[ -x "$HOOK" ]]; then
pass "commit-msg hook is executable"
else
fail "commit-msg hook is not executable"
fi
if grep -q "# managed by setup-hooks.sh" "$HOOK"; then
pass "commit-msg hook contains idempotency marker"
else
fail "commit-msg hook missing idempotency marker"
fi
# --- 3. commit-msg hook validates conventional commits ---
echo ""
echo "--- commit-msg hook: valid message passes ---"
REPO2="$(make_repo)"
trap 'rm -rf "$REPO2"' EXIT
bash "$SCRIPT" "$REPO2" > /dev/null 2>&1
HOOK2="$REPO2/.git/hooks/commit-msg"
TMPFILE="$(mktemp)"
trap 'rm -f "$TMPFILE"' EXIT
for valid_msg in "feat: add validation" "fix(core): correct path resolution" "chore!: drop python dep" "docs: update readme" "refactor(hooks): extract marker logic"; do
echo "$valid_msg" > "$TMPFILE"
if bash "$HOOK2" "$TMPFILE" > /dev/null 2>&1; then
pass "commit-msg hook accepts: $valid_msg"
else
fail "commit-msg hook wrongly rejected: $valid_msg"
fi
done
echo ""
echo "--- commit-msg hook: invalid message is rejected ---"
for invalid_msg in "added some stuff" "WIP" "Fix the thing" "FEAT: bad case" "feat bad colon"; do
echo "$invalid_msg" > "$TMPFILE"
if bash "$HOOK2" "$TMPFILE" > /dev/null 2>&1; then
fail "commit-msg hook wrongly accepted: $invalid_msg"
else
pass "commit-msg hook rejects: $invalid_msg"
fi
done
# --- 4. Appends pre-commit validation block ---
echo ""
echo "--- appends validation block to pre-commit hook ---"
REPO3="$(make_repo)"
trap 'rm -rf "$REPO3"' EXIT
bash "$SCRIPT" "$REPO3" > /dev/null 2>&1
PRE_COMMIT="$REPO3/.git/hooks/pre-commit"
if [[ -f "$PRE_COMMIT" ]]; then
pass "pre-commit hook created"
else
fail "pre-commit hook not created"
fi
if grep -q "shellcheck" "$PRE_COMMIT"; then
pass "pre-commit hook contains shellcheck"
else
fail "pre-commit hook missing shellcheck"
fi
if grep -q "jq" "$PRE_COMMIT"; then
pass "pre-commit hook contains jq"
else
fail "pre-commit hook missing jq"
fi
if grep -q "SKILL.md" "$PRE_COMMIT"; then
pass "pre-commit hook contains SKILL.md frontmatter check"
else
fail "pre-commit hook missing SKILL.md frontmatter check"
fi
# --- 5. Creates pre-push hook ---
echo ""
echo "--- creates pre-push hook ---"
REPO4="$(make_repo)"
trap 'rm -rf "$REPO4"' EXIT
bash "$SCRIPT" "$REPO4" > /dev/null 2>&1
PUSH_HOOK="$REPO4/.git/hooks/pre-push"
if [[ -f "$PUSH_HOOK" ]]; then
pass "pre-push hook created"
else
fail "pre-push hook not created"
fi
if [[ -x "$PUSH_HOOK" ]]; then
pass "pre-push hook is executable"
else
fail "pre-push hook is not executable"
fi
if grep -q "check-manifests" "$PUSH_HOOK"; then
pass "pre-push hook calls check-manifests.sh"
else
fail "pre-push hook missing check-manifests.sh call"
fi
# --- 6. Idempotent: second run replaces each block exactly once ---
echo ""
echo "--- idempotent: second run does not duplicate blocks ---"
REPO5="$(make_repo)"
trap 'rm -rf "$REPO5"' EXIT
bash "$SCRIPT" "$REPO5" > /dev/null 2>&1
bash "$SCRIPT" "$REPO5" > /dev/null 2>&1
bash "$SCRIPT" "$REPO5" > /dev/null 2>&1
for hook_file in "$REPO5/.git/hooks/commit-msg" "$REPO5/.git/hooks/pre-commit" "$REPO5/.git/hooks/pre-push"; do
count=$(grep -c "# managed by setup-hooks.sh" "$hook_file" || true)
hook_name="$(basename "$hook_file")"
if [[ "$count" -eq 1 ]]; then
pass "idempotent: $hook_name marker appears exactly once after 3 runs"
else
fail "idempotent: $hook_name marker appears $count times — block duplicated"
fi
done
# --- 7. Hooks contain graceful degradation paths for missing tools ---
echo ""
echo "--- hooks contain graceful degradation for missing tools ---"
REPO6="$(make_repo)"
trap 'rm -rf "$REPO6"' EXIT
bash "$SCRIPT" "$REPO6" > /dev/null 2>&1
PRE_COMMIT6="$REPO6/.git/hooks/pre-commit"
for tool in shellcheck jq yq; do
if grep -q "Warning:.*$tool\|$tool.*not installed\|$tool.*skipped" "$PRE_COMMIT6"; then
pass "pre-commit hook has graceful degradation path for missing: $tool"
else
fail "pre-commit hook missing graceful degradation for: $tool"
fi
done
echo ""
echo "--- setup always exits 0 (no hard dependency on optional tools) ---"
REPO7="$(make_repo)"
trap 'rm -rf "$REPO7"' EXIT
if bash "$SCRIPT" "$REPO7" > /dev/null 2>&1; then
pass "setup exits 0 when all optional tools are present"
else
fail "setup exited non-zero unexpectedly"
fi
echo ""
echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]]