Skip to content

Commit 5a1d7ce

Browse files
authored
fix: check-dead-exports hook silently no-ops on ESM codebases (#394)
* fix: check-dead-exports hook silently no-ops on ESM codebases * fix: address review — argv off-by-one and portable file URL * docs: add check-dead-exports.sh to example hooks * fix: use static import for node:url in check-dead-exports hook Address Greptile review — pathToFileURL should be a static import like fs and path, not a dynamic await import() for a built-in module. * fix: correct argv index in denial block for node -e scripts For `node -e "..." arg`, process.argv[1] is empty — the actual argument lands at process.argv[2]. This caused the denial reason to be undefined when blocking commits with dead exports.
1 parent a25a544 commit 5a1d7ce

4 files changed

Lines changed: 170 additions & 9 deletions

File tree

.claude/hooks/check-dead-exports.sh

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,15 @@ fi
6464
# Single Node.js invocation: check all files in one process
6565
# Excludes exports that are re-exported from index.js (public API) or consumed
6666
# via dynamic import() — codegraph's static graph doesn't track those edges.
67-
DEAD_EXPORTS=$(node -e "
68-
const fs = require('fs');
69-
const path = require('path');
70-
const root = process.argv[1];
71-
const files = process.argv[2].split('\n').filter(Boolean);
67+
DEAD_EXPORTS=$(node --input-type=module -e "
68+
import fs from 'node:fs';
69+
import path from 'node:path';
70+
import { pathToFileURL } from 'node:url';
71+
const root = process.argv[2];
72+
const files = process.argv[3].split('\n').filter(Boolean);
7273
73-
const { exportsData } = require(path.join(root, 'src/queries.js'));
74+
const fileUrl = pathToFileURL(path.join(root, 'src/queries.js')).href;
75+
const { exportsData } = await import(fileUrl);
7476
7577
// Build set of names exported from index.js (public API surface)
7678
const indexSrc = fs.readFileSync(path.join(root, 'src/index.js'), 'utf8');
@@ -94,14 +96,14 @@ DEAD_EXPORTS=$(node -e "
9496
try {
9597
const src = fs.readFileSync(path.join(dir, ent.name), 'utf8');
9698
// Multi-line-safe: match const { ... } = [await] import('...')
97-
for (const m of src.matchAll(/const\s*\{([^}]+)\}\s*=\s*(?:await\s+)?import\s*\(['"]/gs)) {
99+
for (const m of src.matchAll(/const\s*\{([^}]+)\}\s*=\s*(?:await\s+)?import\s*\([\u0022']/gs)) {
98100
for (const part of m[1].split(',')) {
99101
const name = part.trim().split(/\s+as\s+/).pop().trim().split('\n').pop().trim();
100102
if (name && /^\w+$/.test(name)) publicAPI.add(name);
101103
}
102104
}
103105
// Also match single-binding: const X = [await] import('...') (default import)
104-
for (const m of src.matchAll(/const\s+(\w+)\s*=\s*(?:await\s+)?import\s*\(['"]/g)) {
106+
for (const m of src.matchAll(/const\s+(\w+)\s*=\s*(?:await\s+)?import\s*\([\u0022']/g)) {
105107
publicAPI.add(m[1]);
106108
}
107109
} catch {}
@@ -135,7 +137,7 @@ if [ -n "$DEAD_EXPORTS" ]; then
135137
hookSpecificOutput: {
136138
hookEventName: 'PreToolUse',
137139
permissionDecision: 'deny',
138-
permissionDecisionReason: process.argv[1]
140+
permissionDecisionReason: process.argv[2]
139141
}
140142
}));
141143
" "$REASON"

docs/examples/claude-code-hooks/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ echo ".claude/codegraph-checked.log" >> .gitignore
3535
|------|---------|-------------|
3636
| `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 |
3737

38+
### Code quality hooks
39+
40+
| Hook | Trigger | What it does |
41+
|------|---------|-------------|
42+
| `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 |
43+
3844
### Parallel session safety hooks (recommended for multi-agent workflows)
3945

4046
| Hook | Trigger | What it does |
@@ -69,6 +75,7 @@ Without this fix, `CLAUDE_PROJECT_DIR` (which always points to the main project
6975
- **Solo developer:** `enrich-context.sh` + `update-graph.sh` + `post-git-ops.sh`
7076
- **With reminders:** Add `remind-codegraph.sh`
7177
- **Doc hygiene:** Add `check-readme.sh` to catch source commits that may need doc updates
78+
- **Code quality:** Add `check-dead-exports.sh` to block dead exports at commit time
7279
- **Multi-agent / worktrees:** Add `guard-git.sh` + `track-edits.sh` + `track-moves.sh`
7380

7481
**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.
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
#!/usr/bin/env bash
2+
# check-dead-exports.sh — PreToolUse hook for Bash (git commit)
3+
# Blocks commits if any src/ file edited in THIS SESSION has exports with zero consumers.
4+
# Batches all files in a single Node.js invocation (one DB open) for speed.
5+
6+
set -euo pipefail
7+
8+
INPUT=$(cat)
9+
10+
# Extract the command from tool_input JSON
11+
COMMAND=$(echo "$INPUT" | node -e "
12+
let d='';
13+
process.stdin.on('data',c=>d+=c);
14+
process.stdin.on('end',()=>{
15+
const p=JSON.parse(d).tool_input?.command||'';
16+
if(p)process.stdout.write(p);
17+
});
18+
" 2>/dev/null) || true
19+
20+
if [ -z "$COMMAND" ]; then
21+
exit 0
22+
fi
23+
24+
# Only trigger on git commit commands
25+
if ! echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+commit\b'; then
26+
exit 0
27+
fi
28+
29+
# Guard: codegraph DB must exist
30+
WORK_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || WORK_ROOT="${CLAUDE_PROJECT_DIR:-.}"
31+
if [ ! -f "$WORK_ROOT/.codegraph/graph.db" ]; then
32+
exit 0
33+
fi
34+
35+
# Guard: must have staged changes
36+
STAGED=$(git diff --cached --name-only 2>/dev/null) || true
37+
if [ -z "$STAGED" ]; then
38+
exit 0
39+
fi
40+
41+
# Load session edit log to scope checks to files we actually edited
42+
LOG_FILE="$WORK_ROOT/.claude/session-edits.log"
43+
if [ ! -f "$LOG_FILE" ] || [ ! -s "$LOG_FILE" ]; then
44+
exit 0
45+
fi
46+
EDITED_FILES=$(awk '{print $2}' "$LOG_FILE" | sort -u)
47+
48+
# Filter staged files to src/*.js that were edited in this session
49+
FILES_TO_CHECK=""
50+
while IFS= read -r file; do
51+
if ! echo "$file" | grep -qE '^src/.*\.(js|ts|tsx)$'; then
52+
continue
53+
fi
54+
if echo "$EDITED_FILES" | grep -qxF "$file"; then
55+
FILES_TO_CHECK="${FILES_TO_CHECK:+$FILES_TO_CHECK
56+
}$file"
57+
fi
58+
done <<< "$STAGED"
59+
60+
if [ -z "$FILES_TO_CHECK" ]; then
61+
exit 0
62+
fi
63+
64+
# Single Node.js invocation: check all files in one process
65+
# Excludes exports that are re-exported from index.js (public API) or consumed
66+
# via dynamic import() — codegraph's static graph doesn't track those edges.
67+
DEAD_EXPORTS=$(node --input-type=module -e "
68+
import fs from 'node:fs';
69+
import path from 'node:path';
70+
import { pathToFileURL } from 'node:url';
71+
const root = process.argv[2];
72+
const files = process.argv[3].split('\n').filter(Boolean);
73+
74+
const fileUrl = pathToFileURL(path.join(root, 'src/queries.js')).href;
75+
const { exportsData } = await import(fileUrl);
76+
77+
// Build set of names exported from index.js (public API surface)
78+
const indexSrc = fs.readFileSync(path.join(root, 'src/index.js'), 'utf8');
79+
const publicAPI = new Set();
80+
// Match: export { foo, bar as baz } from '...'
81+
for (const m of indexSrc.matchAll(/export\s*\{([^}]+)\}/g)) {
82+
for (const part of m[1].split(',')) {
83+
const name = part.trim().split(/\s+as\s+/).pop().trim();
84+
if (name) publicAPI.add(name);
85+
}
86+
}
87+
// Match: export default ...
88+
if (/export\s+default\b/.test(indexSrc)) publicAPI.add('default');
89+
90+
// Scan all src/ files for dynamic import() consumers
91+
const srcDir = path.join(root, 'src');
92+
function scanDynamic(dir) {
93+
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
94+
if (ent.isDirectory()) { scanDynamic(path.join(dir, ent.name)); continue; }
95+
if (!ent.name.endsWith('.js')) continue;
96+
try {
97+
const src = fs.readFileSync(path.join(dir, ent.name), 'utf8');
98+
// Multi-line-safe: match const { ... } = [await] import('...')
99+
for (const m of src.matchAll(/const\s*\{([^}]+)\}\s*=\s*(?:await\s+)?import\s*\([\u0022']/gs)) {
100+
for (const part of m[1].split(',')) {
101+
const name = part.trim().split(/\s+as\s+/).pop().trim().split('\n').pop().trim();
102+
if (name && /^\w+$/.test(name)) publicAPI.add(name);
103+
}
104+
}
105+
// Also match single-binding: const X = [await] import('...') (default import)
106+
for (const m of src.matchAll(/const\s+(\w+)\s*=\s*(?:await\s+)?import\s*\([\u0022']/g)) {
107+
publicAPI.add(m[1]);
108+
}
109+
} catch {}
110+
}
111+
}
112+
scanDynamic(srcDir);
113+
114+
const dead = [];
115+
for (const file of files) {
116+
try {
117+
const data = exportsData(file, undefined, { noTests: true, unused: true });
118+
if (data && data.results) {
119+
for (const r of data.results) {
120+
if (publicAPI.has(r.name)) continue; // public API or dynamic import consumer
121+
dead.push(r.name + ' (' + data.file + ':' + r.line + ')');
122+
}
123+
}
124+
} catch {}
125+
}
126+
127+
if (dead.length > 0) {
128+
process.stdout.write(dead.join(', '));
129+
}
130+
" "$WORK_ROOT" "$FILES_TO_CHECK" 2>/dev/null) || true
131+
132+
if [ -n "$DEAD_EXPORTS" ]; then
133+
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."
134+
135+
node -e "
136+
console.log(JSON.stringify({
137+
hookSpecificOutput: {
138+
hookEventName: 'PreToolUse',
139+
permissionDecision: 'deny',
140+
permissionDecisionReason: process.argv[2]
141+
}
142+
}));
143+
" "$REASON"
144+
exit 0
145+
fi
146+
147+
exit 0

docs/examples/claude-code-hooks/settings.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@
2828
"type": "command",
2929
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-git.sh\"",
3030
"timeout": 10
31+
},
32+
{
33+
"type": "command",
34+
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/check-dead-exports.sh\"",
35+
"timeout": 15
3136
}
3237
]
3338
}

0 commit comments

Comments
 (0)