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:
67
scripts/check-manifests.sh
Executable file
67
scripts/check-manifests.sh
Executable file
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Validates that all paths referenced in marketplace.json and plugin.json manifests
|
||||||
|
# resolve to existing files or directories. Run from repo root or pass REPO_ROOT as arg.
|
||||||
|
|
||||||
|
REPO_ROOT="${1:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
err() { echo " FAIL: $1" >&2; FAIL=$((FAIL + 1)); }
|
||||||
|
|
||||||
|
if ! command -v jq &>/dev/null; then
|
||||||
|
echo "Error: jq is required but not installed" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
MARKETPLACE="$REPO_ROOT/.claude-plugin/marketplace.json"
|
||||||
|
if [[ ! -f "$MARKETPLACE" ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
plugin_count=$(jq '.plugins | length' "$MARKETPLACE")
|
||||||
|
|
||||||
|
for ((i = 0; i < plugin_count; i++)); do
|
||||||
|
name=$(jq -r ".plugins[$i].name" "$MARKETPLACE")
|
||||||
|
source=$(jq -r ".plugins[$i].source" "$MARKETPLACE")
|
||||||
|
source="${source#./}"
|
||||||
|
plugin_dir="$REPO_ROOT/$source"
|
||||||
|
|
||||||
|
if [[ ! -d "$plugin_dir" ]]; then
|
||||||
|
err "plugin '$name': source directory not found: $source"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
manifest="$plugin_dir/plugin.json"
|
||||||
|
if [[ ! -f "$manifest" ]]; then
|
||||||
|
err "plugin '$name': plugin.json not found in $source"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check skills directories
|
||||||
|
skill_count=$(jq '.skills | if . then length else 0 end' "$manifest")
|
||||||
|
for ((s = 0; s < skill_count; s++)); do
|
||||||
|
skill_path=$(jq -r ".skills[$s]" "$manifest")
|
||||||
|
full_path="$plugin_dir/$skill_path"
|
||||||
|
full_path="${full_path%/}"
|
||||||
|
if [[ ! -d "$full_path" ]]; then
|
||||||
|
err "plugin '$name': skills path not found: $skill_path"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Check file references (hooks, mcpServers, agents)
|
||||||
|
for field in hooks mcpServers agents; do
|
||||||
|
ref=$(jq -r ".${field} // empty" "$manifest")
|
||||||
|
[[ -z "$ref" ]] && continue
|
||||||
|
full_path="$plugin_dir/$ref"
|
||||||
|
full_path="${full_path%/}"
|
||||||
|
if [[ ! -e "$full_path" ]]; then
|
||||||
|
err "plugin '$name': $field path not found: $ref"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ $FAIL -gt 0 ]]; then
|
||||||
|
echo "Manifest check failed: $FAIL error(s)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
155
scripts/setup-hooks.sh
Executable file
155
scripts/setup-hooks.sh
Executable file
@@ -0,0 +1,155 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Installs git hook blocks for validation into a target repository.
|
||||||
|
# Usage: setup-hooks.sh [TARGET_REPO]
|
||||||
|
# TARGET_REPO — path to the git repo to configure (default: current directory)
|
||||||
|
# Idempotent: safe to re-run; always replaces each managed block with the current version.
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
TARGET="${1:-$(pwd)}"
|
||||||
|
MARKER="# managed by setup-hooks.sh"
|
||||||
|
END_MARKER="# end setup-hooks"
|
||||||
|
|
||||||
|
if [[ ! -d "$TARGET/.git" ]]; then
|
||||||
|
echo "Error: $TARGET is not a git repository" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Tool checks (optional — warn, don't fail) ---
|
||||||
|
|
||||||
|
warn_if_missing() {
|
||||||
|
local tool="$1"
|
||||||
|
if ! command -v "$tool" &>/dev/null; then
|
||||||
|
echo "Warning: $tool not installed — ${tool} checks in pre-commit will be skipped (install $tool to enable)" >&2
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
warn_if_missing shellcheck
|
||||||
|
warn_if_missing jq
|
||||||
|
warn_if_missing yq
|
||||||
|
|
||||||
|
# --- Marker-block helpers ---
|
||||||
|
|
||||||
|
write_block() {
|
||||||
|
local hook_file="$1"
|
||||||
|
local block_content="$2"
|
||||||
|
|
||||||
|
if grep -qF "$MARKER" "$hook_file"; then
|
||||||
|
awk -v start="$MARKER" -v end="$END_MARKER" '
|
||||||
|
$0 == start { skip=1; next }
|
||||||
|
skip && $0 == end { skip=0; next }
|
||||||
|
!skip { print }
|
||||||
|
' "$hook_file" > "${hook_file}.tmp" && mv "${hook_file}.tmp" "$hook_file"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '\n%s\n%s\n%s\n' "$MARKER" "$block_content" "$END_MARKER" >> "$hook_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_hook() {
|
||||||
|
local hook_file="$1"
|
||||||
|
if [[ ! -f "$hook_file" ]]; then
|
||||||
|
printf '#!/usr/bin/env bash\nset -euo pipefail\n' > "$hook_file"
|
||||||
|
chmod +x "$hook_file"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- commit-msg: conventional commits ---
|
||||||
|
|
||||||
|
COMMIT_MSG_HOOK="$TARGET/.git/hooks/commit-msg"
|
||||||
|
ensure_hook "$COMMIT_MSG_HOOK"
|
||||||
|
|
||||||
|
commit_msg_block() {
|
||||||
|
cat <<'BLOCK'
|
||||||
|
msg=$(cat "$1")
|
||||||
|
pattern='^(feat|fix|docs|chore|refactor|test|perf|ci|build|revert)(\(.+\))?!?: .+'
|
||||||
|
if ! echo "$msg" | grep -qE "$pattern"; then
|
||||||
|
echo "ERROR: Commit message must follow Conventional Commits format." >&2
|
||||||
|
echo " Examples: feat: add login, fix(auth): correct token expiry, chore!: drop python dep" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
BLOCK
|
||||||
|
}
|
||||||
|
|
||||||
|
write_block "$COMMIT_MSG_HOOK" "$(commit_msg_block)"
|
||||||
|
echo "Updated: $COMMIT_MSG_HOOK (conventional commits check)"
|
||||||
|
|
||||||
|
# --- pre-commit: shellcheck + jq/yq + SKILL.md frontmatter ---
|
||||||
|
|
||||||
|
PRE_COMMIT_HOOK="$TARGET/.git/hooks/pre-commit"
|
||||||
|
ensure_hook "$PRE_COMMIT_HOOK"
|
||||||
|
|
||||||
|
pre_commit_block() {
|
||||||
|
cat <<'BLOCK'
|
||||||
|
staged=$(git diff --cached --name-only --diff-filter=ACM)
|
||||||
|
|
||||||
|
# shellcheck on staged .sh files
|
||||||
|
if command -v shellcheck &>/dev/null; then
|
||||||
|
echo "$staged" | grep '\.sh$' | while IFS= read -r f; do
|
||||||
|
[[ -f "$f" ]] && shellcheck "$f"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo "Warning: shellcheck not installed — shell script linting skipped" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# jq validation on staged .json files
|
||||||
|
if command -v jq &>/dev/null; then
|
||||||
|
echo "$staged" | grep '\.json$' | while IFS= read -r f; do
|
||||||
|
[[ -f "$f" ]] && jq . "$f" > /dev/null
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo "Warning: jq not installed — JSON validation skipped" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# yq validation on staged .yaml/.yml files
|
||||||
|
if command -v yq &>/dev/null; then
|
||||||
|
echo "$staged" | grep -E '\.(yaml|yml)$' | while IFS= read -r f; do
|
||||||
|
[[ -f "$f" ]] && yq eval '.' "$f" > /dev/null
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo "Warning: yq not installed — YAML validation skipped" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# SKILL.md frontmatter: must have name: and description:
|
||||||
|
echo "$staged" | grep 'SKILL\.md$' | while IFS= read -r f; do
|
||||||
|
if [[ -f "$f" ]]; then
|
||||||
|
if ! grep -q '^name:' "$f" || ! grep -q '^description' "$f"; then
|
||||||
|
echo "ERROR: $f is missing required frontmatter fields (name: and description:)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
BLOCK
|
||||||
|
}
|
||||||
|
|
||||||
|
write_block "$PRE_COMMIT_HOOK" "$(pre_commit_block)"
|
||||||
|
echo "Updated: $PRE_COMMIT_HOOK (shellcheck + jq/yq + SKILL.md validation)"
|
||||||
|
|
||||||
|
# --- pre-push: test suite + manifest cross-reference ---
|
||||||
|
|
||||||
|
PRE_PUSH_HOOK="$TARGET/.git/hooks/pre-push"
|
||||||
|
ensure_hook "$PRE_PUSH_HOOK"
|
||||||
|
|
||||||
|
pre_push_block() {
|
||||||
|
local script_dir="$SCRIPT_DIR"
|
||||||
|
cat <<BLOCK
|
||||||
|
HOOKS_SCRIPT_DIR="$script_dir"
|
||||||
|
REPO_ROOT="\$(git rev-parse --show-toplevel)"
|
||||||
|
|
||||||
|
echo "Running test suite..."
|
||||||
|
bash "\$REPO_ROOT/tests/test-install.sh"
|
||||||
|
bash "\$REPO_ROOT/tests/test-governance-layer.sh"
|
||||||
|
bash "\$REPO_ROOT/tests/test-check-manifests.sh"
|
||||||
|
bash "\$REPO_ROOT/tests/test-setup-hooks.sh"
|
||||||
|
|
||||||
|
echo "Checking manifests..."
|
||||||
|
bash "\$HOOKS_SCRIPT_DIR/check-manifests.sh" "\$REPO_ROOT"
|
||||||
|
BLOCK
|
||||||
|
}
|
||||||
|
|
||||||
|
write_block "$PRE_PUSH_HOOK" "$(pre_push_block)"
|
||||||
|
echo "Updated: $PRE_PUSH_HOOK (test suite + manifest check)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Done. Hooks installed in $TARGET/.git/hooks/"
|
||||||
|
echo "To skip on a single push: git push --no-verify"
|
||||||
147
tests/test-check-manifests.sh
Normal file
147
tests/test-check-manifests.sh
Normal 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
190
tests/test-setup-hooks.sh
Normal 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 ]]
|
||||||
Reference in New Issue
Block a user