diff --git a/.claude/hooks/check-cycles.sh b/.claude/hooks/check-cycles.sh new file mode 100644 index 00000000..3885c407 --- /dev/null +++ b/.claude/hooks/check-cycles.sh @@ -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 diff --git a/.claude/hooks/warn-signature-changes.sh b/.claude/hooks/warn-signature-changes.sh new file mode 100644 index 00000000..6ad9b65f --- /dev/null +++ b/.claude/hooks/warn-signature-changes.sh @@ -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 diff --git a/commitlint.config.js b/commitlint.config.js index c4340a23..2e77d9c3 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -1,5 +1,6 @@ export default { extends: ["@commitlint/config-conventional"], + ignores: [(msg) => /^merge[:\s]/i.test(msg)], rules: { "type-enum": [ 2, diff --git a/docs/guides/recommended-practices.md b/docs/guides/recommended-practices.md index 02a90a21..5b28f721 100644 --- a/docs/guides/recommended-practices.md +++ b/docs/guides/recommended-practices.md @@ -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 --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. diff --git a/src/cli.js b/src/cli.js index 8734bc02..3df60e50 100644 --- a/src/cli.js +++ b/src/cli.js @@ -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, }); }); diff --git a/src/flow.js b/src/flow.js index 6c8fa559..0ca1d212 100644 --- a/src/flow.js +++ b/src/flow.js @@ -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 diff --git a/src/queries.js b/src/queries.js index a1f8094e..4c839366 100644 --- a/src/queries.js +++ b/src/queries.js @@ -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. @@ -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', diff --git a/tests/unit/queries-unit.test.js b/tests/unit/queries-unit.test.js index 939ece00..db188420 100644 --- a/tests/unit/queries-unit.test.js +++ b/tests/unit/queries-unit.test.js @@ -23,7 +23,6 @@ import { fnImpact, impactAnalysis, moduleMap, - queryName, } from '../../src/queries-cli.js'; // ─── Helpers ─────────────────────────────────────────────────────────── @@ -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(() => {});