diff --git a/.claude/hooks/check-commit.sh b/.claude/hooks/check-commit.sh new file mode 100644 index 00000000..1fc6987c --- /dev/null +++ b/.claude/hooks/check-commit.sh @@ -0,0 +1,177 @@ +#!/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 Node.js process. + +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 lines = []; + 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; + } + } + + 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(); + } + + 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..75ccd509 --- /dev/null +++ b/.claude/hooks/check-dead-exports.sh @@ -0,0 +1,104 @@ +#!/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 + 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" + fi +done <<< "$STAGED" + +if [ -z "$FILES_TO_CHECK" ]; then + exit 0 +fi + +# Single Node.js invocation: check all files in one process +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) { + 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..68b4ac67 --- /dev/null +++ b/.claude/hooks/guard-pr-body.sh @@ -0,0 +1,51 @@ +#!/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 + 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 + +# 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 diff --git a/.claude/settings.json b/.claude/settings.json index 4ffe2530..c2aa726c 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": 30 + }, + { + "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..02a90a21 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 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 - `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, }); });