From a2ae658fe419e5e64cff1d2ef1415c8c5029cf9b Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:43:40 -0600 Subject: [PATCH 1/5] fix: check-dead-exports hook silently no-ops on ESM codebases --- .claude/hooks/check-dead-exports.sh | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.claude/hooks/check-dead-exports.sh b/.claude/hooks/check-dead-exports.sh index 19736bc..4be3ddf 100644 --- a/.claude/hooks/check-dead-exports.sh +++ b/.claude/hooks/check-dead-exports.sh @@ -64,13 +64,14 @@ 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'); +DEAD_EXPORTS=$(node --input-type=module -e " + import fs from 'node:fs'; + import path from 'node: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 fileUrl = 'file:///' + path.join(root, 'src/queries.js').replace(/\\\\/g, '/'); + 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 +95,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 {} From 062b4046bde9e127302ff45f33687f3ee5b0e1c1 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:53:55 -0600 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20argv?= =?UTF-8?q?=20off-by-one=20and=20portable=20file=20URL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/hooks/check-dead-exports.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.claude/hooks/check-dead-exports.sh b/.claude/hooks/check-dead-exports.sh index 4be3ddf..c37316c 100644 --- a/.claude/hooks/check-dead-exports.sh +++ b/.claude/hooks/check-dead-exports.sh @@ -67,10 +67,11 @@ fi DEAD_EXPORTS=$(node --input-type=module -e " import fs from 'node:fs'; import path from 'node:path'; - const root = process.argv[1]; - const files = process.argv[2].split('\n').filter(Boolean); + const root = process.argv[2]; + const files = process.argv[3].split('\n').filter(Boolean); - const fileUrl = 'file:///' + path.join(root, 'src/queries.js').replace(/\\\\/g, '/'); + const { pathToFileURL } = await import('node:url'); + 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) From 876e52a7913d31e40a93f0243a13d8bf84cff2d8 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:56:35 -0600 Subject: [PATCH 3/5] docs: add check-dead-exports.sh to example hooks --- docs/examples/claude-code-hooks/README.md | 7 + .../claude-code-hooks/check-dead-exports.sh | 147 ++++++++++++++++++ docs/examples/claude-code-hooks/settings.json | 5 + 3 files changed, 159 insertions(+) create mode 100644 docs/examples/claude-code-hooks/check-dead-exports.sh diff --git a/docs/examples/claude-code-hooks/README.md b/docs/examples/claude-code-hooks/README.md index 6afcb18..5f6b08a 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 0000000..c37316c --- /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'; + const root = process.argv[2]; + const files = process.argv[3].split('\n').filter(Boolean); + + const { pathToFileURL } = await import('node:url'); + 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[1] + } + })); + " "$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 2e881c6..fe02ef8 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 } ] } From df6b204603ae2104a492dae8e020cd9ebe824aff Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:16:45 -0600 Subject: [PATCH 4/5] fix: use static import for node:url in check-dead-exports hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Greptile review — pathToFileURL should be a static import like fs and path, not a dynamic await import() for a built-in module. --- .claude/hooks/check-dead-exports.sh | 2 +- docs/examples/claude-code-hooks/check-dead-exports.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/hooks/check-dead-exports.sh b/.claude/hooks/check-dead-exports.sh index c37316c..664a324 100644 --- a/.claude/hooks/check-dead-exports.sh +++ b/.claude/hooks/check-dead-exports.sh @@ -67,10 +67,10 @@ fi 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 { pathToFileURL } = await import('node:url'); const fileUrl = pathToFileURL(path.join(root, 'src/queries.js')).href; const { exportsData } = await import(fileUrl); diff --git a/docs/examples/claude-code-hooks/check-dead-exports.sh b/docs/examples/claude-code-hooks/check-dead-exports.sh index c37316c..664a324 100644 --- a/docs/examples/claude-code-hooks/check-dead-exports.sh +++ b/docs/examples/claude-code-hooks/check-dead-exports.sh @@ -67,10 +67,10 @@ fi 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 { pathToFileURL } = await import('node:url'); const fileUrl = pathToFileURL(path.join(root, 'src/queries.js')).href; const { exportsData } = await import(fileUrl); From 0373a797415b77422937adb5e9e35db257da5c0c Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:53:42 -0600 Subject: [PATCH 5/5] fix: correct argv index in denial block for node -e scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .claude/hooks/check-dead-exports.sh | 2 +- docs/examples/claude-code-hooks/check-dead-exports.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/hooks/check-dead-exports.sh b/.claude/hooks/check-dead-exports.sh index c37316c..96be773 100644 --- a/.claude/hooks/check-dead-exports.sh +++ b/.claude/hooks/check-dead-exports.sh @@ -137,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/check-dead-exports.sh b/docs/examples/claude-code-hooks/check-dead-exports.sh index c37316c..96be773 100644 --- a/docs/examples/claude-code-hooks/check-dead-exports.sh +++ b/docs/examples/claude-code-hooks/check-dead-exports.sh @@ -137,7 +137,7 @@ if [ -n "$DEAD_EXPORTS" ]; then hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', - permissionDecisionReason: process.argv[1] + permissionDecisionReason: process.argv[2] } })); " "$REASON"