diff --git a/.claude/hooks/check-dead-exports.sh b/.claude/hooks/check-dead-exports.sh index 19736bcb..9cf1baa5 100644 --- a/.claude/hooks/check-dead-exports.sh +++ b/.claude/hooks/check-dead-exports.sh @@ -64,13 +64,15 @@ fi # Single Node.js invocation: check all files in one process # Excludes exports that are re-exported from index.js (public API) or consumed # via dynamic import() — codegraph's static graph doesn't track those edges. -DEAD_EXPORTS=$(node -e " - const fs = require('fs'); - const path = require('path'); - const root = process.argv[1]; - const files = process.argv[2].split('\n').filter(Boolean); +DEAD_EXPORTS=$(node --input-type=module -e " + import fs from 'node:fs'; + import path from 'node:path'; + import { pathToFileURL } from 'node:url'; + const root = process.argv[2]; + const files = process.argv[3].split('\n').filter(Boolean); - const { exportsData } = require(path.join(root, 'src/queries.js')); + const fileUrl = pathToFileURL(path.join(root, 'src/queries.js')).href; + const { exportsData } = await import(fileUrl); // Build set of names exported from index.js (public API surface) const indexSrc = fs.readFileSync(path.join(root, 'src/index.js'), 'utf8'); @@ -94,14 +96,14 @@ DEAD_EXPORTS=$(node -e " try { const src = fs.readFileSync(path.join(dir, ent.name), 'utf8'); // Multi-line-safe: match const { ... } = [await] import('...') - for (const m of src.matchAll(/const\s*\{([^}]+)\}\s*=\s*(?:await\s+)?import\s*\(['"]/gs)) { + for (const m of src.matchAll(/const\s*\{([^}]+)\}\s*=\s*(?:await\s+)?import\s*\([\u0022']/gs)) { for (const part of m[1].split(',')) { const name = part.trim().split(/\s+as\s+/).pop().trim().split('\n').pop().trim(); if (name && /^\w+$/.test(name)) publicAPI.add(name); } } // Also match single-binding: const X = [await] import('...') (default import) - for (const m of src.matchAll(/const\s+(\w+)\s*=\s*(?:await\s+)?import\s*\(['"]/g)) { + for (const m of src.matchAll(/const\s+(\w+)\s*=\s*(?:await\s+)?import\s*\([\u0022']/g)) { publicAPI.add(m[1]); } } catch {} @@ -135,7 +137,7 @@ if [ -n "$DEAD_EXPORTS" ]; then hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', - permissionDecisionReason: process.argv[1] + permissionDecisionReason: process.argv[2] } })); " "$REASON" diff --git a/docs/examples/claude-code-hooks/README.md b/docs/examples/claude-code-hooks/README.md index 6afcb18e..5f6b08a3 100644 --- a/docs/examples/claude-code-hooks/README.md +++ b/docs/examples/claude-code-hooks/README.md @@ -35,6 +35,12 @@ echo ".claude/codegraph-checked.log" >> .gitignore |------|---------|-------------| | `check-readme.sh` | PreToolUse on Bash | Blocks `git commit` when source files are staged but `README.md`, `CLAUDE.md`, or `ROADMAP.md` aren't — prompts the agent to review whether docs need updating | +### Code quality hooks + +| Hook | Trigger | What it does | +|------|---------|-------------| +| `check-dead-exports.sh` | PreToolUse on Bash | Blocks `git commit` when any edited `src/` file has exports with zero consumers — catches dead code before it's committed | + ### Parallel session safety hooks (recommended for multi-agent workflows) | Hook | Trigger | What it does | @@ -69,6 +75,7 @@ Without this fix, `CLAUDE_PROJECT_DIR` (which always points to the main project - **Solo developer:** `enrich-context.sh` + `update-graph.sh` + `post-git-ops.sh` - **With reminders:** Add `remind-codegraph.sh` - **Doc hygiene:** Add `check-readme.sh` to catch source commits that may need doc updates +- **Code quality:** Add `check-dead-exports.sh` to block dead exports at commit time - **Multi-agent / worktrees:** Add `guard-git.sh` + `track-edits.sh` + `track-moves.sh` **Branch name validation:** The `guard-git.sh` in this repo's `.claude/hooks/` validates branch names against conventional prefixes (`feat/`, `fix/`, etc.). The example version omits this — add your own validation if needed. diff --git a/docs/examples/claude-code-hooks/check-dead-exports.sh b/docs/examples/claude-code-hooks/check-dead-exports.sh new file mode 100644 index 00000000..9cf1baa5 --- /dev/null +++ b/docs/examples/claude-code-hooks/check-dead-exports.sh @@ -0,0 +1,147 @@ +#!/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 +# Excludes exports that are re-exported from index.js (public API) or consumed +# via dynamic import() — codegraph's static graph doesn't track those edges. +DEAD_EXPORTS=$(node --input-type=module -e " + import fs from 'node:fs'; + import path from 'node:path'; + import { pathToFileURL } from 'node:url'; + const root = process.argv[2]; + const files = process.argv[3].split('\n').filter(Boolean); + + const fileUrl = pathToFileURL(path.join(root, 'src/queries.js')).href; + const { exportsData } = await import(fileUrl); + + // Build set of names exported from index.js (public API surface) + const indexSrc = fs.readFileSync(path.join(root, 'src/index.js'), 'utf8'); + const publicAPI = new Set(); + // Match: export { foo, bar as baz } from '...' + for (const m of indexSrc.matchAll(/export\s*\{([^}]+)\}/g)) { + for (const part of m[1].split(',')) { + const name = part.trim().split(/\s+as\s+/).pop().trim(); + if (name) publicAPI.add(name); + } + } + // Match: export default ... + if (/export\s+default\b/.test(indexSrc)) publicAPI.add('default'); + + // Scan all src/ files for dynamic import() consumers + const srcDir = path.join(root, 'src'); + function scanDynamic(dir) { + for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { + if (ent.isDirectory()) { scanDynamic(path.join(dir, ent.name)); continue; } + if (!ent.name.endsWith('.js')) continue; + try { + const src = fs.readFileSync(path.join(dir, ent.name), 'utf8'); + // Multi-line-safe: match const { ... } = [await] import('...') + for (const m of src.matchAll(/const\s*\{([^}]+)\}\s*=\s*(?:await\s+)?import\s*\([\u0022']/gs)) { + for (const part of m[1].split(',')) { + const name = part.trim().split(/\s+as\s+/).pop().trim().split('\n').pop().trim(); + if (name && /^\w+$/.test(name)) publicAPI.add(name); + } + } + // Also match single-binding: const X = [await] import('...') (default import) + for (const m of src.matchAll(/const\s+(\w+)\s*=\s*(?:await\s+)?import\s*\([\u0022']/g)) { + publicAPI.add(m[1]); + } + } catch {} + } + } + scanDynamic(srcDir); + + 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 (publicAPI.has(r.name)) continue; // public API or dynamic import consumer + 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[2] + } + })); + " "$REASON" + exit 0 +fi + +exit 0 diff --git a/docs/examples/claude-code-hooks/settings.json b/docs/examples/claude-code-hooks/settings.json index 2e881c6f..fe02ef8d 100644 --- a/docs/examples/claude-code-hooks/settings.json +++ b/docs/examples/claude-code-hooks/settings.json @@ -28,6 +28,11 @@ "type": "command", "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-git.sh\"", "timeout": 10 + }, + { + "type": "command", + "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/check-dead-exports.sh\"", + "timeout": 15 } ] }