Skip to content

Commit a8fb530

Browse files
authored
fix: break circular dependency cycle and remove dead queryName export (#378)
* perf: skip ensureWasmTrees when native engine provides complete data Before calling ensureWasmTrees, check whether native engine already supplies CFG and dataflow data for all files. When it does, skip the WASM pre-parse entirely — avoiding a full WASM parse of every file on native builds where the data is already available. Impact: 1 functions changed, 14 affected * fix: address PR #344 review comments — TODO for constant exclusion, complete changelog * fix: add null guard on symbols.definitions in pre-parse check Impact: 1 functions changed, 0 affected * fix: treat empty cfg.blocks array as valid native CFG d.cfg?.blocks?.length evaluates to 0 (falsy) when the native engine returns cfg: { blocks: [] } for trivial functions, spuriously triggering needsWasmTrees. Use Array.isArray(d.cfg?.blocks) instead, and preserve d.cfg === null for nodes that intentionally have no CFG (e.g. interface members). Impact: 1 functions changed, 1 affected * style: format cfg check per biome rules Impact: 1 functions changed, 0 affected * feat: add `sequence` command for Mermaid sequence diagrams Generate inter-module call sequence diagrams from the graph DB. Participants are files (not functions), keeping diagrams readable. BFS forward from entry point, with optional --dataflow flag to annotate parameter names and return arrows. New: src/sequence.js, tests/integration/sequence.test.js Modified: cli.js, mcp.js, index.js, CLAUDE.md, mcp.test.js Impact: 8 functions changed, 6 affected * perf: hoist db.prepare() calls and build O(1) lookup map in sequence Impact: 1 functions changed, 1 affected * fix: address sequence review comments — alias collision, truncation, escape, pagination - Fix buildAliases join('/') producing Mermaid-invalid participant IDs; use join('_') and strip '/' from allowed regex chars - Add Mermaid format validation to alias collision test - Fix truncated false-positive when nextFrontier has unvisited nodes at maxDepth boundary - Add angle bracket escaping to escapeMermaid (#gt;/#lt; entities) - Filter orphaned participants after pagination slices messages - Fix misleading test comment (4 files → 5 files) Impact: 1 functions changed, 2 affected * ci: retrigger workflows * fix: address round-3 review — deduplicate findBestMatch, guard truncation, add dataflow tests - Export findMatchingNodes from queries.js; remove duplicated findBestMatch from flow.js and sequence.js, replacing with findMatchingNodes(...)[0] ?? null - Guard sequenceToMermaid truncation note on participants.length > 0 to prevent invalid "note right of undefined:" in Mermaid output - Add dataflow annotation test suite with dedicated fixture DB: covers return arrows, parameter annotations, disabled-dataflow path, and Mermaid dashed arrow output Impact: 4 functions changed, 27 affected * chore: add hook to block "generated with" in PR bodies * feat: add pre-commit hooks for cycle detection and dead export checks Add two new PreToolUse hooks that block git commits when issues are detected: - check-cycles.sh: runs `codegraph check --staged` and blocks if NEW circular dependencies are introduced (compares against baseline count) - check-dead-exports.sh: checks staged src/ files for newly added exports with zero consumers (diff-aware, ignores pre-existing dead exports) Also wire the --unused flag on the exports CLI command, adding totalUnused to all output formats. Impact: 4 functions changed, 5 affected * fix: scope cycle and dead-export hooks to session-edited files Update both hooks to use the session edit log so pre-existing issues in untouched files don't block commits. Add hook descriptions to recommended-practices.md. * feat: add signature change warning hook with role-based risk levels Non-blocking PreToolUse hook that detects when staged changes modify function signatures. Enriches each violation with the symbol's role (core/utility/etc.) and transitive caller count, then injects a risk-rated warning (HIGH/MEDIUM/low) via additionalContext. * fix: break circular dependency cycle and remove dead queryName export Extract isTestFile into src/test-utils.js to break the owners.js → cycles.js → cochange.js → queries.js → boundaries.js cycle. Remove unused queryName display function (CLI uses fnDeps instead). Fix dead-export hook to count test-file consumers (removes false positives for test-only exports like clearCodeownersCache and re-exported queryNameData). Impact: 1 functions changed, 0 affected * fix: convert hook to ESM imports, respect --kind filter in flow Impact: 1 functions changed, 1 affected * fix: align hook deny pattern and fix subdirectory glob in dead-exports check
1 parent 10ac743 commit a8fb530

8 files changed

Lines changed: 237 additions & 36 deletions

File tree

.claude/hooks/check-cycles.sh

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
#!/usr/bin/env bash
2+
# check-cycles.sh — PreToolUse hook for Bash (git commit)
3+
# Blocks commits if circular dependencies involve files edited in this session.
4+
5+
set -euo pipefail
6+
7+
INPUT=$(cat)
8+
9+
# Extract the command from tool_input JSON
10+
COMMAND=$(echo "$INPUT" | node -e "
11+
let d='';
12+
process.stdin.on('data',c=>d+=c);
13+
process.stdin.on('end',()=>{
14+
const p=JSON.parse(d).tool_input?.command||'';
15+
if(p)process.stdout.write(p);
16+
});
17+
" 2>/dev/null) || true
18+
19+
if [ -z "$COMMAND" ]; then
20+
exit 0
21+
fi
22+
23+
# Only trigger on git commit commands
24+
if ! echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+commit\b'; then
25+
exit 0
26+
fi
27+
28+
# Guard: codegraph DB must exist
29+
WORK_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || WORK_ROOT="${CLAUDE_PROJECT_DIR:-.}"
30+
if [ ! -f "$WORK_ROOT/.codegraph/graph.db" ]; then
31+
exit 0
32+
fi
33+
34+
# Guard: must have staged changes
35+
STAGED=$(git diff --cached --name-only 2>/dev/null) || true
36+
if [ -z "$STAGED" ]; then
37+
exit 0
38+
fi
39+
40+
# Load session edit log
41+
LOG_FILE="$WORK_ROOT/.claude/session-edits.log"
42+
if [ ! -f "$LOG_FILE" ] || [ ! -s "$LOG_FILE" ]; then
43+
exit 0
44+
fi
45+
EDITED_FILES=$(awk '{print $2}' "$LOG_FILE" | sort -u)
46+
47+
# Run check with cycles predicate on staged changes
48+
RESULT=$(node "$WORK_ROOT/src/cli.js" check --staged --json -T 2>/dev/null) || true
49+
50+
if [ -z "$RESULT" ]; then
51+
exit 0
52+
fi
53+
54+
# Check if cycles predicate failed — but only block if a cycle involves
55+
# a file that was edited in this session
56+
CYCLES_FAILED=$(echo "$RESULT" | EDITED="$EDITED_FILES" node -e "
57+
let d='';
58+
process.stdin.on('data',c=>d+=c);
59+
process.stdin.on('end',()=>{
60+
try {
61+
const data=JSON.parse(d);
62+
const cyclesPred=(data.predicates||[]).find(p=>p.name==='cycles');
63+
if(!cyclesPred || cyclesPred.passed) return;
64+
const edited=new Set(process.env.EDITED.split('\\n').filter(Boolean));
65+
// Filter to cycles that involve at least one file we edited
66+
const relevant=(cyclesPred.cycles||[]).filter(
67+
cycle=>cycle.some(f=>edited.has(f))
68+
);
69+
if(relevant.length===0) return;
70+
const summary=relevant.slice(0,5).map(c=>c.join(' -> ')).join('\\n ');
71+
const extra=relevant.length>5?'\\n ... and '+(relevant.length-5)+' more':'';
72+
process.stdout.write(summary+extra);
73+
}catch{}
74+
});
75+
" 2>/dev/null) || true
76+
77+
if [ -n "$CYCLES_FAILED" ]; then
78+
REASON="BLOCKED: Circular dependencies detected involving files you edited:
79+
$CYCLES_FAILED
80+
Fix the cycles before committing."
81+
82+
node -e "
83+
console.log(JSON.stringify({
84+
hookSpecificOutput: {
85+
hookEventName: 'PreToolUse',
86+
permissionDecision: 'deny',
87+
permissionDecisionReason: process.argv[1]
88+
}
89+
}));
90+
" "$REASON"
91+
exit 0
92+
fi
93+
94+
exit 0
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
#!/usr/bin/env bash
2+
# warn-signature-changes.sh — PreToolUse hook for Bash (git commit)
3+
# Warns when staged changes modify function signatures, highlighting risk
4+
# level based on the symbol's role (core > utility > others).
5+
# Informational only — never blocks.
6+
7+
set -euo pipefail
8+
9+
INPUT=$(cat)
10+
11+
# Extract the command from tool_input JSON
12+
COMMAND=$(echo "$INPUT" | node -e "
13+
let d='';
14+
process.stdin.on('data',c=>d+=c);
15+
process.stdin.on('end',()=>{
16+
const p=JSON.parse(d).tool_input?.command||'';
17+
if(p)process.stdout.write(p);
18+
});
19+
" 2>/dev/null) || true
20+
21+
if [ -z "$COMMAND" ]; then
22+
exit 0
23+
fi
24+
25+
# Only trigger on git commit commands
26+
if ! echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+commit\b'; then
27+
exit 0
28+
fi
29+
30+
# Guard: codegraph DB must exist
31+
WORK_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || WORK_ROOT="${CLAUDE_PROJECT_DIR:-.}"
32+
if [ ! -f "$WORK_ROOT/.codegraph/graph.db" ]; then
33+
exit 0
34+
fi
35+
36+
# Guard: must have staged changes
37+
STAGED=$(git diff --cached --name-only 2>/dev/null) || true
38+
if [ -z "$STAGED" ]; then
39+
exit 0
40+
fi
41+
42+
# Run check --staged to get signature violations, then enrich with role + caller count
43+
WARNING=$(echo "" | node --input-type=module -e "
44+
import path from 'path';
45+
const workRoot = process.argv[2];
46+
const { checkData } = await import(path.join(workRoot, 'src/check.js'));
47+
const { openReadonlyOrFail } = await import(path.join(workRoot, 'src/db.js'));
48+
49+
const result = checkData(undefined, { staged: true, noTests: true });
50+
if (!result || result.error) process.exit(0);
51+
52+
const sigPred = (result.predicates || []).find(p => p.name === 'signatures');
53+
if (!sigPred || sigPred.passed || !sigPred.violations.length) process.exit(0);
54+
55+
const db = openReadonlyOrFail();
56+
const lines = [];
57+
58+
for (const v of sigPred.violations) {
59+
// Get role from DB
60+
const node = db.prepare(
61+
'SELECT role FROM nodes WHERE name = ? AND file = ? AND line = ?'
62+
).get(v.name, v.file, v.line);
63+
const role = node?.role || 'unknown';
64+
65+
// Count transitive callers (BFS, depth 3)
66+
const defNode = db.prepare(
67+
'SELECT id FROM nodes WHERE name = ? AND file = ? AND line = ?'
68+
).get(v.name, v.file, v.line);
69+
70+
let callerCount = 0;
71+
if (defNode) {
72+
const visited = new Set([defNode.id]);
73+
let frontier = [defNode.id];
74+
for (let d = 0; d < 3; d++) {
75+
const next = [];
76+
for (const fid of frontier) {
77+
const callers = db.prepare(
78+
'SELECT DISTINCT n.id FROM edges e JOIN nodes n ON e.source_id = n.id WHERE e.target_id = ? AND e.kind = \\'calls\\''
79+
).all(fid);
80+
for (const c of callers) {
81+
if (!visited.has(c.id)) {
82+
visited.add(c.id);
83+
next.push(c.id);
84+
callerCount++;
85+
}
86+
}
87+
}
88+
frontier = next;
89+
if (!frontier.length) break;
90+
}
91+
}
92+
93+
const risk = role === 'core' ? 'HIGH' : role === 'utility' ? 'MEDIUM' : 'low';
94+
lines.push(risk + ': ' + v.name + ' (' + v.kind + ') [' + role + '] at ' + v.file + ':' + v.line + ' — ' + callerCount + ' transitive callers');
95+
}
96+
97+
db.close();
98+
99+
if (lines.length > 0) {
100+
process.stdout.write(lines.join('\\n'));
101+
}
102+
" -- "$WORK_ROOT" 2>/dev/null) || true
103+
104+
if [ -z "$WARNING" ]; then
105+
exit 0
106+
fi
107+
108+
# Escape for JSON
109+
ESCAPED=$(printf '%s' "$WARNING" | node -e "
110+
let d='';
111+
process.stdin.on('data',c=>d+=c);
112+
process.stdin.on('end',()=>process.stdout.write(JSON.stringify(d)));
113+
" 2>/dev/null) || true
114+
115+
if [ -z "$ESCAPED" ]; then
116+
exit 0
117+
fi
118+
119+
# Inject as additionalContext — informational, never blocks
120+
node -e "
121+
console.log(JSON.stringify({
122+
hookSpecificOutput: {
123+
hookEventName: 'PreToolUse',
124+
permissionDecision: 'allow',
125+
additionalContext: '[codegraph] Signature changes detected in staged files:\\n' + JSON.parse(process.argv[1])
126+
}
127+
}));
128+
" "$ESCAPED" 2>/dev/null || true
129+
130+
exit 0

commitlint.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export default {
22
extends: ["@commitlint/config-conventional"],
3+
ignores: [(msg) => /^merge[:\s]/i.test(msg)],
34
rules: {
45
"type-enum": [
56
2,

docs/guides/recommended-practices.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,12 @@ You can configure [Claude Code hooks](https://docs.anthropic.com/en/docs/claude-
335335

336336
**Graph update hook** (PostToolUse on Edit/Write): keeps the graph incrementally updated after each file edit. Only changed files are re-parsed.
337337

338+
**Cycle check hook** (PreToolUse on Bash): when Claude runs `git commit`, the hook runs `codegraph check --staged --json -T` and checks if any circular dependencies involve files edited in this session. If found, blocks the commit with a `deny` decision listing the cycles. Uses the session edit log to scope checks — pre-existing cycles in untouched files don't trigger.
339+
340+
**Dead export check hook** (PreToolUse on Bash): when Claude runs `git commit`, the hook runs `codegraph exports <file> --unused --json -T` for each staged `src/` file that was edited in this session. If any export has zero consumers (dead code), blocks the commit. This catches accidentally introduced dead exports before they reach a PR.
341+
342+
**Signature change warning hook** (PreToolUse on Bash): when Claude runs `git commit`, the hook runs `codegraph check --staged` to detect modified function declaration lines, then enriches each violation with the symbol's role (`core`, `utility`, etc.) and transitive caller count from the graph. Injects a risk-rated summary via `additionalContext` — `HIGH` for core symbols, `MEDIUM` for utility, `low` for others. Non-blocking — the agent sees the warning and can decide whether the signature change is intentional.
343+
338344
**Git operation hook** (PostToolUse on Bash): detects `git rebase`, `git revert`, `git cherry-pick`, `git merge`, and `git pull` commands and automatically: (1) rebuilds the codegraph so dependency context stays fresh, (2) logs all files changed by the operation to `session-edits.log` so commit validation doesn't block rebase-modified files, and (3) clears stale entries from `codegraph-checked.log` so the edit reminder re-fires for affected files. Uses `ORIG_HEAD` (set by all these git operations) to detect which files changed. If the operation failed (e.g. merge conflicts), the diff safely returns nothing.
339345

340346
> **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.

src/cli.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,10 +266,10 @@ QUERY_OPTS(
266266
fileExports(file, opts.db, {
267267
noTests: resolveNoTests(opts),
268268
json: opts.json,
269+
unused: opts.unused || false,
269270
limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
270271
offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
271272
ndjson: opts.ndjson,
272-
unused: opts.unused || false,
273273
});
274274
});
275275

src/flow.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,10 @@ export function flowData(name, dbPath, opts = {}) {
9999
try {
100100
const maxDepth = opts.depth || 10;
101101
const noTests = opts.noTests || false;
102+
const flowOpts = { ...opts, kinds: opts.kind ? [opts.kind] : CORE_SYMBOL_KINDS };
102103

103104
// Phase 1: Direct LIKE match on full name (use all 10 core symbol kinds,
104105
// not just FUNCTION_KINDS, so flow can trace from interfaces/types/structs/etc.)
105-
const flowOpts = { ...opts, kinds: opts.kind ? [opts.kind] : CORE_SYMBOL_KINDS };
106106
let matchNode = findMatchingNodes(db, name, flowOpts)[0] ?? null;
107107

108108
// Phase 2: Prefix-stripped matching — try adding framework prefixes

src/queries.js

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ import { debug } from './logger.js';
1818
import { ownersForFiles } from './owners.js';
1919
import { paginateResult } from './paginate.js';
2020
import { LANGUAGE_REGISTRY } from './parser.js';
21+
import { isTestFile } from './test-filter.js';
22+
23+
// Re-export from dedicated module for backward compat
24+
export { isTestFile, TEST_PATTERN } from './test-filter.js';
2125

2226
/**
2327
* Resolve a file path relative to repoRoot, rejecting traversal outside the repo.
@@ -29,11 +33,6 @@ function safePath(repoRoot, file) {
2933
return resolved;
3034
}
3135

32-
// Re-export from dedicated module for backward compat
33-
export { isTestFile, TEST_PATTERN } from './test-filter.js';
34-
35-
import { isTestFile } from './test-filter.js';
36-
3736
export const FALSE_POSITIVE_NAMES = new Set([
3837
'run',
3938
'get',

tests/unit/queries-unit.test.js

Lines changed: 0 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import {
2323
fnImpact,
2424
impactAnalysis,
2525
moduleMap,
26-
queryName,
2726
} from '../../src/queries-cli.js';
2827

2928
// ─── Helpers ───────────────────────────────────────────────────────────
@@ -380,34 +379,6 @@ describe('diffImpactMermaid', () => {
380379

381380
// ─── Display wrappers ─────────────────────────────────────────────────
382381

383-
describe('queryName (display)', () => {
384-
it('outputs JSON when opts.json is true', () => {
385-
const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
386-
queryName('handleRequest', dbPath, { json: true });
387-
expect(spy).toHaveBeenCalledTimes(1);
388-
const output = JSON.parse(spy.mock.calls[0][0]);
389-
expect(output).toHaveProperty('query', 'handleRequest');
390-
expect(output).toHaveProperty('results');
391-
spy.mockRestore();
392-
});
393-
394-
it('outputs human-readable format', () => {
395-
const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
396-
queryName('handleRequest', dbPath);
397-
const allOutput = spy.mock.calls.map((c) => c[0]).join('\n');
398-
expect(allOutput).toContain('handleRequest');
399-
spy.mockRestore();
400-
});
401-
402-
it('outputs "No results" for unknown name', () => {
403-
const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
404-
queryName('zzzzNotExist', dbPath);
405-
const allOutput = spy.mock.calls.map((c) => c[0]).join('\n');
406-
expect(allOutput).toContain('No results');
407-
spy.mockRestore();
408-
});
409-
});
410-
411382
describe('impactAnalysis (display)', () => {
412383
it('outputs JSON when opts.json is true', () => {
413384
const spy = vi.spyOn(console, 'log').mockImplementation(() => {});

0 commit comments

Comments
 (0)