From 6db18e96d09a9703656c61b029dc6b905c7f8aa0 Mon Sep 17 00:00:00 2001 From: Jordan Winters Date: Fri, 24 Jul 2026 09:34:24 -0500 Subject: [PATCH 1/2] test(hooks): hook-behavior harness, shellcheck CI, baseline cases (U15) Co-Authored-By: Claude Fable 5 --- .claude/hooks/pre-commit-verification.sh | 7 -- .claude/hooks/session-start-loader.sh | 2 +- .github/workflows/framework-invariants.yml | 29 +++++ CHANGELOG.md | 1 + scripts/fixtures/failing-project/package.json | 9 ++ scripts/fixtures/slow-gate/gate.sh | 5 + scripts/hook-tests.d/00-baseline.sh | 109 ++++++++++++++++++ scripts/test-hooks.sh | 108 +++++++++++++++++ 8 files changed, 262 insertions(+), 8 deletions(-) create mode 100644 scripts/fixtures/failing-project/package.json create mode 100755 scripts/fixtures/slow-gate/gate.sh create mode 100755 scripts/hook-tests.d/00-baseline.sh create mode 100755 scripts/test-hooks.sh diff --git a/.claude/hooks/pre-commit-verification.sh b/.claude/hooks/pre-commit-verification.sh index c98d274..b4fc778 100755 --- a/.claude/hooks/pre-commit-verification.sh +++ b/.claude/hooks/pre-commit-verification.sh @@ -42,7 +42,6 @@ if [ -f "$VERIFICATION_FILE" ]; then fi # Detect project type and available tools -VERIFICATION_COMMANDS="" DETECTED_TOOLS="" # TypeScript/JavaScript (pnpm preferred per tech strategy) @@ -74,12 +73,6 @@ fi # Python (uv preferred per tech strategy) if [ -f "$PROJECT_DIR/pyproject.toml" ]; then - if command -v uv &> /dev/null; then - PY_MGR="uv run" - else - PY_MGR="python -m" - fi - # Ruff (preferred per tech strategy) if grep -q "ruff" "$PROJECT_DIR/pyproject.toml" 2>/dev/null; then DETECTED_TOOLS="$DETECTED_TOOLS ruff" diff --git a/.claude/hooks/session-start-loader.sh b/.claude/hooks/session-start-loader.sh index 71b9c44..ea05ecc 100755 --- a/.claude/hooks/session-start-loader.sh +++ b/.claude/hooks/session-start-loader.sh @@ -32,7 +32,7 @@ echo "{\"session_id\": \"$SESSION_ID\", \"started\": \"$(date -Iseconds)\", \"so CONTEXT="" # Check for active swarm agents -ACTIVE_AGENTS=$(ls -1 "$STATE_DIR"/session_*.json 2>/dev/null | wc -l | tr -d ' ') +ACTIVE_AGENTS=$(find "$STATE_DIR" -maxdepth 1 -name 'session_*.json' -type f 2>/dev/null | wc -l | tr -d ' ') if [ "$ACTIVE_AGENTS" -gt 1 ]; then CONTEXT="$CONTEXT diff --git a/.github/workflows/framework-invariants.yml b/.github/workflows/framework-invariants.yml index 36f2a5e..c5ce752 100644 --- a/.github/workflows/framework-invariants.yml +++ b/.github/workflows/framework-invariants.yml @@ -18,6 +18,35 @@ jobs: - name: Run invariant checks run: ./scripts/check-invariants.sh + shellcheck: + name: Shellcheck + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - name: Run shellcheck + # SC2319/SC2164 (scripts/check-invariants.sh) and SC1087 (scripts/init-framework.sh) + # are pre-existing findings out of scope for this change (no hook, no harness case + # protects them yet) — tracked for a future cleanup pass rather than fixed blind here. + # File list is filesystem-derived (find, not a hardcoded list) so new hooks/scripts/ + # fixtures are covered automatically — see scripts/test-hooks.sh's own extension model. + run: | + # shellcheck disable=SC2046 # deliberate word-split: one file path per shellcheck arg + shellcheck -e SC2319,SC2164,SC1087 $(find .claude/hooks scripts -name '*.sh' -type f | sort) + + hook-tests: + name: Hook behavior tests + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - name: Run hook behavior harness + run: ./scripts/test-hooks.sh + secret-scan: name: Secret scan (Trivy) runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b78c7b..091f39a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Python stack pack (`.claude/templates/stack-packs/python/`): the second stack under the three-file convention — Python 3.13+, uv, Ruff, Litestar, msgspec, asyncpg golden path with the pytest/mypy gate suite `pre-commit-verification.sh` detects, plus a matching `ci-gates.yml` - Go stack pack (`.claude/templates/stack-packs/go/`): the third stack under the three-file convention — Go 1.25+, Gin or Chi, sqlc + pgx v5, golangci-lint golden path with the go vet/go test/go build gate suite `pre-commit-verification.sh` detects, plus a matching `ci-gates.yml`; completes the initial trio and graduates the "Generated by tailor" delivery tier from roadmap to shipped in `docs/skills.md` - Rust stack pack (`.claude/templates/stack-packs/rust/`): the fourth stack under the three-file convention — Rust 2024 edition, Tokio/Monoio, Axum, sqlx/rkyv golden path with the cargo test/clippy/fmt gate suite `pre-commit-verification.sh` detects, plus a matching `ci-gates.yml`; first demand-driven addition per the ADR's Rule of Three, added on owner request rather than as part of the initial trio — exercises the pack discovery mechanism (`adr_stack_packs.md` Decision 4: a new pack is a new directory, zero engine change) end to end +- `scripts/test-hooks.sh` + `scripts/hook-tests.d/`: table-driven hook-behavior test harness (same `report()`/PASS-FAIL idiom as `check-invariants.sh`), extended by dropping new case files in `hook-tests.d/` — never by editing the runner; includes a `path_without_jq` fail-open shim. `00-baseline.sh` pins the current behavior of the five hooks `artifacts/plan_framework_hardening.md` (unit U15) touches next. Two new blocking CI jobs in `framework-invariants.yml`: `shellcheck` (`.claude/hooks/*.sh` + `scripts/*.sh`, filesystem-derived file list) and `hook-tests` (runs the new harness); `scripts/fixtures/failing-project/` and `scripts/fixtures/slow-gate/` fixtures for later units' gate-behavior ACs. Fixes the 3 pre-existing shellcheck findings in the touched hooks (`pre-commit-verification.sh`'s dead `VERIFICATION_COMMANDS`/`PY_MGR`, `session-start-loader.sh`'s `ls`→`find`) so the new job starts green ## [4.0.0] - 2026-07-23 diff --git a/scripts/fixtures/failing-project/package.json b/scripts/fixtures/failing-project/package.json new file mode 100644 index 0000000..e9be12c --- /dev/null +++ b/scripts/fixtures/failing-project/package.json @@ -0,0 +1,9 @@ +{ + "name": "failing-project-fixture", + "version": "0.0.0", + "private": true, + "description": "Dependency-free fixture for hook-behavior tests: a project whose test script always fails (no npm install needed) — exercises the pre-commit gate's red path.", + "scripts": { + "test": "node -e \"process.exit(1)\"" + } +} diff --git a/scripts/fixtures/slow-gate/gate.sh b/scripts/fixtures/slow-gate/gate.sh new file mode 100755 index 0000000..fbb499f --- /dev/null +++ b/scripts/fixtures/slow-gate/gate.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Fixture: a "gate" that runs longer than a single gate should. Used by later +# units' harness cases (U5b) to prove a per-gate timeout produces an honest +# "ask" rather than a silent kill or an indefinite hang. +sleep 8 diff --git a/scripts/hook-tests.d/00-baseline.sh b/scripts/hook-tests.d/00-baseline.sh new file mode 100755 index 0000000..35a70e0 --- /dev/null +++ b/scripts/hook-tests.d/00-baseline.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# scripts/hook-tests.d/00-baseline.sh — characterization cases pinning the +# CURRENT behavior of the five hooks this framework-hardening plan touches +# next (artifacts/plan_framework_hardening.md, unit U15). These must pass +# against the *unmodified* hooks; a later unit changing one of these +# behaviors does so deliberately, by editing the case here, not by accident. +# Add new behavior in a new NN-name.sh file alongside this one. + +# A throwaway CLAUDE_PROJECT_DIR isolates state-file/lock-file hooks +# (pre-commit-verification, stop-validator, pre-tool-use-validator all read +# or write .claude/hooks/.state or .locks under it) from this repo's own, +# genuinely-dirty-during-development working tree. +_fresh_dir() { mktemp -d "${TMPDIR:-/tmp}/hook-test-fixture.XXXXXX"; } + +# --- pre-commit-verification.sh: advisory on git commit -------------------- +commit_dir=$(_fresh_dir) +run_case \ + "pre-commit-verification: git commit emits advisory additionalContext" \ + ".claude/hooks/pre-commit-verification.sh" \ + "$(cat <<'JSON' +{"tool_name":"Bash","tool_input":{"command":"git commit -m \"wip\""}} +JSON +)" \ + "CLAUDE_PROJECT_DIR=$commit_dir" \ + "stdout-contains:PRE-COMMIT VERIFICATION REQUIRED" + +# --- pre-push-main-blocker.sh: explicit push to main is denied ------------- +run_case \ + "pre-push-main-blocker: explicit 'git push origin main' denied" \ + ".claude/hooks/pre-push-main-blocker.sh" \ + "$(cat <<'JSON' +{"tool_name":"Bash","tool_input":{"command":"git push origin main"}} +JSON +)" \ + "" \ + "deny-json" + +# --- stop-validator.sh: silent on a clean tree ------------------------------ +clean_repo=$(_fresh_dir) +git -C "$clean_repo" init -q >/dev/null 2>&1 +git -C "$clean_repo" -c user.email=test@example.com -c user.name=test \ + commit -q --allow-empty -m init >/dev/null 2>&1 +run_case \ + "stop-validator: silent (exit 0, no output) on a clean tree" \ + ".claude/hooks/stop-validator.sh" \ + "$(cat <<'JSON' +{"session_id":"test-session","stop_hook_active":false} +JSON +)" \ + "CLAUDE_PROJECT_DIR=$clean_repo" \ + "exit0-silent" + +# --- pre-tool-use-validator.sh: secret-shaped content asks; plain content allows +ptu_dir=$(_fresh_dir) +# Split across two vars so this fixture file's own text never contains a +# contiguous AWS-key-shaped string (would false-positive this repo's own +# Trivy secret-scan CI job). +_akid_prefix="AKIA" +_akid_suffix="TESTTESTTESTTEST" +run_case \ + "pre-tool-use-validator: Write with AWS-key-shaped content asks" \ + ".claude/hooks/pre-tool-use-validator.sh" \ + "$(cat <find fix -- +ssl_dir=$(_fresh_dir) +mkdir -p "$ssl_dir/.claude/hooks/.state" +echo '{}' > "$ssl_dir/.claude/hooks/.state/session_aaaaaaaa.json" +run_case \ + "session-start-loader: counts multiple active-agent session files (SWARM STATUS)" \ + ".claude/hooks/session-start-loader.sh" \ + "$(cat <<'JSON' +{"source":"startup","session_id":"bbbbbbbb-0000-0000-0000-000000000000"} +JSON +)" \ + "CLAUDE_PROJECT_DIR=$ssl_dir" \ + "stdout-contains:Active agents in project: 2" + +# --- fail-open: with jq absent, every hook this plan touches exits 0, silently +nojq_path=$(path_without_jq) +for hook in \ + ".claude/hooks/pre-commit-verification.sh" \ + ".claude/hooks/pre-push-main-blocker.sh" \ + ".claude/hooks/stop-validator.sh" \ + ".claude/hooks/pre-tool-use-validator.sh" \ + ".claude/hooks/session-start-loader.sh" +do + run_case \ + "fail-open: $hook exits 0 silently with jq absent" \ + "$hook" \ + '{}' \ + "PATH=$nojq_path" \ + "exit0-silent" +done diff --git a/scripts/test-hooks.sh b/scripts/test-hooks.sh new file mode 100755 index 0000000..a1b0018 --- /dev/null +++ b/scripts/test-hooks.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# scripts/test-hooks.sh — table-driven behavior runner for .claude/hooks/*.sh. +# +# Every behavioral claim about a hook (deny / ask / silent-allow / advisory +# text / fail-open when jq is absent) gets a case here instead of living only +# in a comment or a plan doc. Extend this harness by adding a new +# scripts/hook-tests.d/NN-name.sh file that calls run_case (below) — never by +# editing this file's flow (open/closed). Mirrors check-invariants.sh's own +# report()/PASS-FAIL idiom so both harnesses read the same way. +# +# Usage: ./scripts/test-hooks.sh # run every case +# SKIP="" ./scripts/test-hooks.sh # skip one case +# +# Exits non-zero if any non-skipped case fails. + +# shellcheck disable=SC2329 # report/skipped/run_case/path_without_jq are a library +# for scripts/hook-tests.d/*.sh, called only after being source'd at runtime — invisible +# to shellcheck's static reachability analysis of this file in isolation. +set -u +cd "$(git rev-parse --show-toplevel 2>/dev/null || echo .)" || exit 1 +FAIL=0 +SKIP="${SKIP:-}" +CASES_DIR="scripts/hook-tests.d" + +# Resolved once, via the harness's own unmodified PATH — a case's env_override +# (below) must only change what the *hook* sees (e.g. no jq reachable), never +# whether this harness can still find bash to run it. +BASH_BIN=$(command -v bash) + +skipped() { case " $SKIP " in *" $1 "*) return 0;; *) return 1;; esac; } +report() { # $1=case $2=status(0 pass) $3=detail + if skipped "$1"; then echo "SKIP $1"; return; fi + if [ "$2" -eq 0 ]; then echo "PASS $1"; else echo "FAIL $1 — ${3:-}"; FAIL=1; fi +} + +# path_without_jq: symlinks every executable on PATH into a fresh temp dir, +# skipping jq itself. Pass the result as a case's env_override +# ("PATH=$(path_without_jq)") to prove a hook's fail-open path when jq is +# genuinely unreachable — not just "we didn't call it". First match wins (no +# -f on the symlink) so ordinary PATH precedence is preserved across dupes. +path_without_jq() { + local shim dir base name + shim=$(mktemp -d "${TMPDIR:-/tmp}/hook-test-nojq.XXXXXX") + local -a dirs + IFS=':' read -ra dirs <<< "$PATH" + for dir in "${dirs[@]}"; do + [ -d "$dir" ] || continue + for base in "$dir"/*; do + [ -x "$base" ] || continue + name=$(basename "$base") + [ "$name" = "jq" ] && continue + ln -s "$base" "$shim/$name" 2>/dev/null + done + done + printf '%s' "$shim" +} + +# run_case: the harness's one extension point. +# $1 name human-readable case name (also the SKIP key) +# $2 hook hook path, repo-root-relative (e.g. .claude/hooks/x.sh) +# $3 stdin_json JSON payload piped to the hook's stdin (build via heredoc) +# $4 env_override optional "KEY=VAL KEY2=VAL2" pairs (env(1) syntax), or "" +# to inherit the harness's own environment unchanged +# $5 expectation one of: +# deny-json permissionDecision:"deny" present +# ask-json permissionDecision:"ask" present +# exit0-silent exit 0 AND empty stdout +# stdout-empty empty stdout (exit code ignored) +# stdout-contains:SUB stdout contains literal SUB +run_case() { + local name="$1" hook="$2" stdin_json="$3" env_override="$4" expectation="$5" + local out rc ok=1 reason="" + # shellcheck disable=SC2086 # deliberate word-split: "KEY=VAL ..." pairs for env(1), or empty + out=$(printf '%s' "$stdin_json" | env $env_override "$BASH_BIN" "$hook" 2>/dev/null) + rc=$? + case "$expectation" in + deny-json) + printf '%s' "$out" | grep -qE '"permissionDecision"[[:space:]]*:[[:space:]]*"deny"' && ok=0 ;; + ask-json) + printf '%s' "$out" | grep -qE '"permissionDecision"[[:space:]]*:[[:space:]]*"ask"' && ok=0 ;; + exit0-silent) + [ "$rc" -eq 0 ] && [ -z "$out" ] && ok=0 ;; + stdout-empty) + [ -z "$out" ] && ok=0 ;; + stdout-contains:*) + printf '%s' "$out" | grep -qF -- "${expectation#stdout-contains:}" && ok=0 ;; + *) + reason="unrecognized expectation '$expectation'" ;; + esac + reason="${reason:-exit=$rc output=$(printf '%s' "$out" | tr '\n' ' ' | cut -c1-200)}" + report "$name" "$ok" "$reason" +} + +# Auto-source every case file — adding cases never means editing this file. +if [ -d "$CASES_DIR" ]; then + for f in "$CASES_DIR"/*.sh; do + [ -f "$f" ] || continue + # shellcheck source=/dev/null + source "$f" + done +else + echo "FAIL hook-tests.d missing: $CASES_DIR" + FAIL=1 +fi + +echo "" +[ "$FAIL" -eq 0 ] && echo "ALL HOOK TESTS GREEN" || echo "HOOK TEST FAILURES PRESENT" +exit "$FAIL" From fc1a7de5a671c4bb55bb3a123a56261f3b374c9e Mon Sep 17 00:00:00 2001 From: Jordan Winters Date: Fri, 24 Jul 2026 09:47:22 -0500 Subject: [PATCH 2/2] fix(ci): suppress SC2317 version-skew false positive in hook-test runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI runners ship a newer shellcheck that reports the sourced-invoked library functions as SC2317 (unreachable) where local versions report SC2329 (never invoked) — same false positive, different finding code. Scoped to the one file whose functions are called only from dynamically sourced case files; the hook-tests job itself was already green. Co-Authored-By: Claude Fable 5 --- scripts/test-hooks.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/test-hooks.sh b/scripts/test-hooks.sh index a1b0018..916ba13 100755 --- a/scripts/test-hooks.sh +++ b/scripts/test-hooks.sh @@ -13,9 +13,11 @@ # # Exits non-zero if any non-skipped case fails. -# shellcheck disable=SC2329 # report/skipped/run_case/path_without_jq are a library +# shellcheck disable=SC2329,SC2317 # report/skipped/run_case/path_without_jq are a library # for scripts/hook-tests.d/*.sh, called only after being source'd at runtime — invisible -# to shellcheck's static reachability analysis of this file in isolation. +# to shellcheck's static reachability analysis of this file in isolation. Both codes are +# the same false positive; which one fires depends on the shellcheck version (SC2329 +# "never invoked" locally, SC2317 "unreachable" on newer CI runners). set -u cd "$(git rev-parse --show-toplevel 2>/dev/null || echo .)" || exit 1 FAIL=0