From 5baf3ad63843fe363ed2b85e4399a3cf3fa4848e Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 4 Mar 2026 19:28:41 -0700 Subject: [PATCH 01/21] perf: skip ensureWasmTrees when native engine provides complete data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/builder.js | 44 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/src/builder.js b/src/builder.js index c5019b43..28637dc3 100644 --- a/src/builder.js +++ b/src/builder.js @@ -1317,16 +1317,44 @@ export async function buildGraph(rootDir, opts = {}) { _t.complexityMs = performance.now() - _t.complexity0; // Pre-parse files missing WASM trees (native builds) so CFG + dataflow - // share a single parse pass instead of each creating parsers independently + // share a single parse pass instead of each creating parsers independently. + // Skip entirely when native engine already provides CFG + dataflow data. if (opts.cfg !== false || opts.dataflow !== false) { - _t.wasmPre0 = performance.now(); - try { - const { ensureWasmTrees } = await import('./parser.js'); - await ensureWasmTrees(astComplexitySymbols, rootDir); - } catch (err) { - debug(`WASM pre-parse failed: ${err.message}`); + const needsCfg = opts.cfg !== false; + const needsDataflow = opts.dataflow !== false; + + let needsWasmTrees = false; + for (const [, symbols] of astComplexitySymbols) { + if (symbols._tree) continue; // already has a tree + // CFG: need tree if any function/method def lacks native CFG + if (needsCfg) { + const fnDefs = symbols.definitions.filter( + (d) => (d.kind === 'function' || d.kind === 'method') && d.line, + ); + if (fnDefs.length > 0 && !fnDefs.every((d) => d.cfg?.blocks?.length)) { + needsWasmTrees = true; + break; + } + } + // Dataflow: need tree if file lacks native dataflow + if (needsDataflow && !symbols.dataflow) { + needsWasmTrees = true; + break; + } + } + + if (needsWasmTrees) { + _t.wasmPre0 = performance.now(); + try { + const { ensureWasmTrees } = await import('./parser.js'); + await ensureWasmTrees(astComplexitySymbols, rootDir); + } catch (err) { + debug(`WASM pre-parse failed: ${err.message}`); + } + _t.wasmPreMs = performance.now() - _t.wasmPre0; + } else { + _t.wasmPreMs = 0; } - _t.wasmPreMs = performance.now() - _t.wasmPre0; } // CFG analysis (skip with --no-cfg) From d411ab6f3c57a587e36fa7eccf611ef953259548 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 4 Mar 2026 20:09:58 -0700 Subject: [PATCH 02/21] =?UTF-8?q?fix:=20address=20PR=20#344=20review=20com?= =?UTF-8?q?ments=20=E2=80=94=20TODO=20for=20constant=20exclusion,=20comple?= =?UTF-8?q?te=20changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++++++ tests/integration/build-parity.test.js | 2 ++ 2 files changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4573de31..3770c713 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,16 @@ All notable changes to this project will be documented in this file. See [commit ## [3.0.3](https://github.com/optave/codegraph/compare/v3.0.2...v3.0.3) (2026-03-04) +> **Note:** 3.0.2 was an internal/unpublished version used during development. + ### 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/tests/integration/build-parity.test.js b/tests/integration/build-parity.test.js index 566d2019..d2f2e8a8 100644 --- a/tests/integration/build-parity.test.js +++ b/tests/integration/build-parity.test.js @@ -35,6 +35,8 @@ function readGraph(dbPath) { // Exclude constant nodes — the native engine has a known scope bug where it // extracts local `const` variables inside functions as top-level constants, // while WASM correctly limits constant extraction to program-level declarations. + // TODO: Remove kind != 'constant' exclusion once native binary >= 3.0.4 ships + // Fix: crates/codegraph-core/src/extractors/javascript.rs (find_parent_of_types guard) const nodes = db .prepare( "SELECT name, kind, file, line FROM nodes WHERE kind != 'constant' ORDER BY name, kind, file, line", From 4317e0df98fb83dc9aa840aeb1420f3034fdf6eb Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 4 Mar 2026 20:24:41 -0700 Subject: [PATCH 03/21] fix: add null guard on symbols.definitions in pre-parse check Impact: 1 functions changed, 0 affected --- src/builder.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/builder.js b/src/builder.js index 28637dc3..c921c78f 100644 --- a/src/builder.js +++ b/src/builder.js @@ -1328,7 +1328,7 @@ export async function buildGraph(rootDir, opts = {}) { if (symbols._tree) continue; // already has a tree // CFG: need tree if any function/method def lacks native CFG if (needsCfg) { - const fnDefs = symbols.definitions.filter( + const fnDefs = (symbols.definitions || []).filter( (d) => (d.kind === 'function' || d.kind === 'method') && d.line, ); if (fnDefs.length > 0 && !fnDefs.every((d) => d.cfg?.blocks?.length)) { From c2bb5dedf66fbdb95aa6470876dfcc534672c197 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:00:38 -0700 Subject: [PATCH 04/21] 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 --- src/builder.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/builder.js b/src/builder.js index c921c78f..c7078771 100644 --- a/src/builder.js +++ b/src/builder.js @@ -1331,7 +1331,7 @@ export async function buildGraph(rootDir, opts = {}) { const fnDefs = (symbols.definitions || []).filter( (d) => (d.kind === 'function' || d.kind === 'method') && d.line, ); - if (fnDefs.length > 0 && !fnDefs.every((d) => d.cfg?.blocks?.length)) { + if (fnDefs.length > 0 && !fnDefs.every((d) => d.cfg === null || Array.isArray(d.cfg?.blocks))) { needsWasmTrees = true; break; } From 7e6b48912b84d62ec755fd8fc1354d57d6fdd6f1 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:00:51 -0700 Subject: [PATCH 05/21] style: format cfg check per biome rules Impact: 1 functions changed, 0 affected --- src/builder.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/builder.js b/src/builder.js index c7078771..5eb48d7c 100644 --- a/src/builder.js +++ b/src/builder.js @@ -1331,7 +1331,10 @@ export async function buildGraph(rootDir, opts = {}) { const fnDefs = (symbols.definitions || []).filter( (d) => (d.kind === 'function' || d.kind === 'method') && d.line, ); - if (fnDefs.length > 0 && !fnDefs.every((d) => d.cfg === null || Array.isArray(d.cfg?.blocks))) { + if ( + fnDefs.length > 0 && + !fnDefs.every((d) => d.cfg === null || Array.isArray(d.cfg?.blocks)) + ) { needsWasmTrees = true; break; } From f0a5522c52168e0a6dcdefaefb433ac19bbdbfdf Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:40:46 -0700 Subject: [PATCH 06/21] 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 --- CLAUDE.md | 2 + src/cli.js | 33 +++ src/index.js | 3 +- src/mcp.js | 54 ++++ src/sequence.js | 423 +++++++++++++++++++++++++++++ tests/integration/sequence.test.js | 192 +++++++++++++ tests/unit/mcp.test.js | 1 + 7 files changed, 707 insertions(+), 1 deletion(-) create mode 100644 src/sequence.js create mode 100644 tests/integration/sequence.test.js diff --git a/CLAUDE.md b/CLAUDE.md index dd1739ad..fb22fc2e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,6 +66,7 @@ JS source is plain JavaScript (ES modules) in `src/`. No transpilation step. The | `boundaries.js` | Architecture boundary rules with onion architecture preset | | `owners.js` | CODEOWNERS integration for ownership queries | | `snapshot.js` | SQLite DB backup and restore | +| `sequence.js` | Mermaid sequence diagram generation from call graph edges | | `paginate.js` | Pagination helpers for bounded query results | | `logger.js` | Structured logging (`warn`, `debug`, `info`, `error`) | @@ -132,6 +133,7 @@ node src/cli.js deps src/.js # File-level imports and importers node src/cli.js diff-impact main # Impact of current branch vs main node src/cli.js complexity -T # Per-function complexity metrics node src/cli.js communities -T # Community detection & drift analysis +node src/cli.js sequence -T # Mermaid sequence diagram from call edges node src/cli.js check -T # Rule engine pass/fail check node src/cli.js audit -T # Combined structural summary + impact + health report node src/cli.js triage -T # Ranked audit priority queue diff --git a/src/cli.js b/src/cli.js index c799ef1c..41c94a6f 100644 --- a/src/cli.js +++ b/src/cli.js @@ -1137,6 +1137,39 @@ program }); }); +program + .command('sequence ') + .description('Generate a Mermaid sequence diagram from call graph edges (participants = files)') + .option('--depth ', 'Max forward traversal depth', '10') + .option('--dataflow', 'Annotate with parameter names and return arrows from dataflow table') + .option('-d, --db ', 'Path to graph.db') + .option('-f, --file ', 'Scope to a specific file (partial match)') + .option('-k, --kind ', 'Filter by symbol kind') + .option('-T, --no-tests', 'Exclude test/spec files from results') + .option('--include-tests', 'Include test/spec files (overrides excludeTests config)') + .option('-j, --json', 'Output as JSON') + .option('--limit ', 'Max results to return') + .option('--offset ', 'Skip N results (default: 0)') + .option('--ndjson', 'Newline-delimited JSON output') + .action(async (name, opts) => { + if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) { + console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`); + process.exit(1); + } + const { sequence } = await import('./sequence.js'); + sequence(name, opts.db, { + depth: parseInt(opts.depth, 10), + file: opts.file, + kind: opts.kind, + noTests: resolveNoTests(opts), + json: opts.json, + dataflow: opts.dataflow, + limit: opts.limit ? parseInt(opts.limit, 10) : undefined, + offset: opts.offset ? parseInt(opts.offset, 10) : undefined, + ndjson: opts.ndjson, + }); + }); + program .command('dataflow ') .description('Show data flow for a function: parameters, return consumers, mutations') diff --git a/src/index.js b/src/index.js index 0cf65498..9f51e6cf 100644 --- a/src/index.js +++ b/src/index.js @@ -121,7 +121,6 @@ export { isNativeAvailable } from './native.js'; export { matchOwners, owners, ownersData, ownersForFiles, parseCodeowners } from './owners.js'; // Pagination utilities export { MCP_DEFAULTS, MCP_MAX_LIMIT, paginate, paginateResult, printNdjson } from './paginate.js'; - // Unified parser API export { getActiveEngine, isWasmAvailable, parseFileAuto, parseFilesAuto } from './parser.js'; // Query functions (data-returning) @@ -170,6 +169,8 @@ export { saveRegistry, unregisterRepo, } from './registry.js'; +// Sequence diagram generation +export { sequence, sequenceData, sequenceToMermaid } from './sequence.js'; // Snapshot management export { snapshotDelete, diff --git a/src/mcp.js b/src/mcp.js index a63ff873..1b51d75d 100644 --- a/src/mcp.js +++ b/src/mcp.js @@ -418,6 +418,43 @@ const BASE_TOOLS = [ }, }, }, + { + name: 'sequence', + description: + 'Generate a Mermaid sequence diagram from call graph edges. Participants are files, messages are function calls between them.', + inputSchema: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Entry point or function name to trace from (partial match)', + }, + depth: { type: 'number', description: 'Max forward traversal depth', default: 10 }, + format: { + type: 'string', + enum: ['mermaid', 'json'], + description: 'Output format (default: mermaid)', + }, + dataflow: { + type: 'boolean', + description: 'Annotate with parameter names and return arrows', + default: false, + }, + file: { + type: 'string', + description: 'Scope search to functions in this file (partial match)', + }, + kind: { + type: 'string', + enum: EVERY_SYMBOL_KIND, + description: 'Filter to a specific symbol kind', + }, + no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, + ...PAGINATION_PROPS, + }, + required: ['name'], + }, + }, { name: 'complexity', description: @@ -1165,6 +1202,23 @@ export async function startMCPServer(customDbPath, options = {}) { } break; } + case 'sequence': { + const { sequenceData, sequenceToMermaid } = await import('./sequence.js'); + const seqResult = sequenceData(args.name, dbPath, { + depth: args.depth, + file: args.file, + kind: args.kind, + dataflow: args.dataflow, + noTests: args.no_tests, + limit: Math.min(args.limit ?? MCP_DEFAULTS.execution_flow, MCP_MAX_LIMIT), + offset: args.offset ?? 0, + }); + result = + args.format === 'json' + ? seqResult + : { text: sequenceToMermaid(seqResult), ...seqResult }; + break; + } case 'complexity': { const { complexityData } = await import('./complexity.js'); result = complexityData(dbPath, { diff --git a/src/sequence.js b/src/sequence.js new file mode 100644 index 00000000..7678096e --- /dev/null +++ b/src/sequence.js @@ -0,0 +1,423 @@ +/** + * Sequence diagram generation — Mermaid sequenceDiagram from call graph edges. + * + * Participants are files (not individual functions). Calls within the same file + * become self-messages. This keeps diagrams readable and matches typical + * sequence-diagram conventions. + */ + +import { openReadonlyOrFail } from './db.js'; +import { paginateResult, printNdjson } from './paginate.js'; +import { isTestFile, kindIcon } from './queries.js'; +import { FRAMEWORK_ENTRY_PREFIXES } from './structure.js'; + +// ─── findBestMatch (copied from flow.js — same pattern) ───────────── + +function findBestMatch(db, name, opts = {}) { + const kinds = opts.kind + ? [opts.kind] + : [ + 'function', + 'method', + 'class', + 'interface', + 'type', + 'struct', + 'enum', + 'trait', + 'record', + 'module', + ]; + const placeholders = kinds.map(() => '?').join(', '); + const params = [`%${name}%`, ...kinds]; + + let fileCondition = ''; + if (opts.file) { + fileCondition = ' AND n.file LIKE ?'; + params.push(`%${opts.file}%`); + } + + const rows = db + .prepare( + `SELECT n.*, COALESCE(fi.cnt, 0) AS fan_in + FROM nodes n + LEFT JOIN ( + SELECT target_id, COUNT(*) AS cnt FROM edges WHERE kind = 'calls' GROUP BY target_id + ) fi ON fi.target_id = n.id + WHERE n.name LIKE ? AND n.kind IN (${placeholders})${fileCondition}`, + ) + .all(...params); + + const noTests = opts.noTests || false; + const nodes = noTests ? rows.filter((n) => !isTestFile(n.file)) : rows; + + if (nodes.length === 0) return null; + + const lowerQuery = name.toLowerCase(); + for (const node of nodes) { + const lowerName = node.name.toLowerCase(); + const bareName = lowerName.includes('.') ? lowerName.split('.').pop() : lowerName; + + let matchScore; + if (lowerName === lowerQuery || bareName === lowerQuery) { + matchScore = 100; + } else if (lowerName.startsWith(lowerQuery) || bareName.startsWith(lowerQuery)) { + matchScore = 60; + } else if (lowerName.includes(`.${lowerQuery}`) || lowerName.includes(`${lowerQuery}.`)) { + matchScore = 40; + } else { + matchScore = 10; + } + + const fanInBonus = Math.min(Math.log2(node.fan_in + 1) * 5, 25); + node._relevance = matchScore + fanInBonus; + } + + nodes.sort((a, b) => b._relevance - a._relevance); + return nodes[0]; +} + +// ─── Alias generation ──────────────────────────────────────────────── + +/** + * Build short participant aliases from file paths with collision handling. + * e.g. "src/builder.js" → "builder", but if two files share basename, + * progressively add parent dirs: "src/builder" vs "lib/builder". + */ +function buildAliases(files) { + const aliases = new Map(); + const basenames = new Map(); + + // Group by basename + for (const file of files) { + const base = file + .split('/') + .pop() + .replace(/\.[^.]+$/, ''); + if (!basenames.has(base)) basenames.set(base, []); + basenames.get(base).push(file); + } + + for (const [base, paths] of basenames) { + if (paths.length === 1) { + aliases.set(paths[0], base); + } else { + // Collision — progressively add parent dirs until aliases are unique + for (let depth = 2; depth <= 10; depth++) { + const trial = new Map(); + let allUnique = true; + const seen = new Set(); + + for (const p of paths) { + const parts = p.replace(/\.[^.]+$/, '').split('/'); + const alias = parts + .slice(-depth) + .join('/') + .replace(/[^a-zA-Z0-9_/-]/g, '_'); + trial.set(p, alias); + if (seen.has(alias)) allUnique = false; + seen.add(alias); + } + + if (allUnique || depth === 10) { + for (const [p, alias] of trial) { + aliases.set(p, alias); + } + break; + } + } + } + } + + return aliases; +} + +// ─── Core data function ────────────────────────────────────────────── + +/** + * Build sequence diagram data by BFS-forward from an entry point. + * + * @param {string} name - Symbol name to trace from + * @param {string} [dbPath] + * @param {object} [opts] + * @param {number} [opts.depth=10] + * @param {boolean} [opts.noTests] + * @param {string} [opts.file] + * @param {string} [opts.kind] + * @param {boolean} [opts.dataflow] + * @param {number} [opts.limit] + * @param {number} [opts.offset] + * @returns {{ entry, participants, messages, depth, totalMessages, truncated }} + */ +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 = findBestMatch(db, name, opts); + + // Phase 2: Prefix-stripped matching + if (!matchNode) { + for (const prefix of FRAMEWORK_ENTRY_PREFIXES) { + matchNode = findBestMatch(db, `${prefix}${name}`, opts); + if (matchNode) break; + } + } + + if (!matchNode) { + db.close(); + 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, + }; + + // 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; + + for (let d = 1; d <= maxDepth; d++) { + const nextFrontier = []; + + for (const fid of frontier) { + const callees = 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'`, + ) + .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); + } + } + + frontier = nextFrontier; + if (frontier.length === 0) break; + + if (d === maxDepth && frontier.length > 0) { + 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) { + // 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 = [...idToNode.values()].find( + (n) => n.name === msg.label && n.file === msg.to, + ); + if (!targetNode) continue; + + const returnKey = `${msg.to}->${msg.from}:${msg.label}`; + if (seenReturns.has(returnKey)) continue; + + const returns = db + .prepare( + `SELECT d.expression FROM dataflow d + WHERE d.source_id = ? AND d.kind = 'returns'`, + ) + .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 = [...idToNode.values()].find( + (n) => n.name === msg.label && n.file === msg.to, + ); + if (!targetNode) continue; + + const params = db + .prepare( + `SELECT d.expression FROM dataflow d + WHERE d.target_id = ? AND d.kind = 'flows_to' + ORDER BY d.param_index`, + ) + .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); + } + + db.close(); + + const base = { + entry, + participants, + messages, + depth: maxDepth, + totalMessages: messages.length, + truncated, + }; + return paginateResult(base, 'messages', { limit: opts.limit, offset: opts.offset }); +} + +// ─── Mermaid formatter ─────────────────────────────────────────────── + +/** + * Escape special Mermaid characters in labels. + */ +function escapeMermaid(str) { + return str.replace(/:/g, '#colon;').replace(/"/g, '#quot;'); +} + +/** + * Convert sequenceData result to Mermaid sequenceDiagram syntax. + * @param {{ participants, messages, truncated }} seqResult + * @returns {string} + */ +export function sequenceToMermaid(seqResult) { + const lines = ['sequenceDiagram']; + + for (const p of seqResult.participants) { + lines.push(` participant ${p.id} as ${escapeMermaid(p.label)}`); + } + + for (const msg of seqResult.messages) { + const arrow = msg.type === 'return' ? '-->>' : '->>'; + lines.push(` ${msg.from}${arrow}${msg.to}: ${escapeMermaid(msg.label)}`); + } + + if (seqResult.truncated) { + lines.push( + ` note right of ${seqResult.participants[0]?.id}: Truncated at depth ${seqResult.depth}`, + ); + } + + return lines.join('\n'); +} + +// ─── CLI formatter ─────────────────────────────────────────────────── + +/** + * CLI entry point — format sequence data as mermaid, JSON, or ndjson. + */ +export function sequence(name, dbPath, opts = {}) { + const data = sequenceData(name, dbPath, opts); + + if (opts.ndjson) { + printNdjson(data, 'messages'); + return; + } + + if (opts.json) { + console.log(JSON.stringify(data, null, 2)); + return; + } + + // Default: mermaid format + if (!data.entry) { + console.log(`No matching function found for "${name}".`); + return; + } + + const e = data.entry; + console.log(`\nSequence from: [${kindIcon(e.kind)}] ${e.name} ${e.file}:${e.line}`); + console.log(`Participants: ${data.participants.length} Messages: ${data.totalMessages}`); + if (data.truncated) { + console.log(` (truncated at depth ${data.depth})`); + } + console.log(); + + if (data.messages.length === 0) { + console.log(' (leaf node — no callees)'); + return; + } + + console.log(sequenceToMermaid(data)); +} diff --git a/tests/integration/sequence.test.js b/tests/integration/sequence.test.js new file mode 100644 index 00000000..3c44c133 --- /dev/null +++ b/tests/integration/sequence.test.js @@ -0,0 +1,192 @@ +/** + * Integration tests for sequence diagram generation. + * + * Uses a hand-crafted in-memory DB with known graph topology: + * + * buildGraph() → parseFiles() [src/builder.js → src/parser.js] + * → resolveImports() [src/builder.js → src/resolve.js] + * parseFiles() → extractSymbols() [src/parser.js → src/parser.js, same-file] + * extractSymbols() [leaf] + * resolveImports() [leaf] + * + * For alias collision test: + * helperA() in src/utils/helper.js + * helperB() in lib/utils/helper.js + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { afterAll, beforeAll, describe, expect, test } from 'vitest'; +import { initSchema } from '../../src/db.js'; +import { sequenceData, sequenceToMermaid } from '../../src/sequence.js'; + +// ─── Helpers ─────────────────────────────────────────────────────────── + +function insertNode(db, name, kind, file, line) { + return db + .prepare('INSERT INTO nodes (name, kind, file, line) VALUES (?, ?, ?, ?)') + .run(name, kind, file, line).lastInsertRowid; +} + +function insertEdge(db, sourceId, targetId, kind, confidence = 1.0) { + db.prepare( + 'INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) VALUES (?, ?, ?, ?, 0)', + ).run(sourceId, targetId, kind, confidence); +} + +// ─── Fixture DB ──────────────────────────────────────────────────────── + +let tmpDir, dbPath; + +beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-sequence-')); + fs.mkdirSync(path.join(tmpDir, '.codegraph')); + dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + + const db = new Database(dbPath); + db.pragma('journal_mode = WAL'); + initSchema(db); + + // Core nodes + const buildGraph = insertNode(db, 'buildGraph', 'function', 'src/builder.js', 10); + const parseFiles = insertNode(db, 'parseFiles', 'function', 'src/parser.js', 5); + const extractSymbols = insertNode(db, 'extractSymbols', 'function', 'src/parser.js', 20); + const resolveImports = insertNode(db, 'resolveImports', 'function', 'src/resolve.js', 1); + + // Call edges + insertEdge(db, buildGraph, parseFiles, 'calls'); + insertEdge(db, buildGraph, resolveImports, 'calls'); + insertEdge(db, parseFiles, extractSymbols, 'calls'); + + // Alias collision nodes (two different helper.js files) + const helperA = insertNode(db, 'helperA', 'function', 'src/utils/helper.js', 1); + const helperB = insertNode(db, 'helperB', 'function', 'lib/utils/helper.js', 1); + insertEdge(db, buildGraph, helperA, 'calls'); + insertEdge(db, helperA, helperB, 'calls'); + + // Test file node (for noTests filtering) + const testFn = insertNode(db, 'testBuild', 'function', 'tests/builder.test.js', 1); + insertEdge(db, buildGraph, testFn, 'calls'); + + db.close(); +}); + +afterAll(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +// ─── sequenceData ────────────────────────────────────────────────────── + +describe('sequenceData', () => { + test('basic sequence — correct participants and messages in BFS order', () => { + const data = sequenceData('buildGraph', dbPath, { noTests: true }); + expect(data.entry).not.toBeNull(); + expect(data.entry.name).toBe('buildGraph'); + + // Should have 4 files as participants (builder, parser, resolve, src/utils/helper) + // (test file excluded by noTests, lib/utils/helper reachable via helperA) + expect(data.participants.length).toBe(5); + + // Messages should be in BFS depth order + expect(data.messages.length).toBeGreaterThanOrEqual(4); + const depths = data.messages.map((m) => m.depth); + for (let i = 1; i < depths.length; i++) { + expect(depths[i]).toBeGreaterThanOrEqual(depths[i - 1]); + } + }); + + test('self-call — same-file call appears as self-message', () => { + const data = sequenceData('parseFiles', dbPath, { noTests: true }); + expect(data.entry).not.toBeNull(); + + // parseFiles → extractSymbols are both in src/parser.js + const selfMessages = data.messages.filter((m) => m.from === m.to); + expect(selfMessages.length).toBe(1); + expect(selfMessages[0].label).toBe('extractSymbols'); + }); + + test('depth limiting — depth:1 truncates', () => { + const data = sequenceData('buildGraph', dbPath, { depth: 1, noTests: true }); + expect(data.truncated).toBe(true); + expect(data.depth).toBe(1); + + // At depth 1, only direct callees of buildGraph + const msgDepths = data.messages.map((m) => m.depth); + expect(Math.max(...msgDepths)).toBe(1); + }); + + test('unknown name — entry is null', () => { + const data = sequenceData('nonExistentFunction', dbPath); + expect(data.entry).toBeNull(); + expect(data.participants).toHaveLength(0); + expect(data.messages).toHaveLength(0); + }); + + test('leaf entry — entry exists, zero messages', () => { + const data = sequenceData('extractSymbols', dbPath); + expect(data.entry).not.toBeNull(); + expect(data.entry.name).toBe('extractSymbols'); + expect(data.messages).toHaveLength(0); + // Only the entry file as participant + expect(data.participants).toHaveLength(1); + }); + + test('participant alias collision — two helper.js files get distinct IDs', () => { + const data = sequenceData('buildGraph', dbPath, { noTests: true }); + const helperParticipants = data.participants.filter((p) => p.label === 'helper.js'); + expect(helperParticipants.length).toBe(2); + + // IDs should be distinct + const ids = helperParticipants.map((p) => p.id); + expect(ids[0]).not.toBe(ids[1]); + }); + + test('noTests filtering — test file nodes excluded', () => { + const withTests = sequenceData('buildGraph', dbPath, { noTests: false }); + const withoutTests = sequenceData('buildGraph', dbPath, { noTests: true }); + + // With tests should have more messages (includes testBuild) + expect(withTests.totalMessages).toBeGreaterThan(withoutTests.totalMessages); + + // testBuild should not appear when filtering + const testMsgs = withoutTests.messages.filter((m) => m.label === 'testBuild'); + expect(testMsgs).toHaveLength(0); + }); +}); + +// ─── sequenceToMermaid ────────────────────────────────────────────────── + +describe('sequenceToMermaid', () => { + test('starts with sequenceDiagram and has participant lines', () => { + const data = sequenceData('buildGraph', dbPath, { noTests: true }); + const mermaid = sequenceToMermaid(data); + + expect(mermaid).toMatch(/^sequenceDiagram/); + expect(mermaid).toContain('participant'); + }); + + test('has ->> arrows for calls', () => { + const data = sequenceData('buildGraph', dbPath, { noTests: true }); + const mermaid = sequenceToMermaid(data); + expect(mermaid).toContain('->>'); + }); + + test('truncation note when truncated', () => { + const data = sequenceData('buildGraph', dbPath, { depth: 1, noTests: true }); + const mermaid = sequenceToMermaid(data); + expect(mermaid).toContain('Truncated at depth'); + }); + + test('escapes colons in labels', () => { + const mockData = { + participants: [{ id: 'a', label: 'a.js' }], + messages: [{ from: 'a', to: 'a', label: 'route:GET /users', type: 'call' }], + truncated: false, + }; + const mermaid = sequenceToMermaid(mockData); + expect(mermaid).toContain('#colon;'); + expect(mermaid).not.toContain('route:'); + }); +}); diff --git a/tests/unit/mcp.test.js b/tests/unit/mcp.test.js index 4e5bc3cc..70889bfe 100644 --- a/tests/unit/mcp.test.js +++ b/tests/unit/mcp.test.js @@ -28,6 +28,7 @@ const ALL_TOOL_NAMES = [ 'co_changes', 'node_roles', 'execution_flow', + 'sequence', 'complexity', 'communities', 'code_owners', From 0201d314ddcd4870e4b2bfff8912128b982fb801 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Thu, 5 Mar 2026 02:31:38 -0700 Subject: [PATCH 07/21] perf: hoist db.prepare() calls and build O(1) lookup map in sequence Impact: 1 functions changed, 1 affected --- src/sequence.js | 53 +++++++++++++++++++++++++------------------------ 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/src/sequence.js b/src/sequence.js index 7678096e..616f3e50 100644 --- a/src/sequence.js +++ b/src/sequence.js @@ -194,17 +194,17 @@ export function sequenceData(name, dbPath, opts = {}) { 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 = 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'`, - ) - .all(fid); + const callees = getCallees.all(fid); const caller = idToNode.get(fid); @@ -244,24 +244,33 @@ export function sequenceData(name, dbPath, opts = {}) { .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 = [...idToNode.values()].find( - (n) => n.name === msg.label && n.file === msg.to, - ); + 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 = db - .prepare( - `SELECT d.expression FROM dataflow d - WHERE d.source_id = ? AND d.kind = 'returns'`, - ) - .all(targetNode.id); + const returns = getReturns.all(targetNode.id); if (returns.length > 0) { seenReturns.add(returnKey); @@ -279,18 +288,10 @@ export function sequenceData(name, dbPath, opts = {}) { // Annotate call messages with parameter names for (const msg of messages) { if (msg.type !== 'call') continue; - const targetNode = [...idToNode.values()].find( - (n) => n.name === msg.label && n.file === msg.to, - ); + const targetNode = nodeByNameFile.get(`${msg.label}|${msg.to}`); if (!targetNode) continue; - const params = db - .prepare( - `SELECT d.expression FROM dataflow d - WHERE d.target_id = ? AND d.kind = 'flows_to' - ORDER BY d.param_index`, - ) - .all(targetNode.id); + const params = getFlowsTo.all(targetNode.id); if (params.length > 0) { const paramNames = params From 19a990a5a93966c52da74cbda6f9f8d936be77b1 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:39:04 -0700 Subject: [PATCH 08/21] =?UTF-8?q?fix:=20address=20sequence=20review=20comm?= =?UTF-8?q?ents=20=E2=80=94=20alias=20collision,=20truncation,=20escape,?= =?UTF-8?q?=20pagination?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/sequence.js | 21 ++++++++++++++++----- tests/integration/sequence.test.js | 9 +++++++-- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/sequence.js b/src/sequence.js index 616f3e50..56d17be2 100644 --- a/src/sequence.js +++ b/src/sequence.js @@ -112,8 +112,8 @@ function buildAliases(files) { const parts = p.replace(/\.[^.]+$/, '').split('/'); const alias = parts .slice(-depth) - .join('/') - .replace(/[^a-zA-Z0-9_/-]/g, '_'); + .join('_') + .replace(/[^a-zA-Z0-9_-]/g, '_'); trial.set(p, alias); if (seen.has(alias)) allUnique = false; seen.add(alias); @@ -233,7 +233,9 @@ export function sequenceData(name, dbPath, opts = {}) { if (frontier.length === 0) break; if (d === maxDepth && frontier.length > 0) { - truncated = true; + // 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; } } @@ -345,7 +347,12 @@ export function sequenceData(name, dbPath, opts = {}) { totalMessages: messages.length, truncated, }; - return paginateResult(base, 'messages', { limit: opts.limit, offset: opts.offset }); + 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; } // ─── Mermaid formatter ─────────────────────────────────────────────── @@ -354,7 +361,11 @@ export function sequenceData(name, dbPath, opts = {}) { * Escape special Mermaid characters in labels. */ function escapeMermaid(str) { - return str.replace(/:/g, '#colon;').replace(/"/g, '#quot;'); + return str + .replace(//g, '>') + .replace(/:/g, '#colon;') + .replace(/"/g, '#quot;'); } /** diff --git a/tests/integration/sequence.test.js b/tests/integration/sequence.test.js index 3c44c133..3de2b37a 100644 --- a/tests/integration/sequence.test.js +++ b/tests/integration/sequence.test.js @@ -85,8 +85,8 @@ describe('sequenceData', () => { expect(data.entry).not.toBeNull(); expect(data.entry.name).toBe('buildGraph'); - // Should have 4 files as participants (builder, parser, resolve, src/utils/helper) - // (test file excluded by noTests, lib/utils/helper reachable via helperA) + // Should have 5 files as participants (builder, parser, resolve, src/utils/helper, lib/utils/helper) + // (test file excluded by noTests) expect(data.participants.length).toBe(5); // Messages should be in BFS depth order @@ -141,6 +141,11 @@ describe('sequenceData', () => { // IDs should be distinct const ids = helperParticipants.map((p) => p.id); expect(ids[0]).not.toBe(ids[1]); + + // IDs must be valid Mermaid participant identifiers (no slashes, etc.) + for (const id of ids) { + expect(id).toMatch(/^[a-zA-Z0-9_-]+$/); + } }); test('noTests filtering — test file nodes excluded', () => { From 0db9017520e7ea14e4e7a8cd0d379b8eed17053c Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Thu, 5 Mar 2026 13:15:52 -0700 Subject: [PATCH 09/21] ci: retrigger workflows --- src/sequence.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sequence.js b/src/sequence.js index 56d17be2..7e7d115c 100644 --- a/src/sequence.js +++ b/src/sequence.js @@ -1,5 +1,5 @@ /** - * Sequence diagram generation — Mermaid sequenceDiagram from call graph edges. + * Sequence diagram generation – Mermaid sequenceDiagram from call graph edges. * * Participants are files (not individual functions). Calls within the same file * become self-messages. This keeps diagrams readable and matches typical From 995c816f3a5149f151c91659008cfb642cdb9a47 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:24:24 -0700 Subject: [PATCH 10/21] =?UTF-8?q?fix:=20address=20round-3=20review=20?= =?UTF-8?q?=E2=80=94=20deduplicate=20findBestMatch,=20guard=20truncation,?= =?UTF-8?q?=20add=20dataflow=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/flow.js | 73 ++------------------------ src/queries.js | 2 +- src/sequence.js | 76 ++------------------------- tests/integration/sequence.test.js | 82 ++++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 142 deletions(-) diff --git a/src/flow.js b/src/flow.js index 23224bf5..61f39504 100644 --- a/src/flow.js +++ b/src/flow.js @@ -7,7 +7,7 @@ import { openReadonlyOrFail } from './db.js'; import { paginateResult, printNdjson } from './paginate.js'; -import { isTestFile, kindIcon } from './queries.js'; +import { findMatchingNodes, isTestFile, kindIcon } from './queries.js'; import { FRAMEWORK_ENTRY_PREFIXES } from './structure.js'; /** @@ -95,12 +95,12 @@ export function flowData(name, dbPath, opts = {}) { const noTests = opts.noTests || false; // Phase 1: Direct LIKE match on full name - let matchNode = findBestMatch(db, name, opts); + let matchNode = findMatchingNodes(db, name, opts)[0] ?? null; // Phase 2: Prefix-stripped matching — try adding framework prefixes if (!matchNode) { for (const prefix of FRAMEWORK_ENTRY_PREFIXES) { - matchNode = findBestMatch(db, `${prefix}${name}`, opts); + matchNode = findMatchingNodes(db, `${prefix}${name}`, opts)[0] ?? null; if (matchNode) break; } } @@ -219,73 +219,6 @@ export function flowData(name, dbPath, opts = {}) { return paginateResult(base, 'steps', { limit: opts.limit, offset: opts.offset }); } -/** - * Find the best matching node using the same relevance scoring as queries.js findMatchingNodes. - */ -function findBestMatch(db, name, opts = {}) { - const kinds = opts.kind - ? [opts.kind] - : [ - 'function', - 'method', - 'class', - 'interface', - 'type', - 'struct', - 'enum', - 'trait', - 'record', - 'module', - ]; - const placeholders = kinds.map(() => '?').join(', '); - const params = [`%${name}%`, ...kinds]; - - let fileCondition = ''; - if (opts.file) { - fileCondition = ' AND n.file LIKE ?'; - params.push(`%${opts.file}%`); - } - - const rows = db - .prepare( - `SELECT n.*, COALESCE(fi.cnt, 0) AS fan_in - FROM nodes n - LEFT JOIN ( - SELECT target_id, COUNT(*) AS cnt FROM edges WHERE kind = 'calls' GROUP BY target_id - ) fi ON fi.target_id = n.id - WHERE n.name LIKE ? AND n.kind IN (${placeholders})${fileCondition}`, - ) - .all(...params); - - const noTests = opts.noTests || false; - const nodes = noTests ? rows.filter((n) => !isTestFile(n.file)) : rows; - - if (nodes.length === 0) return null; - - const lowerQuery = name.toLowerCase(); - for (const node of nodes) { - const lowerName = node.name.toLowerCase(); - const bareName = lowerName.includes('.') ? lowerName.split('.').pop() : lowerName; - - let matchScore; - if (lowerName === lowerQuery || bareName === lowerQuery) { - matchScore = 100; - } else if (lowerName.startsWith(lowerQuery) || bareName.startsWith(lowerQuery)) { - matchScore = 60; - } else if (lowerName.includes(`.${lowerQuery}`) || lowerName.includes(`${lowerQuery}.`)) { - matchScore = 40; - } else { - matchScore = 10; - } - - const fanInBonus = Math.min(Math.log2(node.fan_in + 1) * 5, 25); - node._relevance = matchScore + fanInBonus; - } - - nodes.sort((a, b) => b._relevance - a._relevance); - return nodes[0]; -} - /** * CLI formatter — text or JSON output. */ diff --git a/src/queries.js b/src/queries.js index a35f4f46..27915091 100644 --- a/src/queries.js +++ b/src/queries.js @@ -163,7 +163,7 @@ function resolveMethodViaHierarchy(db, methodName) { * Find nodes matching a name query, ranked by relevance. * Scoring: exact=100, prefix=60, word-boundary=40, substring=10, plus fan-in tiebreaker. */ -function findMatchingNodes(db, name, opts = {}) { +export function findMatchingNodes(db, name, opts = {}) { const kinds = opts.kind ? [opts.kind] : FUNCTION_KINDS; const placeholders = kinds.map(() => '?').join(', '); const params = [`%${name}%`, ...kinds]; diff --git a/src/sequence.js b/src/sequence.js index 7e7d115c..cb9c2541 100644 --- a/src/sequence.js +++ b/src/sequence.js @@ -8,75 +8,9 @@ import { openReadonlyOrFail } from './db.js'; import { paginateResult, printNdjson } from './paginate.js'; -import { isTestFile, kindIcon } from './queries.js'; +import { findMatchingNodes, isTestFile, kindIcon } from './queries.js'; import { FRAMEWORK_ENTRY_PREFIXES } from './structure.js'; -// ─── findBestMatch (copied from flow.js — same pattern) ───────────── - -function findBestMatch(db, name, opts = {}) { - const kinds = opts.kind - ? [opts.kind] - : [ - 'function', - 'method', - 'class', - 'interface', - 'type', - 'struct', - 'enum', - 'trait', - 'record', - 'module', - ]; - const placeholders = kinds.map(() => '?').join(', '); - const params = [`%${name}%`, ...kinds]; - - let fileCondition = ''; - if (opts.file) { - fileCondition = ' AND n.file LIKE ?'; - params.push(`%${opts.file}%`); - } - - const rows = db - .prepare( - `SELECT n.*, COALESCE(fi.cnt, 0) AS fan_in - FROM nodes n - LEFT JOIN ( - SELECT target_id, COUNT(*) AS cnt FROM edges WHERE kind = 'calls' GROUP BY target_id - ) fi ON fi.target_id = n.id - WHERE n.name LIKE ? AND n.kind IN (${placeholders})${fileCondition}`, - ) - .all(...params); - - const noTests = opts.noTests || false; - const nodes = noTests ? rows.filter((n) => !isTestFile(n.file)) : rows; - - if (nodes.length === 0) return null; - - const lowerQuery = name.toLowerCase(); - for (const node of nodes) { - const lowerName = node.name.toLowerCase(); - const bareName = lowerName.includes('.') ? lowerName.split('.').pop() : lowerName; - - let matchScore; - if (lowerName === lowerQuery || bareName === lowerQuery) { - matchScore = 100; - } else if (lowerName.startsWith(lowerQuery) || bareName.startsWith(lowerQuery)) { - matchScore = 60; - } else if (lowerName.includes(`.${lowerQuery}`) || lowerName.includes(`${lowerQuery}.`)) { - matchScore = 40; - } else { - matchScore = 10; - } - - const fanInBonus = Math.min(Math.log2(node.fan_in + 1) * 5, 25); - node._relevance = matchScore + fanInBonus; - } - - nodes.sort((a, b) => b._relevance - a._relevance); - return nodes[0]; -} - // ─── Alias generation ──────────────────────────────────────────────── /** @@ -156,12 +90,12 @@ export function sequenceData(name, dbPath, opts = {}) { const withDataflow = opts.dataflow || false; // Phase 1: Direct LIKE match - let matchNode = findBestMatch(db, name, opts); + let matchNode = findMatchingNodes(db, name, opts)[0] ?? null; // Phase 2: Prefix-stripped matching if (!matchNode) { for (const prefix of FRAMEWORK_ENTRY_PREFIXES) { - matchNode = findBestMatch(db, `${prefix}${name}`, opts); + matchNode = findMatchingNodes(db, `${prefix}${name}`, opts)[0] ?? null; if (matchNode) break; } } @@ -385,9 +319,9 @@ export function sequenceToMermaid(seqResult) { lines.push(` ${msg.from}${arrow}${msg.to}: ${escapeMermaid(msg.label)}`); } - if (seqResult.truncated) { + if (seqResult.truncated && seqResult.participants.length > 0) { lines.push( - ` note right of ${seqResult.participants[0]?.id}: Truncated at depth ${seqResult.depth}`, + ` note right of ${seqResult.participants[0].id}: Truncated at depth ${seqResult.depth}`, ); } diff --git a/tests/integration/sequence.test.js b/tests/integration/sequence.test.js index 3de2b37a..6d3367e4 100644 --- a/tests/integration/sequence.test.js +++ b/tests/integration/sequence.test.js @@ -184,6 +184,18 @@ describe('sequenceToMermaid', () => { expect(mermaid).toContain('Truncated at depth'); }); + test('no truncation note when participants empty (offset past all messages)', () => { + const mockData = { + participants: [], + messages: [], + truncated: true, + depth: 5, + }; + const mermaid = sequenceToMermaid(mockData); + expect(mermaid).not.toContain('note right of'); + expect(mermaid).not.toContain('undefined'); + }); + test('escapes colons in labels', () => { const mockData = { participants: [{ id: 'a', label: 'a.js' }], @@ -195,3 +207,73 @@ describe('sequenceToMermaid', () => { expect(mermaid).not.toContain('route:'); }); }); + +// ─── Dataflow annotations ─────────────────────────────────────────────── + +describe('dataflow annotations', () => { + let dfTmpDir, dfDbPath; + + beforeAll(() => { + dfTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-seq-df-')); + fs.mkdirSync(path.join(dfTmpDir, '.codegraph')); + dfDbPath = path.join(dfTmpDir, '.codegraph', 'graph.db'); + + const db = new Database(dfDbPath); + db.pragma('journal_mode = WAL'); + initSchema(db); + + // caller → callee (cross-file) + const caller = insertNode(db, 'handleRequest', 'function', 'src/handler.js', 1); + const callee = insertNode(db, 'fetchData', 'function', 'src/service.js', 10); + insertEdge(db, caller, callee, 'calls'); + + // dataflow: fetchData returns 'Promise' and receives param 'userId' + db.prepare( + 'INSERT INTO dataflow (source_id, target_id, kind, param_index, expression) VALUES (?, ?, ?, ?, ?)', + ).run(callee, caller, 'returns', null, 'Promise'); + db.prepare( + 'INSERT INTO dataflow (source_id, target_id, kind, param_index, expression) VALUES (?, ?, ?, ?, ?)', + ).run(caller, callee, 'flows_to', 0, 'userId'); + + db.close(); + }); + + afterAll(() => { + if (dfTmpDir) fs.rmSync(dfTmpDir, { recursive: true, force: true }); + }); + + test('return arrows appear with dataflow enabled', () => { + const data = sequenceData('handleRequest', dfDbPath, { noTests: true, dataflow: true }); + expect(data.entry).not.toBeNull(); + + const returnMsgs = data.messages.filter((m) => m.type === 'return'); + expect(returnMsgs.length).toBe(1); + expect(returnMsgs[0].label).toBe('Promise'); + // Return goes from callee file back to caller file + expect(returnMsgs[0].from).not.toBe(returnMsgs[0].to); + }); + + test('call labels annotated with parameter names', () => { + const data = sequenceData('handleRequest', dfDbPath, { noTests: true, dataflow: true }); + + const callMsgs = data.messages.filter((m) => m.type === 'call'); + expect(callMsgs.length).toBe(1); + expect(callMsgs[0].label).toBe('fetchData(userId)'); + }); + + test('without dataflow flag, no return arrows or param annotations', () => { + const data = sequenceData('handleRequest', dfDbPath, { noTests: true, dataflow: false }); + + const returnMsgs = data.messages.filter((m) => m.type === 'return'); + expect(returnMsgs).toHaveLength(0); + + const callMsgs = data.messages.filter((m) => m.type === 'call'); + expect(callMsgs[0].label).toBe('fetchData'); + }); + + test('mermaid output has dashed return arrow', () => { + const data = sequenceData('handleRequest', dfDbPath, { noTests: true, dataflow: true }); + const mermaid = sequenceToMermaid(data); + expect(mermaid).toContain('-->>'); + }); +}); From c97bdee2c426ebd2eb62f585b695d88be4647199 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 8 Mar 2026 20:04:40 -0600 Subject: [PATCH 11/21] chore: add hook to block "generated with" in PR bodies --- .claude/hooks/guard-pr-body.sh | 13 +++++++++++++ .claude/settings.json | 5 +++++ 2 files changed, 18 insertions(+) create mode 100644 .claude/hooks/guard-pr-body.sh diff --git a/.claude/hooks/guard-pr-body.sh b/.claude/hooks/guard-pr-body.sh new file mode 100644 index 00000000..51d18cef --- /dev/null +++ b/.claude/hooks/guard-pr-body.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Block PR creation if the body contains "generated with" (case-insensitive) + +input="$CLAUDE_TOOL_INPUT" + +# Only check gh pr create commands +echo "$input" | grep -qi 'gh pr create' || exit 0 + +# Block if body contains "generated with" +if echo "$input" | grep -qi 'generated with'; then + echo "BLOCK: Remove any 'Generated with ...' line from the PR body." >&2 + exit 2 +fi diff --git a/.claude/settings.json b/.claude/settings.json index 4ffe2530..6c059786 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -14,6 +14,11 @@ "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\"", From 95c5c66c71c6fca69e24b11a079cafbcc7028671 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 8 Mar 2026 20:20:54 -0600 Subject: [PATCH 12/21] 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 --- .claude/hooks/check-cycles.sh | 115 ++++++++++++++++++++++++++++ .claude/hooks/check-dead-exports.sh | 103 +++++++++++++++++++++++++ .claude/settings.json | 10 +++ src/cli.js | 2 + src/queries.js | 21 +++-- 5 files changed, 246 insertions(+), 5 deletions(-) create mode 100644 .claude/hooks/check-cycles.sh create mode 100644 .claude/hooks/check-dead-exports.sh diff --git a/.claude/hooks/check-cycles.sh b/.claude/hooks/check-cycles.sh new file mode 100644 index 00000000..6befb9e6 --- /dev/null +++ b/.claude/hooks/check-cycles.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# check-cycles.sh — PreToolUse hook for Bash (git commit) +# Blocks commits that introduce NEW circular dependencies (compares HEAD baseline). + +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 + +# Count current cycles involving staged files +RESULT=$(node "$WORK_ROOT/src/cli.js" check --staged --json -T 2>/dev/null) || true +if [ -z "$RESULT" ]; then + exit 0 +fi + +# Extract cycle count and details; compare against HEAD baseline +NEW_CYCLES=$(echo "$RESULT" | 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 cycles=cyclesPred.cycles||[]; + // Compare with HEAD: run cycles on HEAD to get baseline count + // Since we can't run two builds, check if cycles involve NEW files + const newFiles=new Set((data.summary?.newFiles > 0) ? + [...(data._newFiles||[])] : []); + // If all cycles existed before (no new files involved), skip + // For now, report cycle count — the predicate already scopes to changed files + // so any cycle here involves a file the user touched + const summary=cycles.slice(0,5).map(c=>c.join(' -> ')).join('\\n '); + const extra=cycles.length>5?'\\n ... and '+(cycles.length-5)+' more':''; + process.stdout.write(summary+extra); + }catch{} + }); +" 2>/dev/null) || true + +# For diff-awareness: get baseline cycle count from HEAD +# The check --staged predicate uses the *current* graph DB which reflects the working tree. +# If cycles exist in the DB before our changes, they're pre-existing. +# We compare: cycles on HEAD (via diff against HEAD~1) vs cycles on staged. +BASELINE_COUNT=$(node -e " + const {findCycles}=require('./src/cycles.js'); + const {openReadonlyOrFail}=require('./src/db.js'); + try { + const db=openReadonlyOrFail(); + const cycles=findCycles(db,{fileLevel:true,noTests:true}); + process.stdout.write(String(cycles.length)); + db.close(); + }catch{process.stdout.write('0');} +" 2>/dev/null) || echo "0" + +CURRENT_COUNT=$(echo "$RESULT" | 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'); + process.stdout.write(String((cyclesPred?.cycles||[]).length)); + }catch{process.stdout.write('0');} + }); +" 2>/dev/null) || echo "0" + +# Only block if cycle count increased (new cycles introduced) +if [ "$CURRENT_COUNT" -gt "$BASELINE_COUNT" ] 2>/dev/null; then + REASON="BLOCKED: New circular dependencies introduced (was $BASELINE_COUNT, now $CURRENT_COUNT): + $NEW_CYCLES +Fix the new 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..7332bf94 --- /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 that introduce NEW unused exports in staged files. +# Compares against the pre-change state so pre-existing dead exports are ignored. + +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 + +# For each staged src/ file, compare current unused count against the +# count from the staged diff's added lines. Only flag truly new dead exports. +NEW_DEAD="" + +while IFS= read -r file; do + # Only check source files + case "$file" in + src/*.js|src/*.ts|src/*.tsx) ;; + *) continue ;; + esac + + # Get current unused exports for this file + RESULT=$(node "$WORK_ROOT/src/cli.js" exports "$file" --json -T 2>/dev/null) || true + if [ -z "$RESULT" ]; then + continue + fi + + # Get names of new functions added in the staged diff for this file + ADDED_NAMES=$(git diff --cached -U0 "$file" 2>/dev/null | grep -E '^\+.*(export\s+(function|const|class|async\s+function)|module\.exports)' | grep -oP '(?:function|const|class)\s+\K\w+' 2>/dev/null) || true + + # If no new exports were added in the diff, skip this file + if [ -z "$ADDED_NAMES" ]; then + continue + fi + + # Check if any of the newly added exports have zero consumers + NEWLY_DEAD=$(echo "$RESULT" | node -e " + const added=new Set(process.argv.slice(2)); + let d=''; + process.stdin.on('data',c=>d+=c); + process.stdin.on('end',()=>{ + try { + const data=JSON.parse(d); + const dead=(data.results||[]) + .filter(r=>r.consumerCount===0 && added.has(r.name)); + if(dead.length>0){ + process.stdout.write(dead.map(u=>u.name+' ('+data.file+':'+u.line+')').join(', ')); + } + }catch{} + }); + " -- $ADDED_NAMES 2>/dev/null) || true + + if [ -n "$NEWLY_DEAD" ]; then + NEW_DEAD="${NEW_DEAD:+$NEW_DEAD; }$NEWLY_DEAD" + fi +done <<< "$STAGED" + +if [ -n "$NEW_DEAD" ]; then + REASON="BLOCKED: Newly added exports with zero consumers detected: $NEW_DEAD. 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/settings.json b/.claude/settings.json index 6c059786..68fba780 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -23,6 +23,16 @@ "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 } ] }, diff --git a/src/cli.js b/src/cli.js index 41c94a6f..aa008e92 100644 --- a/src/cli.js +++ b/src/cli.js @@ -276,11 +276,13 @@ program .option('-j, --json', 'Output as JSON') .option('--limit ', 'Max results to return') .option('--offset ', 'Skip N results (default: 0)') + .option('--unused', 'Show only exports with zero consumers (dead exports)') .option('--ndjson', 'Newline-delimited JSON output') .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, diff --git a/src/queries.js b/src/queries.js index 27915091..9f88a719 100644 --- a/src/queries.js +++ b/src/queries.js @@ -3134,7 +3134,7 @@ export function roles(customDbPath, opts = {}) { // ─── exportsData ───────────────────────────────────────────────────── -function exportsFileImpl(db, target, noTests, getFileLines) { +function exportsFileImpl(db, target, noTests, getFileLines, unused) { const fileNodes = db .prepare(`SELECT * FROM nodes WHERE file LIKE ? AND kind = 'file'`) .all(`%${target}%`); @@ -3194,12 +3194,20 @@ function exportsFileImpl(db, target, noTests, getFileLines) { .all(fn.id) .map((r) => ({ file: r.file })); + const totalUnused = results.filter((r) => r.consumerCount === 0).length; + + let filteredResults = results; + if (unused) { + filteredResults = results.filter((r) => r.consumerCount === 0); + } + return { file: fn.file, - results, + results: filteredResults, reexports, totalExported: exported.length, totalInternal: internalCount, + totalUnused, }; }); } @@ -3229,12 +3237,13 @@ export function exportsData(file, customDbPath, opts = {}) { } } - const fileResults = exportsFileImpl(db, file, noTests, getFileLines); + const unused = opts.unused || false; + const fileResults = exportsFileImpl(db, file, noTests, getFileLines, unused); db.close(); if (fileResults.length === 0) { return paginateResult( - { file, results: [], reexports: [], totalExported: 0, totalInternal: 0 }, + { file, results: [], reexports: [], totalExported: 0, totalInternal: 0, totalUnused: 0 }, 'results', { limit: opts.limit, offset: opts.offset }, ); @@ -3248,6 +3257,7 @@ export function exportsData(file, customDbPath, opts = {}) { reexports: first.reexports, totalExported: first.totalExported, totalInternal: first.totalInternal, + totalUnused: first.totalUnused, }; return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); } @@ -3268,8 +3278,9 @@ export function fileExports(file, customDbPath, opts = {}) { return; } + const unusedNote = data.totalUnused > 0 ? `, ${data.totalUnused} unused` : ''; console.log( - `\n# ${data.file} — ${data.totalExported} exported, ${data.totalInternal} internal\n`, + `\n# ${data.file} — ${data.totalExported} exported, ${data.totalInternal} internal${unusedNote}\n`, ); for (const sym of data.results) { From f8ff77560dcc9011459beabcdd7532aedde5e974 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 8 Mar 2026 20:23:31 -0600 Subject: [PATCH 13/21] 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. --- .claude/hooks/check-cycles.sh | 71 ++++++++++------------------ .claude/hooks/check-dead-exports.sh | 50 ++++++++++---------- docs/guides/recommended-practices.md | 6 +++ 3 files changed, 56 insertions(+), 71 deletions(-) diff --git a/.claude/hooks/check-cycles.sh b/.claude/hooks/check-cycles.sh index 6befb9e6..3885c407 100644 --- a/.claude/hooks/check-cycles.sh +++ b/.claude/hooks/check-cycles.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # check-cycles.sh — PreToolUse hook for Bash (git commit) -# Blocks commits that introduce NEW circular dependencies (compares HEAD baseline). +# Blocks commits if circular dependencies involve files edited in this session. set -euo pipefail @@ -37,14 +37,23 @@ if [ -z "$STAGED" ]; then exit 0 fi -# Count current cycles involving staged files +# 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 -# Extract cycle count and details; compare against HEAD baseline -NEW_CYCLES=$(echo "$RESULT" | node -e " +# 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',()=>{ @@ -52,53 +61,23 @@ NEW_CYCLES=$(echo "$RESULT" | node -e " const data=JSON.parse(d); const cyclesPred=(data.predicates||[]).find(p=>p.name==='cycles'); if(!cyclesPred || cyclesPred.passed) return; - const cycles=cyclesPred.cycles||[]; - // Compare with HEAD: run cycles on HEAD to get baseline count - // Since we can't run two builds, check if cycles involve NEW files - const newFiles=new Set((data.summary?.newFiles > 0) ? - [...(data._newFiles||[])] : []); - // If all cycles existed before (no new files involved), skip - // For now, report cycle count — the predicate already scopes to changed files - // so any cycle here involves a file the user touched - const summary=cycles.slice(0,5).map(c=>c.join(' -> ')).join('\\n '); - const extra=cycles.length>5?'\\n ... and '+(cycles.length-5)+' more':''; + 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 -# For diff-awareness: get baseline cycle count from HEAD -# The check --staged predicate uses the *current* graph DB which reflects the working tree. -# If cycles exist in the DB before our changes, they're pre-existing. -# We compare: cycles on HEAD (via diff against HEAD~1) vs cycles on staged. -BASELINE_COUNT=$(node -e " - const {findCycles}=require('./src/cycles.js'); - const {openReadonlyOrFail}=require('./src/db.js'); - try { - const db=openReadonlyOrFail(); - const cycles=findCycles(db,{fileLevel:true,noTests:true}); - process.stdout.write(String(cycles.length)); - db.close(); - }catch{process.stdout.write('0');} -" 2>/dev/null) || echo "0" - -CURRENT_COUNT=$(echo "$RESULT" | 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'); - process.stdout.write(String((cyclesPred?.cycles||[]).length)); - }catch{process.stdout.write('0');} - }); -" 2>/dev/null) || echo "0" - -# Only block if cycle count increased (new cycles introduced) -if [ "$CURRENT_COUNT" -gt "$BASELINE_COUNT" ] 2>/dev/null; then - REASON="BLOCKED: New circular dependencies introduced (was $BASELINE_COUNT, now $CURRENT_COUNT): - $NEW_CYCLES -Fix the new cycles before committing." +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({ diff --git a/.claude/hooks/check-dead-exports.sh b/.claude/hooks/check-dead-exports.sh index 7332bf94..4f8140b7 100644 --- a/.claude/hooks/check-dead-exports.sh +++ b/.claude/hooks/check-dead-exports.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # check-dead-exports.sh — PreToolUse hook for Bash (git commit) -# Blocks commits that introduce NEW unused exports in staged files. -# Compares against the pre-change state so pre-existing dead exports are ignored. +# 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 @@ -38,9 +38,15 @@ if [ -z "$STAGED" ]; then exit 0 fi -# For each staged src/ file, compare current unused count against the -# count from the staged diff's added lines. Only flag truly new dead exports. -NEW_DEAD="" +# 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 @@ -49,44 +55,38 @@ while IFS= read -r file; do *) continue ;; esac - # Get current unused exports for this file - RESULT=$(node "$WORK_ROOT/src/cli.js" exports "$file" --json -T 2>/dev/null) || true - if [ -z "$RESULT" ]; then + # Only check files edited in this session + if ! echo "$EDITED_FILES" | grep -qxF "$file"; then continue fi - # Get names of new functions added in the staged diff for this file - ADDED_NAMES=$(git diff --cached -U0 "$file" 2>/dev/null | grep -E '^\+.*(export\s+(function|const|class|async\s+function)|module\.exports)' | grep -oP '(?:function|const|class)\s+\K\w+' 2>/dev/null) || true - - # If no new exports were added in the diff, skip this file - if [ -z "$ADDED_NAMES" ]; then + RESULT=$(node "$WORK_ROOT/src/cli.js" exports "$file" --unused --json -T 2>/dev/null) || true + if [ -z "$RESULT" ]; then continue fi - # Check if any of the newly added exports have zero consumers - NEWLY_DEAD=$(echo "$RESULT" | node -e " - const added=new Set(process.argv.slice(2)); + # 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 dead=(data.results||[]) - .filter(r=>r.consumerCount===0 && added.has(r.name)); - if(dead.length>0){ - process.stdout.write(dead.map(u=>u.name+' ('+data.file+':'+u.line+')').join(', ')); + 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{} }); - " -- $ADDED_NAMES 2>/dev/null) || true + " 2>/dev/null) || true - if [ -n "$NEWLY_DEAD" ]; then - NEW_DEAD="${NEW_DEAD:+$NEW_DEAD; }$NEWLY_DEAD" + if [ -n "$UNUSED" ]; then + DEAD_EXPORTS="${DEAD_EXPORTS:+$DEAD_EXPORTS; }$UNUSED" fi done <<< "$STAGED" -if [ -n "$NEW_DEAD" ]; then - REASON="BLOCKED: Newly added exports with zero consumers detected: $NEW_DEAD. Either add consumers, remove the exports, or verify these are intentionally public API." +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({ diff --git a/docs/guides/recommended-practices.md b/docs/guides/recommended-practices.md index f4bfdc6b..304d47f1 100644 --- a/docs/guides/recommended-practices.md +++ b/docs/guides/recommended-practices.md @@ -335,6 +335,10 @@ 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. + **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 +352,8 @@ 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 #### Parallel session safety hooks From d5355505b491fa0ff09e6ae782b779986dc67d73 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 8 Mar 2026 20:30:55 -0600 Subject: [PATCH 14/21] 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. --- .claude/hooks/warn-signature-changes.sh | 129 ++++++++++++++++++++++++ .claude/settings.json | 5 + docs/guides/recommended-practices.md | 3 + 3 files changed, 137 insertions(+) create mode 100644 .claude/hooks/warn-signature-changes.sh diff --git a/.claude/hooks/warn-signature-changes.sh b/.claude/hooks/warn-signature-changes.sh new file mode 100644 index 00000000..80d8cfda --- /dev/null +++ b/.claude/hooks/warn-signature-changes.sh @@ -0,0 +1,129 @@ +#!/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 = []; + + 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/.claude/settings.json b/.claude/settings.json index 68fba780..fe942475 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -33,6 +33,11 @@ "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/docs/guides/recommended-practices.md b/docs/guides/recommended-practices.md index 304d47f1..030db0c8 100644 --- a/docs/guides/recommended-practices.md +++ b/docs/guides/recommended-practices.md @@ -339,6 +339,8 @@ You can configure [Claude Code hooks](https://docs.anthropic.com/en/docs/claude- **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. @@ -354,6 +356,7 @@ You can configure [Claude Code hooks](https://docs.anthropic.com/en/docs/claude- - `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 From e5580a25df1f8f36adba43596b9e4c0359956cc2 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 8 Mar 2026 20:33:41 -0600 Subject: [PATCH 15/21] fix: remove duplicate declarations and fix sequence.js indentation - Remove duplicate `totalUnused` declaration in queries.js (merge artifact) - Remove duplicate `unused` property in cli.js exports command - Fix indentation of try/finally block in sequence.js Impact: 2 functions changed, 6 affected --- src/cli.js | 1 - src/queries.js | 2 - src/sequence.js | 376 ++++++++++++++++++++++++------------------------ 3 files changed, 188 insertions(+), 191 deletions(-) diff --git a/src/cli.js b/src/cli.js index 9e379e4b..0037fd5a 100644 --- a/src/cli.js +++ b/src/cli.js @@ -287,7 +287,6 @@ program 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/queries.js b/src/queries.js index af26e58e..24d53e32 100644 --- a/src/queries.js +++ b/src/queries.js @@ -3214,8 +3214,6 @@ function exportsFileImpl(db, target, noTests, getFileLines, unused) { .all(fn.id) .map((r) => ({ file: r.file })); - const totalUnused = results.filter((r) => r.consumerCount === 0).length; - let filteredResults = results; if (unused) { filteredResults = results.filter((r) => r.consumerCount === 0); diff --git a/src/sequence.js b/src/sequence.js index 48b7e346..0c339f06 100644 --- a/src/sequence.js +++ b/src/sequence.js @@ -86,205 +86,205 @@ function buildAliases(files) { export function sequenceData(name, dbPath, opts = {}) { const db = openReadonlyOrFail(dbPath); 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) { - 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, - }; - - // 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); + 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; } } - - 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; + + if (!matchNode) { + return { + entry: null, + participants: [], + messages: [], + depth: maxDepth, + totalMessages: 0, + truncated: false, + }; } - } - - // 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'; + + 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: 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); - } - - 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; + + // 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); + } + + 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(); } From 633bc742f0665c17a013489ac74d75cc4f5ad184 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 8 Mar 2026 20:33:58 -0600 Subject: [PATCH 16/21] fix: remove trailing whitespace in sequence.js Impact: 1 functions changed, 1 affected --- src/sequence.js | 60 ++++++++++++++++++++++++------------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/src/sequence.js b/src/sequence.js index 0c339f06..ada5ea80 100644 --- a/src/sequence.js +++ b/src/sequence.js @@ -89,10 +89,10 @@ export function sequenceData(name, dbPath, opts = {}) { 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) { @@ -100,7 +100,7 @@ export function sequenceData(name, dbPath, opts = {}) { if (matchNode) break; } } - + if (!matchNode) { return { entry: null, @@ -111,14 +111,14 @@ export function sequenceData(name, dbPath, opts = {}) { truncated: false, }; } - + 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]; @@ -127,24 +127,24 @@ export function sequenceData(name, dbPath, opts = {}) { 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({ @@ -154,38 +154,38 @@ export function sequenceData(name, dbPath, opts = {}) { type: 'call', depth: d, }); - + if (visited.has(c.id)) continue; - + visited.add(c.id); nextFrontier.push(c.id); idToNode.set(c.id, c); } } - + 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'`, @@ -195,19 +195,19 @@ export function sequenceData(name, dbPath, opts = {}) { 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'; @@ -220,15 +220,15 @@ export function sequenceData(name, dbPath, opts = {}) { }); } } - + // 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) @@ -241,7 +241,7 @@ export function sequenceData(name, dbPath, opts = {}) { } } } - + // Sort messages by depth, then call before return messages.sort((a, b) => { if (a.depth !== b.depth) return a.depth - b.depth; @@ -249,7 +249,7 @@ export function sequenceData(name, dbPath, opts = {}) { 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) => ({ @@ -257,20 +257,20 @@ export function sequenceData(name, dbPath, opts = {}) { 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); } - + const base = { entry, participants, From e90d3ec77bf71edefd9ac3a2d326fce3b3d86ae7 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 8 Mar 2026 20:38:11 -0600 Subject: [PATCH 17/21] fix: remove duplicate --unused option in exports CLI command Merge from main introduced a second .option('--unused', ...) on the exports command, causing commander to throw at startup. --- src/cli.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/cli.js b/src/cli.js index 0037fd5a..aa008e92 100644 --- a/src/cli.js +++ b/src/cli.js @@ -278,7 +278,6 @@ program .option('--offset ', 'Skip N results (default: 0)') .option('--unused', 'Show only exports with zero consumers (dead exports)') .option('--ndjson', 'Newline-delimited JSON output') - .option('--unused', 'Show only exports with zero consumers') .action((file, opts) => { fileExports(file, opts.db, { noTests: resolveNoTests(opts), From 386e86517983892b163ac624949fdd0acf3d063c Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 8 Mar 2026 20:30:55 -0600 Subject: [PATCH 18/21] 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. --- .claude/hooks/warn-signature-changes.sh | 129 ++++++++++++++++++++++++ .claude/settings.json | 5 + docs/guides/recommended-practices.md | 3 + 3 files changed, 137 insertions(+) create mode 100644 .claude/hooks/warn-signature-changes.sh diff --git a/.claude/hooks/warn-signature-changes.sh b/.claude/hooks/warn-signature-changes.sh new file mode 100644 index 00000000..80d8cfda --- /dev/null +++ b/.claude/hooks/warn-signature-changes.sh @@ -0,0 +1,129 @@ +#!/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 = []; + + 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/.claude/settings.json b/.claude/settings.json index 68fba780..fe942475 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -33,6 +33,11 @@ "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/docs/guides/recommended-practices.md b/docs/guides/recommended-practices.md index 304d47f1..030db0c8 100644 --- a/docs/guides/recommended-practices.md +++ b/docs/guides/recommended-practices.md @@ -339,6 +339,8 @@ You can configure [Claude Code hooks](https://docs.anthropic.com/en/docs/claude- **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. @@ -354,6 +356,7 @@ You can configure [Claude Code hooks](https://docs.anthropic.com/en/docs/claude- - `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 From 2637d4260b7c08e82c8427fbb170a6c9878c4940 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 8 Mar 2026 20:54:59 -0600 Subject: [PATCH 19/21] merge: resolve conflicts with main Impact: 1 functions changed, 23 affected --- src/queries.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/queries.js b/src/queries.js index f6eeb64e..5dcb8685 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]; From 7d173821a201ad11d10c473e2ddd4f5b112ed6f3 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 8 Mar 2026 20:55:40 -0600 Subject: [PATCH 20/21] =?UTF-8?q?fix:=20address=20greptile=20review=20?= =?UTF-8?q?=E2=80=94=20flow=20kind=20regression=20and=20hook=20consistency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - findMatchingNodes now accepts opts.kinds to override default FUNCTION_KINDS - flowData passes CORE_SYMBOL_KINDS (all 10 kinds) so flow command can trace from interfaces, types, structs, etc. — restoring pre-refactor behavior - guard-pr-body.sh switched from $CLAUDE_TOOL_INPUT to stdin pattern, consistent with check-cycles.sh and other hooks Impact: 2 functions changed, 40 affected --- .claude/hooks/guard-pr-body.sh | 17 +++++++++++++---- src/flow.js | 10 ++++++---- src/queries.js | 2 +- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/.claude/hooks/guard-pr-body.sh b/.claude/hooks/guard-pr-body.sh index ce125431..df2e7726 100644 --- a/.claude/hooks/guard-pr-body.sh +++ b/.claude/hooks/guard-pr-body.sh @@ -1,11 +1,20 @@ #!/usr/bin/env bash # Block PR creation if the body contains "generated with" (case-insensitive) -input="$CLAUDE_TOOL_INPUT" +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 -# Only check gh pr create commands — extract just the command field to avoid -# false positives on the description field (greptile review feedback) -cmd=$(echo "$input" | jq -r '.command // ""') echo "$cmd" | grep -qi 'gh pr create' || exit 0 # Block if body contains "generated with" 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 5dcb8685..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] : (opts.kinds || FUNCTION_KINDS); + const kinds = opts.kind ? [opts.kind] : opts.kinds || FUNCTION_KINDS; const placeholders = kinds.map(() => '?').join(', '); const params = [`%${name}%`, ...kinds]; From 9e6192ff0137742cb86ebccfac2a071ecb83c034 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 8 Mar 2026 21:04:22 -0600 Subject: [PATCH 21/21] fix: surface triage query errors instead of misleading "no symbols" message When the SQL query fails (e.g. invalid --kind or missing table), the catch block now returns an error field. The CLI formatter checks this field first, showing the real error instead of "No symbols found. Run codegraph build first." Impact: 2 functions changed, 4 affected --- src/triage.js | 5 ++++- tests/integration/triage.test.js | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) 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,