Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
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
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
6 changes: 6 additions & 0 deletions docs/guides/recommended-practices.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,12 @@ You can configure [Claude Code hooks](https://docs.anthropic.com/en/docs/claude-

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

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

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

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

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

> **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.
Expand Down
2 changes: 1 addition & 1 deletion src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -266,10 +266,10 @@ QUERY_OPTS(
fileExports(file, opts.db, {
noTests: resolveNoTests(opts),
json: opts.json,
unused: opts.unused || false,
limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
ndjson: opts.ndjson,
unused: opts.unused || false,
});
});

Expand Down
2 changes: 1 addition & 1 deletion src/flow.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,10 @@ export function flowData(name, dbPath, opts = {}) {
try {
const maxDepth = opts.depth || 10;
const noTests = opts.noTests || false;
const flowOpts = { ...opts, kinds: opts.kind ? [opts.kind] : CORE_SYMBOL_KINDS };

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

// Phase 2: Prefix-stripped matching — try adding framework prefixes
Expand Down
9 changes: 4 additions & 5 deletions src/queries.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ import { debug } from './logger.js';
import { ownersForFiles } from './owners.js';
import { paginateResult } from './paginate.js';
import { LANGUAGE_REGISTRY } from './parser.js';
import { isTestFile } from './test-filter.js';

// Re-export from dedicated module for backward compat
export { isTestFile, TEST_PATTERN } from './test-filter.js';

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

// Re-export from dedicated module for backward compat
export { isTestFile, TEST_PATTERN } from './test-filter.js';

import { isTestFile } from './test-filter.js';

export const FALSE_POSITIVE_NAMES = new Set([
'run',
'get',
Expand Down
29 changes: 0 additions & 29 deletions tests/unit/queries-unit.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import {
fnImpact,
impactAnalysis,
moduleMap,
queryName,
} from '../../src/queries-cli.js';

// ─── Helpers ───────────────────────────────────────────────────────────
Expand Down Expand Up @@ -380,34 +379,6 @@ describe('diffImpactMermaid', () => {

// ─── Display wrappers ─────────────────────────────────────────────────

describe('queryName (display)', () => {
it('outputs JSON when opts.json is true', () => {
const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
queryName('handleRequest', dbPath, { json: true });
expect(spy).toHaveBeenCalledTimes(1);
const output = JSON.parse(spy.mock.calls[0][0]);
expect(output).toHaveProperty('query', 'handleRequest');
expect(output).toHaveProperty('results');
spy.mockRestore();
});

it('outputs human-readable format', () => {
const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
queryName('handleRequest', dbPath);
const allOutput = spy.mock.calls.map((c) => c[0]).join('\n');
expect(allOutput).toContain('handleRequest');
spy.mockRestore();
});

it('outputs "No results" for unknown name', () => {
const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
queryName('zzzzNotExist', dbPath);
const allOutput = spy.mock.calls.map((c) => c[0]).join('\n');
expect(allOutput).toContain('No results');
spy.mockRestore();
});
});

describe('impactAnalysis (display)', () => {
it('outputs JSON when opts.json is true', () => {
const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
Expand Down
Loading