From 079748b2d7b81d098379c71258d19b31cf974d97 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 8 Mar 2026 21:11:48 -0600 Subject: [PATCH 1/4] feat: add pre-commit hooks for cycles, dead exports, signature warnings, and PR body guard Add four Claude Code hooks that run on git commit: - check-commit.sh: combined cycle detection (blocking) + signature change warning (informational). Runs checkData() once with both predicates in a single Node.js invocation, skipping boundaries for speed. Cycles only block if they involve session-edited files. Signature warnings are enriched with role-based risk levels and transitive caller counts via hoisted db.prepare() statements. - check-dead-exports.sh: blocks commits when staged src/ files (edited in this session) contain exports with zero consumers. Batches all files in one Node.js process with a single DB open. - guard-pr-body.sh: blocks PR creation if the body contains "generated with" (case-insensitive). Performance: previous design ran checkData() twice (once for cycles, once for signatures) and spawned per-file CLI processes for dead exports. This version cuts commit hook overhead from ~60s to ~20s by combining predicates and batching DB access. --- .claude/hooks/check-commit.sh | 174 +++++++++++++++++++++++++++ .claude/hooks/check-dead-exports.sh | 107 ++++++++++++++++ .claude/hooks/guard-pr-body.sh | 24 ++++ .claude/settings.json | 15 +++ docs/guides/recommended-practices.md | 7 ++ src/cli.js | 4 +- 6 files changed, 329 insertions(+), 2 deletions(-) create mode 100644 .claude/hooks/check-commit.sh create mode 100644 .claude/hooks/check-dead-exports.sh create mode 100644 .claude/hooks/guard-pr-body.sh diff --git a/.claude/hooks/check-commit.sh b/.claude/hooks/check-commit.sh new file mode 100644 index 00000000..f55660d4 --- /dev/null +++ b/.claude/hooks/check-commit.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +# check-commit.sh — PreToolUse hook for Bash (git commit) +# Combined cycle-detection (blocking) + signature-change warning (informational). +# Runs checkData() ONCE with both predicates, single DB connection. + +set -euo pipefail + +INPUT=$(cat) + +# Extract the command from tool_input JSON +COMMAND=$(echo "$INPUT" | node -e " + let d=''; + process.stdin.on('data',c=>d+=c); + process.stdin.on('end',()=>{ + const p=JSON.parse(d).tool_input?.command||''; + if(p)process.stdout.write(p); + }); +" 2>/dev/null) || true + +if [ -z "$COMMAND" ]; then + exit 0 +fi + +# Only trigger on git commit commands +if ! echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+commit\b'; then + exit 0 +fi + +# Guard: codegraph DB must exist +WORK_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || WORK_ROOT="${CLAUDE_PROJECT_DIR:-.}" +if [ ! -f "$WORK_ROOT/.codegraph/graph.db" ]; then + exit 0 +fi + +# Guard: must have staged changes +STAGED=$(git diff --cached --name-only 2>/dev/null) || true +if [ -z "$STAGED" ]; then + exit 0 +fi + +# Load session edit log for cycle scoping +LOG_FILE="$WORK_ROOT/.claude/session-edits.log" +EDITED_FILES="" +if [ -f "$LOG_FILE" ] && [ -s "$LOG_FILE" ]; then + EDITED_FILES=$(awk '{print $2}' "$LOG_FILE" | sort -u) +fi + +# Single Node.js invocation: run checkData once, process both predicates +RESULT=$(node -e " + const path = require('path'); + const root = process.argv[1]; + const editedRaw = process.argv[2] || ''; + + const { checkData } = require(path.join(root, 'src/check.js')); + const { openReadonlyOrFail } = require(path.join(root, 'src/db.js')); + + // Run check with cycles + signatures only (skip boundaries for speed) + const data = checkData(undefined, { + staged: true, + noTests: true, + boundaries: false, + }); + + if (!data || data.error || !data.predicates) process.exit(0); + + const output = { action: 'allow' }; + + // ── Cycle check (blocking) ── + const cyclesPred = data.predicates.find(p => p.name === 'cycles'); + if (cyclesPred && !cyclesPred.passed && cyclesPred.cycles?.length) { + const edited = new Set(editedRaw.split('\n').filter(Boolean)); + // Only block if cycles involve files edited in this session + if (edited.size > 0) { + const relevant = cyclesPred.cycles.filter( + cycle => cycle.some(f => edited.has(f)) + ); + if (relevant.length > 0) { + const summary = relevant.slice(0, 5).map(c => c.join(' -> ')).join('\n '); + const extra = relevant.length > 5 ? '\n ... and ' + (relevant.length - 5) + ' more' : ''; + output.action = 'deny'; + output.reason = 'BLOCKED: Circular dependencies detected involving files you edited:\n ' + summary + extra + '\nFix the cycles before committing.'; + } + } + } + + // ── Signature warning (informational, never blocks) ── + const sigPred = data.predicates.find(p => p.name === 'signatures'); + if (sigPred && !sigPred.passed && sigPred.violations?.length) { + // Enrich with role + transitive caller count using a single DB connection + const db = openReadonlyOrFail(); + const stmtNode = db.prepare( + 'SELECT id, role FROM nodes WHERE name = ? AND file = ? AND line = ?' + ); + const stmtCallers = db.prepare( + 'SELECT DISTINCT n.id FROM edges e JOIN nodes n ON e.source_id = n.id WHERE e.target_id = ? AND e.kind = \\'calls\\'' + ); + + const lines = []; + for (const v of sigPred.violations) { + const node = stmtNode.get(v.name, v.file, v.line); + const role = node?.role || 'unknown'; + + let callerCount = 0; + if (node) { + const visited = new Set([node.id]); + let frontier = [node.id]; + for (let d = 0; d < 3; d++) { + const next = []; + for (const fid of frontier) { + for (const c of stmtCallers.all(fid)) { + if (!visited.has(c.id)) { + visited.add(c.id); + next.push(c.id); + callerCount++; + } + } + } + frontier = next; + if (!frontier.length) break; + } + } + + const risk = role === 'core' ? 'HIGH' : role === 'utility' ? 'MEDIUM' : 'low'; + lines.push(risk + ': ' + v.name + ' (' + v.kind + ') [' + role + '] at ' + v.file + ':' + v.line + ' — ' + callerCount + ' transitive callers'); + } + db.close(); + + if (lines.length > 0) { + output.sigWarning = lines.join('\n'); + } + } + + process.stdout.write(JSON.stringify(output)); +" "$WORK_ROOT" "$EDITED_FILES" 2>/dev/null) || true + +if [ -z "$RESULT" ]; then + exit 0 +fi + +ACTION=$(echo "$RESULT" | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{process.stdout.write(JSON.parse(d).action||'allow')}catch{process.stdout.write('allow')}})" 2>/dev/null) || ACTION="allow" + +if [ "$ACTION" = "deny" ]; then + REASON=$(echo "$RESULT" | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{process.stdout.write(JSON.parse(d).reason||'')}catch{}})" 2>/dev/null) || true + node -e " + console.log(JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: process.argv[1] + } + })); + " "$REASON" + exit 0 +fi + +# Signature warning (non-blocking) +SIG_WARNING=$(echo "$RESULT" | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const w=JSON.parse(d).sigWarning;if(w)process.stdout.write(w)}catch{}})" 2>/dev/null) || true + +if [ -n "$SIG_WARNING" ]; then + ESCAPED=$(printf '%s' "$SIG_WARNING" | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>process.stdout.write(JSON.stringify(d)))" 2>/dev/null) || true + if [ -n "$ESCAPED" ]; then + node -e " + console.log(JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'allow', + additionalContext: '[codegraph] Signature changes detected in staged files:\\n' + JSON.parse(process.argv[1]) + } + })); + " "$ESCAPED" 2>/dev/null || true + fi +fi + +exit 0 diff --git a/.claude/hooks/check-dead-exports.sh b/.claude/hooks/check-dead-exports.sh new file mode 100644 index 00000000..a5d66f78 --- /dev/null +++ b/.claude/hooks/check-dead-exports.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# check-dead-exports.sh — PreToolUse hook for Bash (git commit) +# Blocks commits if any src/ file edited in THIS SESSION has exports with zero consumers. +# Batches all files in a single Node.js invocation (one DB open) for speed. + +set -euo pipefail + +INPUT=$(cat) + +# Extract the command from tool_input JSON +COMMAND=$(echo "$INPUT" | node -e " + let d=''; + process.stdin.on('data',c=>d+=c); + process.stdin.on('end',()=>{ + const p=JSON.parse(d).tool_input?.command||''; + if(p)process.stdout.write(p); + }); +" 2>/dev/null) || true + +if [ -z "$COMMAND" ]; then + exit 0 +fi + +# Only trigger on git commit commands +if ! echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+commit\b'; then + exit 0 +fi + +# Guard: codegraph DB must exist +WORK_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || WORK_ROOT="${CLAUDE_PROJECT_DIR:-.}" +if [ ! -f "$WORK_ROOT/.codegraph/graph.db" ]; then + exit 0 +fi + +# Guard: must have staged changes +STAGED=$(git diff --cached --name-only 2>/dev/null) || true +if [ -z "$STAGED" ]; then + exit 0 +fi + +# Load session edit log to scope checks to files we actually edited +LOG_FILE="$WORK_ROOT/.claude/session-edits.log" +if [ ! -f "$LOG_FILE" ] || [ ! -s "$LOG_FILE" ]; then + exit 0 +fi +EDITED_FILES=$(awk '{print $2}' "$LOG_FILE" | sort -u) + +# Filter staged files to src/*.js that were edited in this session +FILES_TO_CHECK="" +while IFS= read -r file; do + case "$file" in + src/*.js|src/*.ts|src/*.tsx) ;; + *) continue ;; + esac + if echo "$EDITED_FILES" | grep -qxF "$file"; then + FILES_TO_CHECK="${FILES_TO_CHECK:+$FILES_TO_CHECK +}$file" + fi +done <<< "$STAGED" + +if [ -z "$FILES_TO_CHECK" ]; then + exit 0 +fi + +# Single Node.js invocation: check all files with one DB connection +DEAD_EXPORTS=$(node -e " + const path = require('path'); + const root = process.argv[1]; + const files = process.argv[2].split('\n').filter(Boolean); + + const { exportsData } = require(path.join(root, 'src/queries.js')); + + const dead = []; + for (const file of files) { + try { + const data = exportsData(file, undefined, { noTests: true, unused: true }); + if (data && data.results) { + for (const r of data.results) { + if (r.consumerCount === 0) { + dead.push(r.name + ' (' + data.file + ':' + r.line + ')'); + } + } + } + } catch {} + } + + if (dead.length > 0) { + process.stdout.write(dead.join(', ')); + } +" "$WORK_ROOT" "$FILES_TO_CHECK" 2>/dev/null) || true + +if [ -n "$DEAD_EXPORTS" ]; then + REASON="BLOCKED: Dead exports (zero consumers) detected in files you edited: $DEAD_EXPORTS. Either add consumers, remove the exports, or verify these are intentionally public API." + + node -e " + console.log(JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: process.argv[1] + } + })); + " "$REASON" + exit 0 +fi + +exit 0 diff --git a/.claude/hooks/guard-pr-body.sh b/.claude/hooks/guard-pr-body.sh new file mode 100644 index 00000000..df2e7726 --- /dev/null +++ b/.claude/hooks/guard-pr-body.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Block PR creation if the body contains "generated with" (case-insensitive) + +set -euo pipefail + +INPUT=$(cat) + +# Extract just the command field to avoid false positives on the description field +cmd=$(echo "$INPUT" | node -e " + let d=''; + process.stdin.on('data',c=>d+=c); + process.stdin.on('end',()=>{ + const p=JSON.parse(d).tool_input?.command||''; + if(p)process.stdout.write(p); + }); +" 2>/dev/null) || true + +echo "$cmd" | grep -qi 'gh pr create' || exit 0 + +# Block if body contains "generated with" +if echo "$cmd" | grep -qi 'generated with'; then + echo "BLOCK: Remove any 'Generated with ...' line from the PR body." >&2 + exit 2 +fi diff --git a/.claude/settings.json b/.claude/settings.json index 4ffe2530..9949de9d 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -14,10 +14,25 @@ "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-git.sh\"", "timeout": 10 }, + { + "type": "command", + "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-pr-body.sh\"", + "timeout": 10 + }, { "type": "command", "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/show-diff-impact.sh\"", "timeout": 15 + }, + { + "type": "command", + "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/check-commit.sh\"", + "timeout": 20 + }, + { + "type": "command", + "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/check-dead-exports.sh\"", + "timeout": 30 } ] }, diff --git a/docs/guides/recommended-practices.md b/docs/guides/recommended-practices.md index f4bfdc6b..7184b54d 100644 --- a/docs/guides/recommended-practices.md +++ b/docs/guides/recommended-practices.md @@ -339,6 +339,10 @@ You can configure [Claude Code hooks](https://docs.anthropic.com/en/docs/claude- > **Windows note:** If your hooks use bash scripts, normalize backslashes inside `node -e` rather than bash (`${VAR//\\//}` fails on Git Bash). See this repo's `.claude/hooks/enrich-context.sh` for the pattern. +**Commit check hook** (PreToolUse on Bash): when Claude runs `git commit`, the hook runs `checkData()` once with cycles + signatures predicates enabled (boundaries skipped for speed). If circular dependencies involve files edited in this session, blocks the commit. If function signatures were modified, injects a risk-rated warning via `additionalContext` — `HIGH` for core symbols, `MEDIUM` for utility, `low` for others — with transitive caller counts. Non-blocking for signatures, blocking for cycles. + +**Dead export check hook** (PreToolUse on Bash): when Claude runs `git commit`, the hook batch-checks all staged `src/` files edited in this session for exports with zero consumers. Uses a single Node.js invocation with one DB connection for all files (not per-file CLI calls). If any export has zero consumers, blocks the commit. + **Ready-to-use examples** are in [`docs/examples/claude-code-hooks/`](../examples/claude-code-hooks/) with a complete `settings.json` and setup instructions: - `enrich-context.sh` — dependency context injection - `remind-codegraph.sh` — pre-edit reminder to check context/impact @@ -348,6 +352,9 @@ You can configure [Claude Code hooks](https://docs.anthropic.com/en/docs/claude- - `guard-git.sh` — blocks dangerous git commands + validates commits - `track-edits.sh` — logs edited files for commit validation - `track-moves.sh` — logs file moves/copies for commit validation +- `guard-pr-body.sh` — blocks PRs with "generated with" in the body +- `check-commit.sh` — combined cycle detection (blocking) + signature change warning (informational) +- `check-dead-exports.sh` — blocks commits if files you edited contain exports with zero consumers #### Parallel session safety hooks diff --git a/src/cli.js b/src/cli.js index d299a7f0..8734bc02 100644 --- a/src/cli.js +++ b/src/cli.js @@ -261,7 +261,7 @@ QUERY_OPTS( .command('exports ') .description('Show exported symbols with per-symbol consumers (who calls each export)'), ) - .option('--unused', 'Show only exports with zero consumers') + .option('--unused', 'Show only exports with zero consumers (dead exports)') .action((file, opts) => { fileExports(file, opts.db, { noTests: resolveNoTests(opts), @@ -269,7 +269,7 @@ QUERY_OPTS( limit: opts.limit ? parseInt(opts.limit, 10) : undefined, offset: opts.offset ? parseInt(opts.offset, 10) : undefined, ndjson: opts.ndjson, - unused: opts.unused, + unused: opts.unused || false, }); }); From 77a2b3004cf6be6797e25ae873498c7dfefa5ba3 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 8 Mar 2026 21:21:25 -0600 Subject: [PATCH 2/4] fix: address Greptile review feedback on pre-commit hooks - check-dead-exports.sh: use grep filter instead of case glob to match files in subdirectories (e.g. src/extractors/*.js) - check-commit.sh: uppercase 'LOW' for consistent risk-level casing - check-commit.sh: wrap DB enrichment block in try/finally to prevent leaked SQLite connections on unexpected errors - guard-pr-body.sh: use structured JSON deny + exit 0 consistent with the other hooks instead of stderr + exit 2 - settings.json: bump check-commit.sh timeout from 20s to 30s to match check-dead-exports and avoid silent skips on larger graphs --- .claude/hooks/check-commit.sh | 61 +++++++++++++++-------------- .claude/hooks/check-dead-exports.sh | 7 ++-- .claude/hooks/guard-pr-body.sh | 14 ++++++- .claude/settings.json | 2 +- 4 files changed, 48 insertions(+), 36 deletions(-) diff --git a/.claude/hooks/check-commit.sh b/.claude/hooks/check-commit.sh index f55660d4..b093705d 100644 --- a/.claude/hooks/check-commit.sh +++ b/.claude/hooks/check-commit.sh @@ -88,42 +88,45 @@ RESULT=$(node -e " if (sigPred && !sigPred.passed && sigPred.violations?.length) { // Enrich with role + transitive caller count using a single DB connection const db = openReadonlyOrFail(); - const stmtNode = db.prepare( - 'SELECT id, role FROM nodes WHERE name = ? AND file = ? AND line = ?' - ); - const stmtCallers = db.prepare( - 'SELECT DISTINCT n.id FROM edges e JOIN nodes n ON e.source_id = n.id WHERE e.target_id = ? AND e.kind = \\'calls\\'' - ); - const lines = []; - for (const v of sigPred.violations) { - const node = stmtNode.get(v.name, v.file, v.line); - const role = node?.role || 'unknown'; - - let callerCount = 0; - if (node) { - const visited = new Set([node.id]); - let frontier = [node.id]; - for (let d = 0; d < 3; d++) { - const next = []; - for (const fid of frontier) { - for (const c of stmtCallers.all(fid)) { - if (!visited.has(c.id)) { - visited.add(c.id); - next.push(c.id); - callerCount++; + try { + const stmtNode = db.prepare( + 'SELECT id, role FROM nodes WHERE name = ? AND file = ? AND line = ?' + ); + const stmtCallers = db.prepare( + 'SELECT DISTINCT n.id FROM edges e JOIN nodes n ON e.source_id = n.id WHERE e.target_id = ? AND e.kind = \\'calls\\'' + ); + + for (const v of sigPred.violations) { + const node = stmtNode.get(v.name, v.file, v.line); + const role = node?.role || 'unknown'; + + let callerCount = 0; + if (node) { + const visited = new Set([node.id]); + let frontier = [node.id]; + for (let d = 0; d < 3; d++) { + const next = []; + for (const fid of frontier) { + for (const c of stmtCallers.all(fid)) { + if (!visited.has(c.id)) { + visited.add(c.id); + next.push(c.id); + callerCount++; + } } } + frontier = next; + if (!frontier.length) break; } - frontier = next; - if (!frontier.length) break; } - } - const risk = role === 'core' ? 'HIGH' : role === 'utility' ? 'MEDIUM' : 'low'; - lines.push(risk + ': ' + v.name + ' (' + v.kind + ') [' + role + '] at ' + v.file + ':' + v.line + ' — ' + callerCount + ' transitive callers'); + const risk = role === 'core' ? 'HIGH' : role === 'utility' ? 'MEDIUM' : 'LOW'; + lines.push(risk + ': ' + v.name + ' (' + v.kind + ') [' + role + '] at ' + v.file + ':' + v.line + ' — ' + callerCount + ' transitive callers'); + } + } finally { + db.close(); } - db.close(); if (lines.length > 0) { output.sigWarning = lines.join('\n'); diff --git a/.claude/hooks/check-dead-exports.sh b/.claude/hooks/check-dead-exports.sh index a5d66f78..14bb0bda 100644 --- a/.claude/hooks/check-dead-exports.sh +++ b/.claude/hooks/check-dead-exports.sh @@ -48,10 +48,9 @@ EDITED_FILES=$(awk '{print $2}' "$LOG_FILE" | sort -u) # Filter staged files to src/*.js that were edited in this session FILES_TO_CHECK="" while IFS= read -r file; do - case "$file" in - src/*.js|src/*.ts|src/*.tsx) ;; - *) continue ;; - esac + if ! echo "$file" | grep -qE '^src/.*\.(js|ts|tsx)$'; then + continue + fi if echo "$EDITED_FILES" | grep -qxF "$file"; then FILES_TO_CHECK="${FILES_TO_CHECK:+$FILES_TO_CHECK }$file" diff --git a/.claude/hooks/guard-pr-body.sh b/.claude/hooks/guard-pr-body.sh index df2e7726..9fffb288 100644 --- a/.claude/hooks/guard-pr-body.sh +++ b/.claude/hooks/guard-pr-body.sh @@ -19,6 +19,16 @@ echo "$cmd" | grep -qi 'gh pr create' || exit 0 # Block if body contains "generated with" if echo "$cmd" | grep -qi 'generated with'; then - echo "BLOCK: Remove any 'Generated with ...' line from the PR body." >&2 - exit 2 + node -e " + console.log(JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: 'BLOCKED: Remove any \'Generated with ...\' line from the PR body.' + } + })); + " + exit 0 fi + +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json index 9949de9d..c2aa726c 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -27,7 +27,7 @@ { "type": "command", "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/check-commit.sh\"", - "timeout": 20 + "timeout": 30 }, { "type": "command", From b3f6f41c022475fbd848b6326353900fd90f4fbc Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 8 Mar 2026 21:33:47 -0600 Subject: [PATCH 3/4] fix: correct misleading DB connection claims and risk-level casing in docs --- .claude/hooks/check-dead-exports.sh | 2 +- docs/guides/recommended-practices.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude/hooks/check-dead-exports.sh b/.claude/hooks/check-dead-exports.sh index 14bb0bda..f8d1499c 100644 --- a/.claude/hooks/check-dead-exports.sh +++ b/.claude/hooks/check-dead-exports.sh @@ -61,7 +61,7 @@ if [ -z "$FILES_TO_CHECK" ]; then exit 0 fi -# Single Node.js invocation: check all files with one DB connection +# Single Node.js invocation: check all files in one process DEAD_EXPORTS=$(node -e " const path = require('path'); const root = process.argv[1]; diff --git a/docs/guides/recommended-practices.md b/docs/guides/recommended-practices.md index 7184b54d..02a90a21 100644 --- a/docs/guides/recommended-practices.md +++ b/docs/guides/recommended-practices.md @@ -339,9 +339,9 @@ You can configure [Claude Code hooks](https://docs.anthropic.com/en/docs/claude- > **Windows note:** If your hooks use bash scripts, normalize backslashes inside `node -e` rather than bash (`${VAR//\\//}` fails on Git Bash). See this repo's `.claude/hooks/enrich-context.sh` for the pattern. -**Commit check hook** (PreToolUse on Bash): when Claude runs `git commit`, the hook runs `checkData()` once with cycles + signatures predicates enabled (boundaries skipped for speed). If circular dependencies involve files edited in this session, blocks the commit. If function signatures were modified, injects a risk-rated warning via `additionalContext` — `HIGH` for core symbols, `MEDIUM` for utility, `low` for others — with transitive caller counts. Non-blocking for signatures, blocking for cycles. +**Commit check hook** (PreToolUse on Bash): when Claude runs `git commit`, the hook runs `checkData()` once with cycles + signatures predicates enabled (boundaries skipped for speed). If circular dependencies involve files edited in this session, blocks the commit. If function signatures were modified, injects a risk-rated warning via `additionalContext` — `HIGH` for core symbols, `MEDIUM` for utility, `LOW` for others — with transitive caller counts. Non-blocking for signatures, blocking for cycles. -**Dead export check hook** (PreToolUse on Bash): when Claude runs `git commit`, the hook batch-checks all staged `src/` files edited in this session for exports with zero consumers. Uses a single Node.js invocation with one DB connection for all files (not per-file CLI calls). If any export has zero consumers, blocks the commit. +**Dead export check hook** (PreToolUse on Bash): when Claude runs `git commit`, the hook batch-checks all staged `src/` files edited in this session for exports with zero consumers. Uses a single Node.js process for all files (not per-file CLI calls). If any export has zero consumers, blocks the commit. **Ready-to-use examples** are in [`docs/examples/claude-code-hooks/`](../examples/claude-code-hooks/) with a complete `settings.json` and setup instructions: - `enrich-context.sh` — dependency context injection From 9880b4d2f752e396f7244b63dbbe80dbb20ac97c Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 8 Mar 2026 22:11:23 -0600 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20address=20review=20round=202=20?= =?UTF-8?q?=E2=80=94=20body-file=20bypass,=20redundant=20filter,=20comment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/hooks/check-commit.sh | 2 +- .claude/hooks/check-dead-exports.sh | 4 +--- .claude/hooks/guard-pr-body.sh | 17 +++++++++++++++++ 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/.claude/hooks/check-commit.sh b/.claude/hooks/check-commit.sh index b093705d..1fc6987c 100644 --- a/.claude/hooks/check-commit.sh +++ b/.claude/hooks/check-commit.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # check-commit.sh — PreToolUse hook for Bash (git commit) # Combined cycle-detection (blocking) + signature-change warning (informational). -# Runs checkData() ONCE with both predicates, single DB connection. +# Runs checkData() ONCE with both predicates, single Node.js process. set -euo pipefail diff --git a/.claude/hooks/check-dead-exports.sh b/.claude/hooks/check-dead-exports.sh index f8d1499c..75ccd509 100644 --- a/.claude/hooks/check-dead-exports.sh +++ b/.claude/hooks/check-dead-exports.sh @@ -75,9 +75,7 @@ DEAD_EXPORTS=$(node -e " const data = exportsData(file, undefined, { noTests: true, unused: true }); if (data && data.results) { for (const r of data.results) { - if (r.consumerCount === 0) { - dead.push(r.name + ' (' + data.file + ':' + r.line + ')'); - } + dead.push(r.name + ' (' + data.file + ':' + r.line + ')'); } } } catch {} diff --git a/.claude/hooks/guard-pr-body.sh b/.claude/hooks/guard-pr-body.sh index 9fffb288..68b4ac67 100644 --- a/.claude/hooks/guard-pr-body.sh +++ b/.claude/hooks/guard-pr-body.sh @@ -31,4 +31,21 @@ if echo "$cmd" | grep -qi 'generated with'; then exit 0 fi +# Also check --body-file path +BODY_FILE=$(echo "$cmd" | grep -oP '(?<=--body-file\s)\S+' || true) +if [ -n "$BODY_FILE" ] && [ -f "$BODY_FILE" ]; then + if grep -qi 'generated with' "$BODY_FILE"; then + node -e " + console.log(JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: 'BLOCKED: Remove any \'Generated with ...\' line from the PR body file.' + } + })); + " + exit 0 + fi +fi + exit 0