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
46 changes: 46 additions & 0 deletions .claude/hooks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Claude Code hooks

Repo-committed hooks so they travel to every checkout. Registered in [`.claude/settings.json`](../settings.json) once a human has reviewed them (harness-safety: a hook lands in `settings.json` only after review, because a bad hook can lock out the tools that would fix it).

They **fail open**: any missing tool, unparseable input, or internal error is a no-op, never a false block. The only actions any hook takes are the narrow, high-confidence cases, each with an announced environment-variable override. Every gate is provable without a running app by a committed `*.tests.sh` matrix that drives it against a throwaway `CLAUDE_PROJECT_DIR`; run the matrix after any edit to the gate.

NetPace keeps both production and `*.Tests` projects under `src/`, so the filesystem-reading gates scan `src/` (there is no top-level `tests/`).

## `no-skipped-tests.sh` — skip-family ban (Constitution §X)

PreToolUse(Bash) gate that blocks a `git commit` while any banned skipped-test construct exists under `src/`: `Skip.If/IfNot/Always/Unless`, `Assert.Skip`, `[Fact/Theory(Skip=…)]`, `[SkippableFact/Theory]`, `SkipException`, and xUnit v3's `SkipUnless=`/`SkipWhen=`. Also exposes `--check` for CI/manual scans (the audit ignores the override, so a leaked env var can never silence it). Fails **closed** once a command is classified as a commit — a gate that can't scan must not read as clean — and waves every non-commit Bash call straight through. Override: `NETPACE_ALLOW_SKIPS=1` (announced on stderr). Promotes Constitution §X into a gate rather than a rule the agent must remember.

```bash
.claude/hooks/no-skipped-tests.sh --check # scan src/, exit 1 on any banned construct
.claude/hooks/no-skipped-tests.tests.sh # synthetic-sandbox matrix — non-zero on failure
```

## `green-gate.sh` — `dotnet test --no-build` staleness guard

PreToolUse(Bash) gate that denies `dotnet test --no-build` when it would report misleading results: no test assembly has been built yet, or a `*.cs` under `src/` is newer than the newest built `*.Tests.dll`. Either way `--no-build` would run a stale or absent assembly. Promotes [`feedback_dotnet_test_no_build`](../memory/feedback_dotnet_test_no_build.md).

**Command detection:** the `dotnet test` matcher fires only when it is the actual command — after stripping benign `cd …&&` / `export …&&` / env-assignment / `rtk` prefixes — not when the string merely appears inside a commit message, an `echo`, or quoted data. `--no-build` must be a flag of the `dotnet test` invocation itself, not a substring in a chained command. The staleness scan ignores generated `obj/`/`bin/` `.cs` so an unrelated restore or build can't falsely fire.

**Fail open.** Any missing tool, unparseable input, or non-`dotnet test` command is a no-op. It is wired without an `if`, so the script's own prefix-stripping does the filtering and a prefix-wrapped `cd repo && dotnet test --no-build` is still caught. Override: `NETPACE_SKIP_GREEN_GATE=1` (announced on stderr).

```bash
.claude/hooks/green-gate.tests.sh # synthetic-JSON matrix — non-zero on failure
```

## `traceability-gate.sh` — AC↔marker traceability gate (Constitution §VIII)

Stop hook enforcing the two exact-match edges of the §VIII traceability chain — spec.md `**Scenario: X**` label → test-plan.md `#### Scenario: X` header → test `// SCENARIO: X` marker under `src/`. It checks the edges a machine can decide; the judgment checks (fuzzy match, mock self-satisfaction, trivially-passing bodies, undocumented-test detection) stay in `/speckit.testchecklist`.

| Edge | Rule | Direction |
|------|------|-----------|
| **spec ⟷ test-plan** | every `**Scenario: X**` label has exactly one matching `#### Scenario: X` header, and vice versa — a repeated name on either side is flagged (the label is a unique §VIII key) | bijection |
| **test-plan → code** | every `#### Scenario: X` header has ≥1 matching `// SCENARIO: X` marker under `src/` (generated `obj/`/`bin/` copies excluded) | coverage only |

**Scope — active specs only.** The gate reads `specs/*/spec.md`. Merged features have their specs deleted (leaving only drifted markers behind), so a repo with no in-flight feature — the steady state — is a clean no-op. The test-plan→code edge is deliberately **directional**: a marker with no plan scenario is not flagged, because `src/` accumulates markers from already-merged features whose specs are gone. "Undocumented test" is a judgment left to `/speckit.testchecklist`.

**Staged fail-open:** a spec still being authored never blocks. No `test-plan.md`, or a plan with no scenarios yet → no-op. Plan scenarios present but zero have a marker → pre-implementation, so the coverage edge is skipped (only the spec⟷plan edge runs). Once any scenario has a marker, all must. It is loop-guarded (`stop_hook_active`), so it nudges at most once per turn and can never hard-lock. Override: `NETPACE_SKIP_TRACEABILITY_GATE=1` (announced on stderr).

```bash
.claude/hooks/traceability-gate.sh --check [specdir] # report + exit 1 on any mismatch (CI/manual)
.claude/hooks/traceability-gate.tests.sh # synthetic-fixture matrix — non-zero on failure
```
119 changes: 119 additions & 0 deletions .claude/hooks/green-gate.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
#!/usr/bin/env bash
#
# green-gate.sh — `dotnet test --no-build` staleness guard.
#
# A single PreToolUse(Bash) gate: deny `dotnet test --no-build` when no test assembly has
# been built yet, or when a source is newer than the built assembly — either way a
# --no-build run would execute a stale/absent DLL and report misleading results. Promotes
# feedback_dotnet_test_no_build.
#
# SCOPE. This gate does exactly one thing: deny a `dotnet test --no-build` that would run a
# stale or absent test assembly. The "tests are green before a PR" guarantee lives elsewhere —
# a real whole-suite run inside the human-invoked `/ship` command, not in this hook.
#
# DESIGN RULE: fail OPEN. Any missing tool, unparseable input, or internal error exits
# 0 (no objection). The one action taken (the --no-build deny) is the narrow, high-confidence case
# only; every uncertain path defaults to allow, because a guard that falsely blocks is
# worse than no guard — and, for a harness we edit with itself, a false block can lock out
# the very tools that would fix it.
#
# Escape hatch (harness-safety: override-first, then tighten): set NETPACE_SKIP_GREEN_GATE=1
# to make the whole script a no-op. It announces itself on stderr so it can never be
# silently in effect.

set -uo pipefail

# --- override-first escape hatch — announced, never silent ------------------------
if [ "${NETPACE_SKIP_GREEN_GATE:-}" = "1" ]; then
echo "green-gate: WARNING — gate BYPASSED via NETPACE_SKIP_GREEN_GATE=1 (--no-build staleness deny NOT enforced)." >&2
exit 0
fi

# --- fail-open preconditions ------------------------------------------------------
command -v jq >/dev/null 2>&1 || exit 0
INPUT=$(cat 2>/dev/null) || exit 0
[ -n "$INPUT" ] || exit 0

jget() { printf '%s' "$INPUT" | jq -r "$1" 2>/dev/null; }

EVENT=$(jget '.hook_event_name // empty')
TOOL=$(jget '.tool_name // empty')

ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"

# Print the first source file newer than the reference file $1, or nothing. The source root
# whose edits invalidate a build is the .NET tree under src/ (*.cs) — where NetPace keeps both
# production and *.Tests projects; obj/ and bin/ (generated .cs) are excluded so an unrelated
# restore/build can't read as "our code changed". Only roots that exist are scanned; find needs
# the roots BEFORE the expression, and -quit stops at the first hit (cheap).
first_newer_than() {
local ref="$1" d roots=()
for d in src; do [ -d "$ROOT/$d" ] && roots+=("$ROOT/$d"); done
[ ${#roots[@]} -eq 0 ] && return 0
find "${roots[@]}" -name '*.cs' \
-not -path '*/obj/*' -not -path '*/bin/*' \
-newer "$ref" -print -quit 2>/dev/null
}

# Strip the known-benign LEADING prefixes so what remains begins with the real command:
# chained `cd …&&` / `export …&&`, env-assignments, an optional `rtk` wrapper. Regex can't
# parse shell, so we only ever strip these safe leaders — never anything that could hide a
# different command.
strip_cmd_prefixes() {
printf '%s' "$1" | sed -E '
s/^[[:space:]]+//
:a; s/^(cd|export)[[:space:]]+[^&]*&&[[:space:]]*//; ta
:b; s/^[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+//; tb
s/^rtk[[:space:]]+//
'
}

# True only when `dotnet test` is the actual command being run — not text inside an
# argument (a commit message, an echo string, quoted data that merely mentions it). After
# prefix-stripping, require what remains to START with `dotnet test` at a word boundary
# (so `dotnet testfoo` and `git commit -m "…dotnet test…"` do not match).
is_dotnet_test() {
strip_cmd_prefixes "$1" | grep -Eq '^dotnet[[:space:]]+test([[:space:]]|$)'
}

# Echo only the leading `dotnet test …` invocation — the segment up to the first chained
# command (&&, ||, ;, |, &) — so a `--no-build` sitting in a trailing `echo` or a quoted
# argument can't be mistaken for a flag belonging to `dotnet test`. Empty if not a real
# `dotnet test` run.
dotnet_test_invocation() {
is_dotnet_test "$1" || return 0
strip_cmd_prefixes "$1" | sed -E 's/[[:space:]]*(&&|\|\||[;&|]).*$//'
}

emit_pre_deny() { # reason
jq -n --arg r "$1" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}'
exit 0
}

case "$EVENT" in
# ---------------------------------------------------------------------------------
# deny `dotnet test --no-build` against a stale build.
PreToolUse)
[ "$TOOL" = "Bash" ] || exit 0
CMD=$(jget '.tool_input.command // empty')
INVOKE=$(dotnet_test_invocation "$CMD")
[ -n "$INVOKE" ] || exit 0
# --no-build must be a flag of the `dotnet test` invocation itself, not a substring in a
# chained command or a quoted argument — check the extracted invocation, not raw $CMD.
printf '%s' "$INVOKE" | grep -Eq -- '--no-build' || exit 0

# Newest built test assembly is the reference "last build".
newest_dll=$(find "$ROOT/src" -path '*/bin/*' -name '*.Tests.dll' -printf '%T@ %p\n' 2>/dev/null \
| sort -n | tail -1 | cut -d' ' -f2-)
if [ -z "$newest_dll" ]; then
emit_pre_deny "No built test assemblies found, but --no-build was requested. Build first (drop --no-build), then re-run. (feedback_dotnet_test_no_build)"
fi
newer=$(first_newer_than "$newest_dll")
if [ -n "$newer" ]; then
emit_pre_deny "Source changed since the last build (e.g. ${newer#"$ROOT"/}). 'dotnet test --no-build' would run a STALE assembly and report misleading results. Rebuild first (drop --no-build). (feedback_dotnet_test_no_build)"
fi
exit 0
;;
esac

exit 0
59 changes: 59 additions & 0 deletions .claude/hooks/green-gate.tests.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
#
# green-gate.tests.sh — standalone synthetic-JSON test matrix for green-gate.sh.
#
# The hook is a single PreToolUse(Bash) gate, so every branch is provable by piping
# synthetic hook JSON — no dev stack required. This script drives all branches against a
# throwaway CLAUDE_PROJECT_DIR so the real repo is never touched, and exits non-zero on any
# failure. Run it after any edit to the hook.
#
# (History: the hook once also carried PostToolUse ledger-stamping and a Stop completion
# gate; both were retired in issue #122 when test-green enforcement moved to `/ship`. Those
# cases are gone from this matrix; only the marker-independent B7 --no-build deny remains.)
#
# Usage: .claude/hooks/green-gate.tests.sh

set -uo pipefail

HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HOOK="$HERE/green-gate.sh"

SB="$(mktemp -d)"
trap 'rm -rf "$SB"' EXIT
export CLAUDE_PROJECT_DIR="$SB"
mkdir -p "$SB/src/Svc" "$SB/src/Svc.Tests/bin/Debug"
DLL="$SB/src/Svc.Tests/bin/Debug/Svc.Tests.dll"

pass=0; fail=0
run() { OUTPUT="$(printf '%s' "$1" | "$HOOK" 2>/dev/null)"; RC=$?; }
ok() { if eval "$2"; then echo " ok $1"; pass=$((pass+1)); else echo " FAIL $1 -- got:[$OUTPUT] rc=$RC"; fail=$((fail+1)); fi; }

# Synthetic-payload builder (keep the real hook schema in one place).
pre() { printf '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":%s}}' "$(jq -Rn --arg c "$1" '$c')"; }

echo "PreToolUse / B7 (--no-build staleness):"
run "$(printf '{"hook_event_name":"PreToolUse","tool_name":"Edit","tool_input":{"file_path":"x.cs"}}')"; ok "non-Bash tool → allow" '[ -z "$OUTPUT" ] && [ "$RC" = 0 ]'
run "$(pre 'echo dotnet test --no-build')"; ok "echo mention → allow" '[ -z "$OUTPUT" ]'
run "$(pre 'dotnet test')"; ok "dotnet test (build) → allow" '[ -z "$OUTPUT" ]'
run "$(pre 'dotnet test --no-build')"; ok "no dll → deny" 'echo "$OUTPUT"|jq -e ".hookSpecificOutput.permissionDecision==\"deny\"">/dev/null'
touch "$SB/src/Svc/A.cs"; sleep 0.05; : > "$DLL"
run "$(pre 'dotnet test --no-build')"; ok "fresh build → allow" '[ -z "$OUTPUT" ]'
sleep 0.05; touch "$SB/src/Svc/A.cs"
run "$(pre 'dotnet test --no-build')"; ok "stale source → deny" 'echo "$OUTPUT"|jq -e ".hookSpecificOutput.permissionDecision==\"deny\"">/dev/null'
run "$(pre 'cd /repo && dotnet test --no-build')"; ok "prefix cd&& stale → deny" 'echo "$OUTPUT"|jq -e ".hookSpecificOutput.permissionDecision==\"deny\"">/dev/null'
run "$(pre 'git commit -m "run dotnet test --no-build later"')"; ok "commit msg mention → allow" '[ -z "$OUTPUT" ]'
# regression: --no-build in a chained command must NOT deny a plain `dotnet test` (fresh build)
sleep 0.05; : > "$DLL"
run "$(pre 'dotnet test --filter X && echo try --no-build next')"; ok "--no-build in chained echo → allow" '[ -z "$OUTPUT" ]'
# a generated obj/ source newer than the DLL must NOT deny (only real *.cs under src/ count)
sleep 0.05; mkdir -p "$SB/src/Svc/obj"; touch "$SB/src/Svc/obj/Gen.cs"
run "$(pre 'dotnet test --no-build')"; ok "newer obj/*.cs ignored → allow" '[ -z "$OUTPUT" ]'

echo "fail-open / override:"
run ''; ok "empty stdin → allow" '[ "$RC" = 0 ] && [ -z "$OUTPUT" ]'
OUTPUT="$(printf '%s' "$(pre 'dotnet test --no-build')" | NETPACE_SKIP_GREEN_GATE=1 "$HOOK" 2>"$SB/err")"; RC=$?
ok "NETPACE_SKIP_GREEN_GATE=1 → no-op + warns" '[ -z "$OUTPUT" ] && grep -q BYPASSED "$SB/err"'

echo ""
echo "RESULT: $pass passed, $fail failed"
[ "$fail" = 0 ]
Loading
Loading