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/check-dead-exports.sh b/.claude/hooks/check-dead-exports.sh new file mode 100644 index 00000000..4f8140b7 --- /dev/null +++ b/.claude/hooks/check-dead-exports.sh @@ -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 + + # 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 -T 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||[]).filter(r=>r.consumerCount===0); + 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 diff --git a/.claude/hooks/guard-pr-body.sh b/.claude/hooks/guard-pr-body.sh new file mode 100644 index 00000000..df2e7726 --- /dev/null +++ b/.claude/hooks/guard-pr-body.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Block PR creation if the body contains "generated with" (case-insensitive) + +set -euo pipefail + +INPUT=$(cat) + +# Extract just the command field to avoid false positives on the description field +cmd=$(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 + +echo "$cmd" | grep -qi 'gh pr create' || exit 0 + +# Block if body contains "generated with" +if echo "$cmd" | grep -qi 'generated with'; then + echo "BLOCK: Remove any 'Generated with ...' line from the PR body." >&2 + exit 2 +fi diff --git a/.claude/hooks/warn-signature-changes.sh b/.claude/hooks/warn-signature-changes.sh new file mode 100644 index 00000000..b1bb599f --- /dev/null +++ b/.claude/hooks/warn-signature-changes.sh @@ -0,0 +1,126 @@ +#!/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=$(node -e " + const path = require('path'); + const { checkData } = require(path.join(process.argv[1], 'src/check.js')); + const { openReadonlyOrFail } = require(path.join(process.argv[1], '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 = []; + + const stmtNode = db.prepare( + 'SELECT id, role FROM nodes WHERE name = ? AND file = ? AND line = ?' + ); + const stmtCallers = 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\\'' + ); + + for (const v of sigPred.violations) { + const node = stmtNode.get(v.name, v.file, v.line); + const role = node?.role || 'unknown'; + + let callerCount = 0; + if (node) { + const visited = new Set([node.id]); + let frontier = [node.id]; + for (let d = 0; d < 3; d++) { + const next = []; + for (const fid of frontier) { + const callers = stmtCallers.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/.claude/settings.json b/.claude/settings.json index 4ffe2530..fe942475 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -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 } ] }, diff --git a/CHANGELOG.md b/CHANGELOG.md index 03b5a615..ad0b69db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,11 @@ All notable changes to this project will be documented in this file. See [commit ### Performance * **ast:** use single transaction for AST node insertion — astMs drops from ~3600ms to ~350ms (native) and ~547ms (WASM), reducing overall native build from 24.9 to 8.5 ms/file ([#333](https://github.com/optave/codegraph/pull/333)) +* **builder:** skip `ensureWasmTrees` when native engine provides complete CFG + dataflow data ([#344](https://github.com/optave/codegraph/pull/344)) + +### Bug Fixes + +* **native:** fix function-scoped `const` declarations being incorrectly extracted as top-level constants ([#344](https://github.com/optave/codegraph/pull/344)) ## [3.0.2](https://github.com/optave/codegraph/compare/v3.0.1...v3.0.2) (2026-03-04) diff --git a/docs/guides/recommended-practices.md b/docs/guides/recommended-practices.md index f4bfdc6b..030db0c8 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. @@ -348,6 +354,9 @@ You can configure [Claude Code hooks](https://docs.anthropic.com/en/docs/claude- - `guard-git.sh` — blocks dangerous git commands + validates commits - `track-edits.sh` — logs edited files for commit validation - `track-moves.sh` — logs file moves/copies for commit validation +- `check-cycles.sh` — blocks commits if circular dependencies involve files you edited +- `check-dead-exports.sh` — blocks commits if files you edited contain exports with zero consumers +- `warn-signature-changes.sh` — warns (non-blocking) when staged changes modify function signatures, with risk level based on symbol role and transitive caller count #### Parallel session safety hooks diff --git a/src/cli.js b/src/cli.js index d299a7f0..3df60e50 100644 --- a/src/cli.js +++ b/src/cli.js @@ -261,15 +261,15 @@ QUERY_OPTS( .command('exports ') .description('Show exported symbols with per-symbol consumers (who calls each export)'), ) - .option('--unused', 'Show only exports with zero consumers') + .option('--unused', 'Show only exports with zero consumers (dead exports)') .action((file, 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, }); }); diff --git a/src/flow.js b/src/flow.js index 84bbc40b..6679df83 100644 --- a/src/flow.js +++ b/src/flow.js @@ -7,7 +7,7 @@ import { openReadonlyOrFail } from './db.js'; import { paginateResult } from './paginate.js'; -import { findMatchingNodes, kindIcon } from './queries.js'; +import { CORE_SYMBOL_KINDS, findMatchingNodes, kindIcon } from './queries.js'; import { outputResult } from './result-formatter.js'; import { FRAMEWORK_ENTRY_PREFIXES } from './structure.js'; import { isTestFile } from './test-filter.js'; @@ -96,13 +96,15 @@ export function flowData(name, dbPath, opts = {}) { const maxDepth = opts.depth || 10; const noTests = opts.noTests || false; - // Phase 1: Direct LIKE match on full name - let matchNode = findMatchingNodes(db, name, opts)[0] ?? null; + // 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: CORE_SYMBOL_KINDS }; + let matchNode = findMatchingNodes(db, name, flowOpts)[0] ?? null; // Phase 2: Prefix-stripped matching — try adding framework prefixes if (!matchNode) { for (const prefix of FRAMEWORK_ENTRY_PREFIXES) { - matchNode = findMatchingNodes(db, `${prefix}${name}`, opts)[0] ?? null; + matchNode = findMatchingNodes(db, `${prefix}${name}`, flowOpts)[0] ?? null; if (matchNode) break; } } diff --git a/src/queries.js b/src/queries.js index f6eeb64e..6b11d324 100644 --- a/src/queries.js +++ b/src/queries.js @@ -164,7 +164,7 @@ function resolveMethodViaHierarchy(db, methodName) { * Scoring: exact=100, prefix=60, word-boundary=40, substring=10, plus fan-in tiebreaker. */ export function findMatchingNodes(db, name, opts = {}) { - const kinds = opts.kind ? [opts.kind] : FUNCTION_KINDS; + const kinds = opts.kind ? [opts.kind] : opts.kinds || FUNCTION_KINDS; const placeholders = kinds.map(() => '?').join(', '); const params = [`%${name}%`, ...kinds]; diff --git a/src/sequence.js b/src/sequence.js index 840361d7..5b5d11c4 100644 --- a/src/sequence.js +++ b/src/sequence.js @@ -87,208 +87,209 @@ function buildAliases(files) { */ export function sequenceData(name, dbPath, opts = {}) { const db = openReadonlyOrFail(dbPath); - const maxDepth = opts.depth || 10; - const noTests = opts.noTests || false; - const withDataflow = opts.dataflow || false; - - // Phase 1: Direct LIKE match - let matchNode = findMatchingNodes(db, name, opts)[0] ?? null; - - // Phase 2: Prefix-stripped matching - if (!matchNode) { - for (const prefix of FRAMEWORK_ENTRY_PREFIXES) { - matchNode = findMatchingNodes(db, `${prefix}${name}`, opts)[0] ?? null; - if (matchNode) break; + try { + const maxDepth = opts.depth || 10; + const noTests = opts.noTests || false; + const withDataflow = opts.dataflow || false; + + // Phase 1: Direct LIKE match + let matchNode = findMatchingNodes(db, name, opts)[0] ?? null; + + // Phase 2: Prefix-stripped matching + if (!matchNode) { + for (const prefix of FRAMEWORK_ENTRY_PREFIXES) { + matchNode = findMatchingNodes(db, `${prefix}${name}`, opts)[0] ?? null; + if (matchNode) break; + } } - } - if (!matchNode) { - db.close(); - return { - entry: null, - participants: [], - messages: [], - depth: maxDepth, - totalMessages: 0, - truncated: false, + if (!matchNode) { + return { + entry: null, + participants: [], + messages: [], + depth: maxDepth, + totalMessages: 0, + truncated: false, + }; + } + + const entry = { + name: matchNode.name, + file: matchNode.file, + kind: matchNode.kind, + line: matchNode.line, }; - } - const entry = { - name: matchNode.name, - file: matchNode.file, - kind: matchNode.kind, - line: matchNode.line, - }; - - // BFS forward — track edges, not just nodes - const visited = new Set([matchNode.id]); - let frontier = [matchNode.id]; - const messages = []; - const fileSet = new Set([matchNode.file]); - const idToNode = new Map(); - idToNode.set(matchNode.id, matchNode); - let truncated = false; - - const getCallees = db.prepare( - `SELECT DISTINCT n.id, n.name, n.kind, n.file, n.line - FROM edges e JOIN nodes n ON e.target_id = n.id - WHERE e.source_id = ? AND e.kind = 'calls'`, - ); - - for (let d = 1; d <= maxDepth; d++) { - const nextFrontier = []; - - for (const fid of frontier) { - const callees = getCallees.all(fid); - - const caller = idToNode.get(fid); - - for (const c of callees) { - if (noTests && isTestFile(c.file)) continue; - - // Always record the message (even for visited nodes — different caller path) - fileSet.add(c.file); - messages.push({ - from: caller.file, - to: c.file, - label: c.name, - type: 'call', - depth: d, - }); - - if (visited.has(c.id)) continue; - - visited.add(c.id); - nextFrontier.push(c.id); - idToNode.set(c.id, c); - } - } + // BFS forward — track edges, not just nodes + const visited = new Set([matchNode.id]); + let frontier = [matchNode.id]; + const messages = []; + const fileSet = new Set([matchNode.file]); + const idToNode = new Map(); + idToNode.set(matchNode.id, matchNode); + let truncated = false; + + const getCallees = db.prepare( + `SELECT DISTINCT n.id, n.name, n.kind, n.file, n.line + FROM edges e JOIN nodes n ON e.target_id = n.id + WHERE e.source_id = ? AND e.kind = 'calls'`, + ); - frontier = nextFrontier; - if (frontier.length === 0) break; + for (let d = 1; d <= maxDepth; d++) { + const nextFrontier = []; - if (d === maxDepth && frontier.length > 0) { - // Only mark truncated if at least one frontier node has further callees - const hasMoreCalls = frontier.some((fid) => getCallees.all(fid).length > 0); - if (hasMoreCalls) truncated = true; - } - } + for (const fid of frontier) { + const callees = getCallees.all(fid); - // Dataflow annotations: add return arrows - if (withDataflow && messages.length > 0) { - const hasTable = db - .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='dataflow'") - .get(); - - if (hasTable) { - // Build name|file lookup for O(1) target node access - const nodeByNameFile = new Map(); - for (const n of idToNode.values()) { - nodeByNameFile.set(`${n.name}|${n.file}`, n); - } + const caller = idToNode.get(fid); + + for (const c of callees) { + if (noTests && isTestFile(c.file)) continue; - const getReturns = db.prepare( - `SELECT d.expression FROM dataflow d - WHERE d.source_id = ? AND d.kind = 'returns'`, - ); - const getFlowsTo = db.prepare( - `SELECT d.expression FROM dataflow d - WHERE d.target_id = ? AND d.kind = 'flows_to' - ORDER BY d.param_index`, - ); - - // For each called function, check if it has return edges - const seenReturns = new Set(); - for (const msg of [...messages]) { - if (msg.type !== 'call') continue; - const targetNode = nodeByNameFile.get(`${msg.label}|${msg.to}`); - if (!targetNode) continue; - - const returnKey = `${msg.to}->${msg.from}:${msg.label}`; - if (seenReturns.has(returnKey)) continue; - - const returns = getReturns.all(targetNode.id); - - if (returns.length > 0) { - seenReturns.add(returnKey); - const expr = returns[0].expression || 'result'; + // Always record the message (even for visited nodes — different caller path) + fileSet.add(c.file); messages.push({ - from: msg.to, - to: msg.from, - label: expr, - type: 'return', - depth: msg.depth, + from: caller.file, + to: c.file, + label: c.name, + type: 'call', + depth: d, }); + + if (visited.has(c.id)) continue; + + visited.add(c.id); + nextFrontier.push(c.id); + idToNode.set(c.id, c); } } - // Annotate call messages with parameter names - for (const msg of messages) { - if (msg.type !== 'call') continue; - const targetNode = nodeByNameFile.get(`${msg.label}|${msg.to}`); - if (!targetNode) continue; - - const params = getFlowsTo.all(targetNode.id); - - if (params.length > 0) { - const paramNames = params - .map((p) => p.expression) - .filter(Boolean) - .slice(0, 3); - if (paramNames.length > 0) { - msg.label = `${msg.label}(${paramNames.join(', ')})`; + frontier = nextFrontier; + if (frontier.length === 0) break; + + if (d === maxDepth && frontier.length > 0) { + // Only mark truncated if at least one frontier node has further callees + const hasMoreCalls = frontier.some((fid) => getCallees.all(fid).length > 0); + if (hasMoreCalls) truncated = true; + } + } + + // Dataflow annotations: add return arrows + if (withDataflow && messages.length > 0) { + const hasTable = db + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='dataflow'") + .get(); + + if (hasTable) { + // Build name|file lookup for O(1) target node access + const nodeByNameFile = new Map(); + for (const n of idToNode.values()) { + nodeByNameFile.set(`${n.name}|${n.file}`, n); + } + + const getReturns = db.prepare( + `SELECT d.expression FROM dataflow d + WHERE d.source_id = ? AND d.kind = 'returns'`, + ); + const getFlowsTo = db.prepare( + `SELECT d.expression FROM dataflow d + WHERE d.target_id = ? AND d.kind = 'flows_to' + ORDER BY d.param_index`, + ); + + // For each called function, check if it has return edges + const seenReturns = new Set(); + for (const msg of [...messages]) { + if (msg.type !== 'call') continue; + const targetNode = nodeByNameFile.get(`${msg.label}|${msg.to}`); + if (!targetNode) continue; + + const returnKey = `${msg.to}->${msg.from}:${msg.label}`; + if (seenReturns.has(returnKey)) continue; + + const returns = getReturns.all(targetNode.id); + + if (returns.length > 0) { + seenReturns.add(returnKey); + const expr = returns[0].expression || 'result'; + messages.push({ + from: msg.to, + to: msg.from, + label: expr, + type: 'return', + depth: msg.depth, + }); + } + } + + // Annotate call messages with parameter names + for (const msg of messages) { + if (msg.type !== 'call') continue; + const targetNode = nodeByNameFile.get(`${msg.label}|${msg.to}`); + if (!targetNode) continue; + + const params = getFlowsTo.all(targetNode.id); + + if (params.length > 0) { + const paramNames = params + .map((p) => p.expression) + .filter(Boolean) + .slice(0, 3); + if (paramNames.length > 0) { + msg.label = `${msg.label}(${paramNames.join(', ')})`; + } } } } } - } - // Sort messages by depth, then call before return - messages.sort((a, b) => { - if (a.depth !== b.depth) return a.depth - b.depth; - if (a.type === 'call' && b.type === 'return') return -1; - if (a.type === 'return' && b.type === 'call') return 1; - return 0; - }); - - // Build participant list from files - const aliases = buildAliases([...fileSet]); - const participants = [...fileSet].map((file) => ({ - id: aliases.get(file), - label: file.split('/').pop(), - file, - })); - - // Sort participants: entry file first, then alphabetically - participants.sort((a, b) => { - if (a.file === entry.file) return -1; - if (b.file === entry.file) return 1; - return a.file.localeCompare(b.file); - }); - - // Replace file paths with alias IDs in messages - for (const msg of messages) { - msg.from = aliases.get(msg.from); - msg.to = aliases.get(msg.to); - } + // Sort messages by depth, then call before return + messages.sort((a, b) => { + if (a.depth !== b.depth) return a.depth - b.depth; + if (a.type === 'call' && b.type === 'return') return -1; + if (a.type === 'return' && b.type === 'call') return 1; + return 0; + }); + + // Build participant list from files + const aliases = buildAliases([...fileSet]); + const participants = [...fileSet].map((file) => ({ + id: aliases.get(file), + label: file.split('/').pop(), + file, + })); + + // Sort participants: entry file first, then alphabetically + participants.sort((a, b) => { + if (a.file === entry.file) return -1; + if (b.file === entry.file) return 1; + return a.file.localeCompare(b.file); + }); + + // Replace file paths with alias IDs in messages + for (const msg of messages) { + msg.from = aliases.get(msg.from); + msg.to = aliases.get(msg.to); + } - db.close(); - - const base = { - entry, - participants, - messages, - depth: maxDepth, - totalMessages: messages.length, - truncated, - }; - const result = paginateResult(base, 'messages', { limit: opts.limit, offset: opts.offset }); - if (opts.limit !== undefined || opts.offset !== undefined) { - const activeFiles = new Set(result.messages.flatMap((m) => [m.from, m.to])); - result.participants = result.participants.filter((p) => activeFiles.has(p.id)); + const base = { + entry, + participants, + messages, + depth: maxDepth, + totalMessages: messages.length, + truncated, + }; + const result = paginateResult(base, 'messages', { limit: opts.limit, offset: opts.offset }); + if (opts.limit !== undefined || opts.offset !== undefined) { + const activeFiles = new Set(result.messages.flatMap((m) => [m.from, m.to])); + result.participants = result.participants.filter((p) => activeFiles.has(p.id)); + } + return result; + } finally { + db.close(); } - return result; } // ─── Mermaid formatter ─────────────────────────────────────────────── diff --git a/src/triage.js b/src/triage.js index 000397d0..6d2b73ba 100644 --- a/src/triage.js +++ b/src/triage.js @@ -105,6 +105,7 @@ export function triageData(customDbPath, opts = {}) { return { items: [], summary: { total: 0, analyzed: 0, avgScore: 0, maxScore: 0, weights, signalCoverage: {} }, + error: err.message, }; } @@ -222,7 +223,9 @@ export function triage(customDbPath, opts = {}) { if (outputResult(data, 'items', opts)) return; if (data.items.length === 0) { - if (data.summary.total === 0) { + if (data.error) { + console.error(`\nError: ${data.error}\n`); + } else if (data.summary.total === 0) { console.log('\nNo symbols found. Run "codegraph build" first.\n'); } else { console.log('\nNo symbols match the given filters.\n'); diff --git a/tests/integration/triage.test.js b/tests/integration/triage.test.js index f9727d5b..0afe1c3b 100644 --- a/tests/integration/triage.test.js +++ b/tests/integration/triage.test.js @@ -303,6 +303,38 @@ describe('triage', () => { fs.rmSync(sparseDir, { recursive: true, force: true }); }); + test('query error propagates error field instead of misleading "no symbols" message', async () => { + // Create a DB missing the function_complexity table to trigger a query error + const brokenDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-triage-broken-')); + fs.mkdirSync(path.join(brokenDir, '.codegraph')); + const brokenDbPath = path.join(brokenDir, '.codegraph', 'graph.db'); + + const db = new Database(brokenDbPath); + db.pragma('journal_mode = WAL'); + // Create only the nodes table — omit function_complexity and file_commit_counts + db.exec(` + CREATE TABLE IF NOT EXISTS nodes ( + id INTEGER PRIMARY KEY, + name TEXT, kind TEXT, file TEXT, line INTEGER, end_line INTEGER, role TEXT + ); + CREATE TABLE IF NOT EXISTS edges ( + source_id INTEGER, target_id INTEGER, kind TEXT + ); + `); + insertNode(db, 'foo', 'function', 'src/foo.js', 1, { role: 'core' }); + db.close(); + + const result = triageData(brokenDbPath, { limit: 100 }); + // Should have error field with the real error message + expect(result.error).toBeDefined(); + expect(result.error).toMatch(/function_complexity/i); + // Should still return empty items + expect(result.items).toEqual([]); + expect(result.summary.total).toBe(0); + + fs.rmSync(brokenDir, { recursive: true, force: true }); + }); + test('role weights applied correctly', () => { const result = triageData(dbPath, { limit: 100,