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
108 changes: 108 additions & 0 deletions .claude/hooks/gate-lib.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# .claude/hooks/gate-lib.sh — shared quality-gate detection library.
#
# Single responsibility (artifacts/plan_framework_hardening.md, unit U5a):
# given a project directory, detect which quality gates apply and emit, per
# detected gate, an INVOCABLE command string plus the short human label the
# pre-commit advisory has always shown. Detection lives here once; each
# caller decides what to DO with a gate — today, pre-commit-verification.sh
# only reads the labels for its advisory text; a later enforcement hook can
# read GATE_COMMANDS to actually run them (open/closed — plan's Design
# Principles).
#
# Usage:
# # shellcheck source=gate-lib.sh
# source ".../gate-lib.sh"
# gate_lib_detect "$PROJECT_DIR"
# # then read the parallel arrays it populates:
# # GATE_LABELS[i] human label, unchanged wording, e.g. "lint(pnpm)"
# # GATE_COMMANDS[i] invocable command, e.g. "pnpm run lint"
#
# Adapting: add a stack by appending one `if [ -f "$dir/<manifest>" ]` block
# with its own `_gate_add` calls — never by editing an existing block.
#
# Sourced, not executed directly — deliberately does not `set -u` itself
# (that would silently change the sourcing script's shell options too).
# Every variable below is defensively initialized so this stays correct
# under a caller's own `set -u` regardless.

GATE_LABELS=()
GATE_COMMANDS=()

_gate_add() { # $1=label $2=command
GATE_LABELS+=("$1")
GATE_COMMANDS+=("$2")
}

# gate_lib_detect DIR — populate GATE_LABELS/GATE_COMMANDS for DIR.
# Re-runnable: resets both arrays on every call.
gate_lib_detect() {
local dir="${1:?gate_lib_detect: project dir required}"
GATE_LABELS=()
GATE_COMMANDS=()

# TypeScript/JavaScript (pnpm preferred per tech strategy)
if [ -f "$dir/package.json" ]; then
local pkg_mgr
if [ -f "$dir/pnpm-lock.yaml" ]; then
pkg_mgr="pnpm"
elif [ -f "$dir/package-lock.json" ]; then
pkg_mgr="npm"
else
pkg_mgr="pnpm"
fi

if grep -q '"lint"' "$dir/package.json" 2>/dev/null; then
_gate_add "lint($pkg_mgr)" "$pkg_mgr run lint"
fi
if grep -q '"test"' "$dir/package.json" 2>/dev/null; then
_gate_add "test($pkg_mgr)" "$pkg_mgr test"
fi
if grep -q '"typecheck\|"tsc\|"type-check"' "$dir/package.json" 2>/dev/null; then
_gate_add "typecheck($pkg_mgr)" "$pkg_mgr run typecheck"
fi

# Biome (preferred per tech strategy) — its own invocable check,
# independent of whether a package.json "lint" script also exists.
if [ -f "$dir/biome.json" ] || [ -f "$dir/biome.jsonc" ]; then
_gate_add "biome" "$pkg_mgr exec biome check ."
fi
fi

# Python (uv preferred per tech strategy)
if [ -f "$dir/pyproject.toml" ]; then
local py_run
if command -v uv >/dev/null 2>&1; then
py_run="uv run"
else
py_run="python -m"
fi

if grep -q "ruff" "$dir/pyproject.toml" 2>/dev/null; then
_gate_add "ruff" "$py_run ruff check ."
fi
if grep -q "pytest" "$dir/pyproject.toml" 2>/dev/null; then
_gate_add "pytest" "$py_run pytest"
fi
if grep -q "mypy" "$dir/pyproject.toml" 2>/dev/null; then
_gate_add "mypy" "$py_run mypy ."
fi
fi

# Go
if [ -f "$dir/go.mod" ]; then
_gate_add "go-test" "go test ./..."
_gate_add "go-vet" "go vet ./..."

if command -v golangci-lint >/dev/null 2>&1 || [ -f "$dir/.golangci.yml" ]; then
_gate_add "golangci-lint" "golangci-lint run"
fi
fi

# Rust
if [ -f "$dir/Cargo.toml" ]; then
_gate_add "cargo-test" "cargo test"
_gate_add "cargo-clippy" "cargo clippy"
_gate_add "cargo-fmt" "cargo fmt --check"
fi
}
76 changes: 15 additions & 61 deletions .claude/hooks/pre-commit-verification.sh
Original file line number Diff line number Diff line change
Expand Up @@ -41,68 +41,22 @@ if [ -f "$VERIFICATION_FILE" ]; then
fi
fi

# Detect project type and available tools
DETECTED_TOOLS=""

# TypeScript/JavaScript (pnpm preferred per tech strategy)
if [ -f "$PROJECT_DIR/package.json" ]; then
if [ -f "$PROJECT_DIR/pnpm-lock.yaml" ]; then
PKG_MGR="pnpm"
elif [ -f "$PROJECT_DIR/package-lock.json" ]; then
PKG_MGR="npm"
else
PKG_MGR="pnpm"
fi

# Check for scripts in package.json
if grep -q '"lint"' "$PROJECT_DIR/package.json" 2>/dev/null; then
DETECTED_TOOLS="$DETECTED_TOOLS lint($PKG_MGR)"
fi
if grep -q '"test"' "$PROJECT_DIR/package.json" 2>/dev/null; then
DETECTED_TOOLS="$DETECTED_TOOLS test($PKG_MGR)"
fi
if grep -q '"typecheck\|"tsc\|"type-check"' "$PROJECT_DIR/package.json" 2>/dev/null; then
DETECTED_TOOLS="$DETECTED_TOOLS typecheck($PKG_MGR)"
fi

# Biome (preferred per tech strategy)
if [ -f "$PROJECT_DIR/biome.json" ] || [ -f "$PROJECT_DIR/biome.jsonc" ]; then
DETECTED_TOOLS="$DETECTED_TOOLS biome"
fi
fi

# Python (uv preferred per tech strategy)
if [ -f "$PROJECT_DIR/pyproject.toml" ]; then
# Ruff (preferred per tech strategy)
if grep -q "ruff" "$PROJECT_DIR/pyproject.toml" 2>/dev/null; then
DETECTED_TOOLS="$DETECTED_TOOLS ruff"
fi
# Detect project type and available gates — detection lives in gate-lib.sh
# (artifacts/plan_framework_hardening.md, unit U5a): one shared function
# emits invocable commands per gate; this hook only needs the human labels
# for its advisory text below, reconstructed here in the same order and
# format ("<label>" tokens space-joined) as before the extraction, so the
# advisory output stays byte-identical.
# shellcheck source=gate-lib.sh
# shellcheck disable=SC1091 # dynamic path (BASH_SOURCE-relative); the
# above source= directive documents it for anyone re-running with `-x`
source "$(dirname "${BASH_SOURCE[0]}")/gate-lib.sh"
gate_lib_detect "$PROJECT_DIR"

# pytest
if grep -q "pytest" "$PROJECT_DIR/pyproject.toml" 2>/dev/null; then
DETECTED_TOOLS="$DETECTED_TOOLS pytest"
fi

# mypy
if grep -q "mypy" "$PROJECT_DIR/pyproject.toml" 2>/dev/null; then
DETECTED_TOOLS="$DETECTED_TOOLS mypy"
fi
fi

# Go
if [ -f "$PROJECT_DIR/go.mod" ]; then
DETECTED_TOOLS="$DETECTED_TOOLS go-test go-vet"

# golangci-lint (preferred per tech strategy)
if command -v golangci-lint &> /dev/null || [ -f "$PROJECT_DIR/.golangci.yml" ]; then
DETECTED_TOOLS="$DETECTED_TOOLS golangci-lint"
fi
fi

# Rust
if [ -f "$PROJECT_DIR/Cargo.toml" ]; then
DETECTED_TOOLS="$DETECTED_TOOLS cargo-test cargo-clippy cargo-fmt"
fi
DETECTED_TOOLS=""
for _gate_label in "${GATE_LABELS[@]}"; do
DETECTED_TOOLS="$DETECTED_TOOLS $_gate_label"
done

# Build verification context message
cat << EOF
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- New `rules-lines` CI invariant (check #22, `scripts/check-invariants.sh`): sums `.claude/rules/*.md` line counts, excluding any file whose frontmatter carries a `paths:` key (load-on-demand, not always-loaded), against a 500-line budget — measured 409 lines at implementation time. `docs/customization.md`'s "Adding a Rule" section gains a three-tier table (always-loaded rule / `paths:`-scoped rule / skill) documenting the budget and native `paths:` frontmatter mechanism (unit U8)
- `.claude/rules/hooks-conventions.md`: the framework's first `paths:`-scoped rule (`.claude/hooks/**`, `scripts/**`), dogfooding the tier documented in U8 — shell baseline (`set -u`, shellcheck/`bash -n`, ≤~120 LOC), the fail-open-visibly pattern, the field-scoped `sed` stdin-extraction idiom (citing `branch-pr-discipline.sh`), and the deny/ask JSON output contract. Excluded from `rules-lines`' budget by design (verified: 409 lines counted with the `paths:` frontmatter intact vs. 473 if it were stripped). `docs/customization.md` links it as the tier table's worked example and notes stack packs may use the same mechanism (unit U12)
- REVIEW.md freshness contract: `review-steering/SKILL.md`'s generation workflow gains step 8, stamping REVIEW.md's final line with `<!-- rules-hash: <hash> -->` (`cat .claude/rules/code-quality.md .claude/rules/security.md | shasum -a 256 | cut -d' ' -f1`); its Refresh Discipline section now cites the mechanical check instead of an unenforced "must never contradict" assertion. New `review-freshness` CI invariant (check #23, `scripts/check-invariants.sh`) recomputes and compares the hash for a tracked root `REVIEW.md`, failing on a missing or stale footer; skips cleanly (this repo ships no REVIEW.md today) when none is tracked (unit U6)
- `.claude/hooks/gate-lib.sh`: new shared stack-detection library — one `gate_lib_detect` function replaces `pre-commit-verification.sh`'s inline per-stack block, emitting an INVOCABLE command (e.g. `pnpm run lint`, `uv run pytest`, `go test ./...`, `cargo clippy`) alongside the exact human label the pre-commit advisory already showed, per detected TS/JS (package-manager-aware)/Python/Go/Rust gate. `pre-commit-verification.sh` now sources the lib and reconstructs its advisory text from the labels — verified byte-identical stdout across TS/npm/Python/Go/Rust fixtures pre- and post-refactor; pure structure, zero behavior change (Two Hats). Lib's `test(pnpm)` command smoke-tested against `scripts/fixtures/failing-project/`, confirmed nonzero (unit U5a)

## [4.0.0] - 2026-07-23

Expand Down
Loading