From 7e96df0c17ec1555f94b23bc99a7752951252416 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 8 Mar 2026 21:08:37 -0600 Subject: [PATCH 1/4] fix: use CORE_SYMBOL_KINDS in flow matching for interfaces/types/structs flow's findMatchingNodes was limited to FUNCTION_KINDS, so `codegraph flow` couldn't trace from interfaces, types, or struct declarations. Pass CORE_SYMBOL_KINDS via opts.kinds override so all 10 core symbol kinds are searchable. Also support opts.kinds array in findMatchingNodes as a general-purpose override alongside the existing opts.kind scalar. Impact: 2 functions changed, 40 affected --- src/flow.js | 10 ++++++---- src/queries.js | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/flow.js b/src/flow.js index 84bbc40b..6679df83 100644 --- a/src/flow.js +++ b/src/flow.js @@ -7,7 +7,7 @@ import { openReadonlyOrFail } from './db.js'; import { paginateResult } from './paginate.js'; -import { findMatchingNodes, kindIcon } from './queries.js'; +import { CORE_SYMBOL_KINDS, findMatchingNodes, kindIcon } from './queries.js'; import { outputResult } from './result-formatter.js'; import { FRAMEWORK_ENTRY_PREFIXES } from './structure.js'; import { isTestFile } from './test-filter.js'; @@ -96,13 +96,15 @@ export function flowData(name, dbPath, opts = {}) { const maxDepth = opts.depth || 10; const noTests = opts.noTests || false; - // Phase 1: Direct LIKE match on full name - let matchNode = findMatchingNodes(db, name, opts)[0] ?? null; + // Phase 1: Direct LIKE match on full name (use all 10 core symbol kinds, + // not just FUNCTION_KINDS, so flow can trace from interfaces/types/structs/etc.) + const flowOpts = { ...opts, kinds: CORE_SYMBOL_KINDS }; + let matchNode = findMatchingNodes(db, name, flowOpts)[0] ?? null; // Phase 2: Prefix-stripped matching — try adding framework prefixes if (!matchNode) { for (const prefix of FRAMEWORK_ENTRY_PREFIXES) { - matchNode = findMatchingNodes(db, `${prefix}${name}`, opts)[0] ?? null; + matchNode = findMatchingNodes(db, `${prefix}${name}`, flowOpts)[0] ?? null; if (matchNode) break; } } diff --git a/src/queries.js b/src/queries.js index f6eeb64e..6b11d324 100644 --- a/src/queries.js +++ b/src/queries.js @@ -164,7 +164,7 @@ function resolveMethodViaHierarchy(db, methodName) { * Scoring: exact=100, prefix=60, word-boundary=40, substring=10, plus fan-in tiebreaker. */ export function findMatchingNodes(db, name, opts = {}) { - const kinds = opts.kind ? [opts.kind] : FUNCTION_KINDS; + const kinds = opts.kind ? [opts.kind] : opts.kinds || FUNCTION_KINDS; const placeholders = kinds.map(() => '?').join(', '); const params = [`%${name}%`, ...kinds]; From 565d998c2cc79ddae4bdbc376ca8de82f966db14 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 8 Mar 2026 21:09:41 -0600 Subject: [PATCH 2/4] refactor: wrap sequenceData in try/finally for reliable db.close() Replace manual db.close() calls before each return with a single try/finally block. Ensures the database connection is always released, even if an unexpected error occurs mid-execution. Impact: 1 functions changed, 1 affected --- src/sequence.js | 365 ++++++++++++++++++++++++------------------------ 1 file changed, 183 insertions(+), 182 deletions(-) diff --git a/src/sequence.js b/src/sequence.js index 840361d7..5b5d11c4 100644 --- a/src/sequence.js +++ b/src/sequence.js @@ -87,208 +87,209 @@ function buildAliases(files) { */ export function sequenceData(name, dbPath, opts = {}) { const db = openReadonlyOrFail(dbPath); - const maxDepth = opts.depth || 10; - const noTests = opts.noTests || false; - const withDataflow = opts.dataflow || false; - - // Phase 1: Direct LIKE match - let matchNode = findMatchingNodes(db, name, opts)[0] ?? null; - - // Phase 2: Prefix-stripped matching - if (!matchNode) { - for (const prefix of FRAMEWORK_ENTRY_PREFIXES) { - matchNode = findMatchingNodes(db, `${prefix}${name}`, opts)[0] ?? null; - if (matchNode) break; + try { + const maxDepth = opts.depth || 10; + const noTests = opts.noTests || false; + const withDataflow = opts.dataflow || false; + + // Phase 1: Direct LIKE match + let matchNode = findMatchingNodes(db, name, opts)[0] ?? null; + + // Phase 2: Prefix-stripped matching + if (!matchNode) { + for (const prefix of FRAMEWORK_ENTRY_PREFIXES) { + matchNode = findMatchingNodes(db, `${prefix}${name}`, opts)[0] ?? null; + if (matchNode) break; + } } - } - if (!matchNode) { - db.close(); - return { - entry: null, - participants: [], - messages: [], - depth: maxDepth, - totalMessages: 0, - truncated: false, + if (!matchNode) { + return { + entry: null, + participants: [], + messages: [], + depth: maxDepth, + totalMessages: 0, + truncated: false, + }; + } + + const entry = { + name: matchNode.name, + file: matchNode.file, + kind: matchNode.kind, + line: matchNode.line, }; - } - const entry = { - name: matchNode.name, - file: matchNode.file, - kind: matchNode.kind, - line: matchNode.line, - }; - - // BFS forward — track edges, not just nodes - const visited = new Set([matchNode.id]); - let frontier = [matchNode.id]; - const messages = []; - const fileSet = new Set([matchNode.file]); - const idToNode = new Map(); - idToNode.set(matchNode.id, matchNode); - let truncated = false; - - const getCallees = db.prepare( - `SELECT DISTINCT n.id, n.name, n.kind, n.file, n.line - FROM edges e JOIN nodes n ON e.target_id = n.id - WHERE e.source_id = ? AND e.kind = 'calls'`, - ); - - for (let d = 1; d <= maxDepth; d++) { - const nextFrontier = []; - - for (const fid of frontier) { - const callees = getCallees.all(fid); - - const caller = idToNode.get(fid); - - for (const c of callees) { - if (noTests && isTestFile(c.file)) continue; - - // Always record the message (even for visited nodes — different caller path) - fileSet.add(c.file); - messages.push({ - from: caller.file, - to: c.file, - label: c.name, - type: 'call', - depth: d, - }); - - if (visited.has(c.id)) continue; - - visited.add(c.id); - nextFrontier.push(c.id); - idToNode.set(c.id, c); - } - } + // BFS forward — track edges, not just nodes + const visited = new Set([matchNode.id]); + let frontier = [matchNode.id]; + const messages = []; + const fileSet = new Set([matchNode.file]); + const idToNode = new Map(); + idToNode.set(matchNode.id, matchNode); + let truncated = false; + + const getCallees = db.prepare( + `SELECT DISTINCT n.id, n.name, n.kind, n.file, n.line + FROM edges e JOIN nodes n ON e.target_id = n.id + WHERE e.source_id = ? AND e.kind = 'calls'`, + ); - frontier = nextFrontier; - if (frontier.length === 0) break; + for (let d = 1; d <= maxDepth; d++) { + const nextFrontier = []; - if (d === maxDepth && frontier.length > 0) { - // Only mark truncated if at least one frontier node has further callees - const hasMoreCalls = frontier.some((fid) => getCallees.all(fid).length > 0); - if (hasMoreCalls) truncated = true; - } - } + for (const fid of frontier) { + const callees = getCallees.all(fid); - // Dataflow annotations: add return arrows - if (withDataflow && messages.length > 0) { - const hasTable = db - .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='dataflow'") - .get(); - - if (hasTable) { - // Build name|file lookup for O(1) target node access - const nodeByNameFile = new Map(); - for (const n of idToNode.values()) { - nodeByNameFile.set(`${n.name}|${n.file}`, n); - } + const caller = idToNode.get(fid); + + for (const c of callees) { + if (noTests && isTestFile(c.file)) continue; - const getReturns = db.prepare( - `SELECT d.expression FROM dataflow d - WHERE d.source_id = ? AND d.kind = 'returns'`, - ); - const getFlowsTo = db.prepare( - `SELECT d.expression FROM dataflow d - WHERE d.target_id = ? AND d.kind = 'flows_to' - ORDER BY d.param_index`, - ); - - // For each called function, check if it has return edges - const seenReturns = new Set(); - for (const msg of [...messages]) { - if (msg.type !== 'call') continue; - const targetNode = nodeByNameFile.get(`${msg.label}|${msg.to}`); - if (!targetNode) continue; - - const returnKey = `${msg.to}->${msg.from}:${msg.label}`; - if (seenReturns.has(returnKey)) continue; - - const returns = getReturns.all(targetNode.id); - - if (returns.length > 0) { - seenReturns.add(returnKey); - const expr = returns[0].expression || 'result'; + // Always record the message (even for visited nodes — different caller path) + fileSet.add(c.file); messages.push({ - from: msg.to, - to: msg.from, - label: expr, - type: 'return', - depth: msg.depth, + from: caller.file, + to: c.file, + label: c.name, + type: 'call', + depth: d, }); + + if (visited.has(c.id)) continue; + + visited.add(c.id); + nextFrontier.push(c.id); + idToNode.set(c.id, c); } } - // Annotate call messages with parameter names - for (const msg of messages) { - if (msg.type !== 'call') continue; - const targetNode = nodeByNameFile.get(`${msg.label}|${msg.to}`); - if (!targetNode) continue; - - const params = getFlowsTo.all(targetNode.id); - - if (params.length > 0) { - const paramNames = params - .map((p) => p.expression) - .filter(Boolean) - .slice(0, 3); - if (paramNames.length > 0) { - msg.label = `${msg.label}(${paramNames.join(', ')})`; + frontier = nextFrontier; + if (frontier.length === 0) break; + + if (d === maxDepth && frontier.length > 0) { + // Only mark truncated if at least one frontier node has further callees + const hasMoreCalls = frontier.some((fid) => getCallees.all(fid).length > 0); + if (hasMoreCalls) truncated = true; + } + } + + // Dataflow annotations: add return arrows + if (withDataflow && messages.length > 0) { + const hasTable = db + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='dataflow'") + .get(); + + if (hasTable) { + // Build name|file lookup for O(1) target node access + const nodeByNameFile = new Map(); + for (const n of idToNode.values()) { + nodeByNameFile.set(`${n.name}|${n.file}`, n); + } + + const getReturns = db.prepare( + `SELECT d.expression FROM dataflow d + WHERE d.source_id = ? AND d.kind = 'returns'`, + ); + const getFlowsTo = db.prepare( + `SELECT d.expression FROM dataflow d + WHERE d.target_id = ? AND d.kind = 'flows_to' + ORDER BY d.param_index`, + ); + + // For each called function, check if it has return edges + const seenReturns = new Set(); + for (const msg of [...messages]) { + if (msg.type !== 'call') continue; + const targetNode = nodeByNameFile.get(`${msg.label}|${msg.to}`); + if (!targetNode) continue; + + const returnKey = `${msg.to}->${msg.from}:${msg.label}`; + if (seenReturns.has(returnKey)) continue; + + const returns = getReturns.all(targetNode.id); + + if (returns.length > 0) { + seenReturns.add(returnKey); + const expr = returns[0].expression || 'result'; + messages.push({ + from: msg.to, + to: msg.from, + label: expr, + type: 'return', + depth: msg.depth, + }); + } + } + + // Annotate call messages with parameter names + for (const msg of messages) { + if (msg.type !== 'call') continue; + const targetNode = nodeByNameFile.get(`${msg.label}|${msg.to}`); + if (!targetNode) continue; + + const params = getFlowsTo.all(targetNode.id); + + if (params.length > 0) { + const paramNames = params + .map((p) => p.expression) + .filter(Boolean) + .slice(0, 3); + if (paramNames.length > 0) { + msg.label = `${msg.label}(${paramNames.join(', ')})`; + } } } } } - } - // Sort messages by depth, then call before return - messages.sort((a, b) => { - if (a.depth !== b.depth) return a.depth - b.depth; - if (a.type === 'call' && b.type === 'return') return -1; - if (a.type === 'return' && b.type === 'call') return 1; - return 0; - }); - - // Build participant list from files - const aliases = buildAliases([...fileSet]); - const participants = [...fileSet].map((file) => ({ - id: aliases.get(file), - label: file.split('/').pop(), - file, - })); - - // Sort participants: entry file first, then alphabetically - participants.sort((a, b) => { - if (a.file === entry.file) return -1; - if (b.file === entry.file) return 1; - return a.file.localeCompare(b.file); - }); - - // Replace file paths with alias IDs in messages - for (const msg of messages) { - msg.from = aliases.get(msg.from); - msg.to = aliases.get(msg.to); - } + // Sort messages by depth, then call before return + messages.sort((a, b) => { + if (a.depth !== b.depth) return a.depth - b.depth; + if (a.type === 'call' && b.type === 'return') return -1; + if (a.type === 'return' && b.type === 'call') return 1; + return 0; + }); + + // Build participant list from files + const aliases = buildAliases([...fileSet]); + const participants = [...fileSet].map((file) => ({ + id: aliases.get(file), + label: file.split('/').pop(), + file, + })); + + // Sort participants: entry file first, then alphabetically + participants.sort((a, b) => { + if (a.file === entry.file) return -1; + if (b.file === entry.file) return 1; + return a.file.localeCompare(b.file); + }); + + // Replace file paths with alias IDs in messages + for (const msg of messages) { + msg.from = aliases.get(msg.from); + msg.to = aliases.get(msg.to); + } - db.close(); - - const base = { - entry, - participants, - messages, - depth: maxDepth, - totalMessages: messages.length, - truncated, - }; - const result = paginateResult(base, 'messages', { limit: opts.limit, offset: opts.offset }); - if (opts.limit !== undefined || opts.offset !== undefined) { - const activeFiles = new Set(result.messages.flatMap((m) => [m.from, m.to])); - result.participants = result.participants.filter((p) => activeFiles.has(p.id)); + const base = { + entry, + participants, + messages, + depth: maxDepth, + totalMessages: messages.length, + truncated, + }; + const result = paginateResult(base, 'messages', { limit: opts.limit, offset: opts.offset }); + if (opts.limit !== undefined || opts.offset !== undefined) { + const activeFiles = new Set(result.messages.flatMap((m) => [m.from, m.to])); + result.participants = result.participants.filter((p) => activeFiles.has(p.id)); + } + return result; + } finally { + db.close(); } - return result; } // ─── Mermaid formatter ─────────────────────────────────────────────── From 159b1754904af40a5c71b4a5d2c5ca2a39f81774 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 8 Mar 2026 22:27:08 -0600 Subject: [PATCH 3/4] ci: allow `merge` type in commitlint config The repo policy is never-rebase-always-merge, so merge commits are inevitable. Add `merge` to the type-enum so they pass validation. --- commitlint.config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/commitlint.config.js b/commitlint.config.js index 6e6a6481..c4340a23 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -17,6 +17,7 @@ export default { "style", "revert", "release", + "merge", ], ], "header-max-length": [2, "always", 100], From 87a3c0aaa77f1bb75738cad46a052ccdb987cc65 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 8 Mar 2026 22:35:32 -0600 Subject: [PATCH 4/4] fix: remove extra blank line in sequence.js from merge resolution Impact: 1 functions changed, 1 affected --- src/sequence.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/sequence.js b/src/sequence.js index edafe0cf..e8147b1d 100644 --- a/src/sequence.js +++ b/src/sequence.js @@ -147,7 +147,6 @@ export function sequenceData(name, dbPath, opts = {}) { 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({