Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
5baf3ad
perf: skip ensureWasmTrees when native engine provides complete data
carlos-alm Mar 5, 2026
d411ab6
fix: address PR #344 review comments — TODO for constant exclusion, c…
carlos-alm Mar 5, 2026
4317e0d
fix: add null guard on symbols.definitions in pre-parse check
carlos-alm Mar 5, 2026
c2bb5de
fix: treat empty cfg.blocks array as valid native CFG
carlos-alm Mar 5, 2026
7e6b489
style: format cfg check per biome rules
carlos-alm Mar 5, 2026
f0a5522
feat: add `sequence` command for Mermaid sequence diagrams
carlos-alm Mar 5, 2026
0201d31
perf: hoist db.prepare() calls and build O(1) lookup map in sequence
carlos-alm Mar 5, 2026
19a990a
fix: address sequence review comments — alias collision, truncation, …
carlos-alm Mar 5, 2026
0db9017
ci: retrigger workflows
carlos-alm Mar 5, 2026
ae4af09
Merge remote-tracking branch 'origin/main' into feat/sequence-command
carlos-alm Mar 5, 2026
995c816
fix: address round-3 review — deduplicate findBestMatch, guard trunca…
carlos-alm Mar 6, 2026
c97bdee
chore: add hook to block "generated with" in PR bodies
carlos-alm Mar 9, 2026
95c5c66
feat: add pre-commit hooks for cycle detection and dead export checks
carlos-alm Mar 9, 2026
f8ff775
fix: scope cycle and dead-export hooks to session-edited files
carlos-alm Mar 9, 2026
d535550
feat: add signature change warning hook with role-based risk levels
carlos-alm Mar 9, 2026
3bdc5ee
fix: break circular dependency cycle and remove dead queryName export
carlos-alm Mar 9, 2026
3ca9a6d
merge: resolve conflicts with main
carlos-alm Mar 9, 2026
5a415c4
fix: resolve merge conflict with main, remove dead test-utils.js
carlos-alm Mar 9, 2026
8251248
Merge branch 'main' into fix/break-cycle-remove-dead-export
carlos-alm Mar 9, 2026
bc03c9c
fix: convert hook to ESM imports, respect --kind filter in flow
carlos-alm Mar 9, 2026
ae0725b
Merge branch 'fix/break-cycle-remove-dead-export' of https://github.c…
carlos-alm Mar 9, 2026
5efbc4c
Merge branch 'main' into fix/break-cycle-remove-dead-export
carlos-alm Mar 9, 2026
3cd8130
Merge remote-tracking branch 'origin/main' into fix/break-cycle-remov…
carlos-alm Mar 9, 2026
4cce192
Merge branch 'fix/break-cycle-remove-dead-export' of https://github.c…
carlos-alm Mar 9, 2026
a72be9d
fix: align hook deny pattern and fix subdirectory glob in dead-export…
carlos-alm Mar 9, 2026
80e1a9e
merge: resolve conflicts with main
carlos-alm Mar 9, 2026
2ebe158
Merge remote-tracking branch 'origin/main' into fix/break-cycle-remov…
carlos-alm Mar 9, 2026
9f65cd3
Merge branch 'main' into fix/break-cycle-remove-dead-export
carlos-alm Mar 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions .claude/hooks/check-cycles.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
# check-cycles.sh — PreToolUse hook for Bash (git commit)
# Blocks commits if circular dependencies involve files edited in this session.

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
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)

# Run check with cycles predicate on staged changes
RESULT=$(node "$WORK_ROOT/src/cli.js" check --staged --json -T 2>/dev/null) || true

if [ -z "$RESULT" ]; then
exit 0
fi

# Check if cycles predicate failed — but only block if a cycle involves
# a file that was edited in this session
CYCLES_FAILED=$(echo "$RESULT" | EDITED="$EDITED_FILES" node -e "
let d='';
process.stdin.on('data',c=>d+=c);
process.stdin.on('end',()=>{
try {
const data=JSON.parse(d);
const cyclesPred=(data.predicates||[]).find(p=>p.name==='cycles');
if(!cyclesPred || cyclesPred.passed) return;
const edited=new Set(process.env.EDITED.split('\\n').filter(Boolean));
// Filter to cycles that involve at least one file we edited
const relevant=(cyclesPred.cycles||[]).filter(
cycle=>cycle.some(f=>edited.has(f))
);
if(relevant.length===0) return;
const summary=relevant.slice(0,5).map(c=>c.join(' -> ')).join('\\n ');
const extra=relevant.length>5?'\\n ... and '+(relevant.length-5)+' more':'';
process.stdout.write(summary+extra);
}catch{}
});
" 2>/dev/null) || true

if [ -n "$CYCLES_FAILED" ]; then
REASON="BLOCKED: Circular dependencies detected involving files you edited:
$CYCLES_FAILED
Fix the cycles before committing."

node -e "
console.log(JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: process.argv[1]
}
}));
" "$REASON"
exit 0
fi

exit 0
103 changes: 103 additions & 0 deletions .claude/hooks/check-dead-exports.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
#!/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.
# Uses the session edit log to scope checks to files you actually touched.

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)

# Check each staged source file that was edited in this session
DEAD_EXPORTS=""

while IFS= read -r file; do
# Only check source files
case "$file" in
src/*.js|src/*.ts|src/*.tsx) ;;
*) continue ;;
esac

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Glob pattern silently skips all subdirectory source files

The case pattern src/*.js|src/*.ts|src/*.tsx only matches files directly inside src/ (e.g., src/queries.js). In bash case, * does not cross directory boundaries, so any file in a subdirectory — src/extractors/go.js, src/ast-analysis/rules/javascript.js, src/db/connection.js, etc. — will hit the *) continue ;; branch and be silently skipped.

The repo has at least three subdirectories under src/ (extractors/, ast-analysis/, db/), so a significant portion of the codebase is never checked for dead exports.

To match nested paths, replace the case guard with a regex or glob-based check:

Suggested change
case "$file" in
src/*.js|src/*.ts|src/*.tsx) ;;
*) continue ;;
esac
# Only check source files (including subdirectories)
if [[ ! "$file" =~ ^src/.*\.(js|ts|tsx)$ ]]; then
continue
fi

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — replaced the case glob pattern with a bash regex (^src/.*\.(js|ts|tsx)$) that correctly matches files in subdirectories like src/extractors/ and src/ast-analysis/.


# Only check files edited in this session
if ! echo "$EDITED_FILES" | grep -qxF "$file"; then
continue
fi

RESULT=$(node "$WORK_ROOT/src/cli.js" exports "$file" --unused --json 2>/dev/null) || true
if [ -z "$RESULT" ]; then
continue
fi

# Extract unused export names
UNUSED=$(echo "$RESULT" | node -e "
let d='';
process.stdin.on('data',c=>d+=c);
process.stdin.on('end',()=>{
try {
const data=JSON.parse(d);
const unused=data.results||[];
if(unused.length>0){
process.stdout.write(unused.map(u=>u.name+' ('+data.file+':'+u.line+')').join(', '));
}
}catch{}
});
" 2>/dev/null) || true

if [ -n "$UNUSED" ]; then
DEAD_EXPORTS="${DEAD_EXPORTS:+$DEAD_EXPORTS; }$UNUSED"
fi
done <<< "$STAGED"

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
29 changes: 29 additions & 0 deletions .claude/hooks/guard-pr-body.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Block PR creation if the body contains "generated with" (case-insensitive)

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 check gh pr create commands
echo "$COMMAND" | grep -qi 'gh pr create' || exit 0

# Block if body contains "generated with"
if echo "$COMMAND" | grep -qi 'generated with'; then
echo "BLOCK: Remove any 'Generated with ...' line from the PR body." >&2
exit 2

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent block mechanism: exit 2 vs structured JSON deny

The sibling hooks check-cycles.sh and check-dead-exports.sh both block by writing a structured permissionDecision: 'deny' JSON object to stdout and then exit 0. This hook instead writes to stderr and exits with code 2, which is inconsistent and may not be interpreted as a structured Claude Code hook denial.

Consider aligning to the same pattern used by the other blocking hooks:

Suggested change
if echo "$COMMAND" | grep -qi 'generated with'; then
echo "BLOCK: Remove any 'Generated with ...' line from the PR body." >&2
exit 2
if echo "$COMMAND" | grep -qi 'generated with'; then
node -e "
console.log(JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: \"Remove any 'Generated with ...' line from the PR body.\"
}
}));
"
exit 0
fi

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — replaced stderr + exit 2 with structured JSON \ pattern matching sibling hooks. Also added \ content check.

fi
Comment on lines +1 to +49

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent stdin vs. $CLAUDE_TOOL_INPUT hook input approach

All the other new hooks (check-cycles.sh, check-dead-exports.sh, warn-signature-changes.sh) read tool input from stdin via INPUT=$(cat) and parse the JSON using node. This hook reads from $CLAUDE_TOOL_INPUT instead:

input="$CLAUDE_TOOL_INPUT"
echo "$input" | grep -qi 'gh pr create' || exit 0

While $CLAUDE_TOOL_INPUT may work in some Claude Code versions, the stdin approach is more robust and consistent with the rest of the hooks in this PR. Consider aligning to the stdin pattern used by the sibling hooks to reduce maintenance confusion.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — guard-pr-body.sh now reads from stdin via INPUT=$(cat) and parses with node, consistent with check-cycles.sh and the other hooks.

130 changes: 130 additions & 0 deletions .claude/hooks/warn-signature-changes.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
#!/usr/bin/env bash
# warn-signature-changes.sh — PreToolUse hook for Bash (git commit)
# Warns when staged changes modify function signatures, highlighting risk
# level based on the symbol's role (core > utility > others).
# Informational only — never blocks.

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

# Run check --staged to get signature violations, then enrich with role + caller count
WARNING=$(echo "" | node --input-type=module -e "
import path from 'path';
const workRoot = process.argv[2];
const { checkData } = await import(path.join(workRoot, 'src/check.js'));
const { openReadonlyOrFail } = await import(path.join(workRoot, 'src/db.js'));

const result = checkData(undefined, { staged: true, noTests: true });
if (!result || result.error) process.exit(0);

const sigPred = (result.predicates || []).find(p => p.name === 'signatures');
if (!sigPred || sigPred.passed || !sigPred.violations.length) process.exit(0);

const db = openReadonlyOrFail();
const lines = [];

for (const v of sigPred.violations) {
// Get role from DB
const node = db.prepare(
'SELECT role FROM nodes WHERE name = ? AND file = ? AND line = ?'
).get(v.name, v.file, v.line);
const role = node?.role || 'unknown';

// Count transitive callers (BFS, depth 3)
const defNode = db.prepare(
'SELECT id FROM nodes WHERE name = ? AND file = ? AND line = ?'
).get(v.name, v.file, v.line);

let callerCount = 0;
if (defNode) {
const visited = new Set([defNode.id]);
let frontier = [defNode.id];
for (let d = 0; d < 3; d++) {
const next = [];
for (const fid of frontier) {
const callers = 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\\''
).all(fid);
for (const c of callers) {
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) {
process.stdout.write(lines.join('\\n'));
}
" -- "$WORK_ROOT" 2>/dev/null) || true

if [ -z "$WARNING" ]; then
exit 0
fi

# Escape for JSON
ESCAPED=$(printf '%s' "$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 [ -z "$ESCAPED" ]; then
exit 0
fi

# Inject as additionalContext — informational, never blocks
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

exit 0
20 changes: 20 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,30 @@
"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-cycles.sh\"",
"timeout": 15
},
{
"type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/check-dead-exports.sh\"",
"timeout": 30
},
{
"type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/warn-signature-changes.sh\"",
"timeout": 15
}
]
},
Expand Down
1 change: 1 addition & 0 deletions commitlint.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export default {
extends: ["@commitlint/config-conventional"],
ignores: [(msg) => /^merge[:\s]/i.test(msg)],
rules: {
"type-enum": [
2,
Expand Down
Loading