@optave/codegraph 3.0.3 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cfg.js CHANGED
@@ -1046,15 +1046,29 @@ export function buildFunctionCFG(functionNode, langId) {
1046
1046
  export async function buildCFGData(db, fileSymbols, rootDir, _engineOpts) {
1047
1047
  // Lazily init WASM parsers if needed
1048
1048
  let parsers = null;
1049
- let extToLang = null;
1050
1049
  let needsFallback = false;
1051
1050
 
1051
+ // Always build ext→langId map so native-only builds (where _langId is unset)
1052
+ // can still derive the language from the file extension.
1053
+ const extToLang = new Map();
1054
+ for (const entry of LANGUAGE_REGISTRY) {
1055
+ for (const ext of entry.extensions) {
1056
+ extToLang.set(ext, entry.id);
1057
+ }
1058
+ }
1059
+
1052
1060
  for (const [relPath, symbols] of fileSymbols) {
1053
1061
  if (!symbols._tree) {
1054
1062
  const ext = path.extname(relPath).toLowerCase();
1055
1063
  if (CFG_EXTENSIONS.has(ext)) {
1056
- needsFallback = true;
1057
- break;
1064
+ // Check if all function/method defs already have native CFG data
1065
+ const hasNativeCfg = symbols.definitions
1066
+ .filter((d) => (d.kind === 'function' || d.kind === 'method') && d.line)
1067
+ .every((d) => d.cfg === null || d.cfg?.blocks?.length);
1068
+ if (!hasNativeCfg) {
1069
+ needsFallback = true;
1070
+ break;
1071
+ }
1058
1072
  }
1059
1073
  }
1060
1074
  }
@@ -1062,12 +1076,6 @@ export async function buildCFGData(db, fileSymbols, rootDir, _engineOpts) {
1062
1076
  if (needsFallback) {
1063
1077
  const { createParsers } = await import('./parser.js');
1064
1078
  parsers = await createParsers();
1065
- extToLang = new Map();
1066
- for (const entry of LANGUAGE_REGISTRY) {
1067
- for (const ext of entry.extensions) {
1068
- extToLang.set(ext, entry.id);
1069
- }
1070
- }
1071
1079
  }
1072
1080
 
1073
1081
  let getParserFn = null;
@@ -1102,9 +1110,14 @@ export async function buildCFGData(db, fileSymbols, rootDir, _engineOpts) {
1102
1110
  let tree = symbols._tree;
1103
1111
  let langId = symbols._langId;
1104
1112
 
1105
- // WASM fallback if no cached tree
1106
- if (!tree) {
1107
- if (!extToLang || !getParserFn) continue;
1113
+ // Check if all defs already have native CFG — skip WASM parse if so
1114
+ const allNative = symbols.definitions
1115
+ .filter((d) => (d.kind === 'function' || d.kind === 'method') && d.line)
1116
+ .every((d) => d.cfg === null || d.cfg?.blocks?.length);
1117
+
1118
+ // WASM fallback if no cached tree and not all native
1119
+ if (!tree && !allNative) {
1120
+ if (!getParserFn) continue;
1108
1121
  langId = extToLang.get(ext);
1109
1122
  if (!langId || !CFG_LANG_IDS.has(langId)) continue;
1110
1123
 
@@ -1127,7 +1140,7 @@ export async function buildCFGData(db, fileSymbols, rootDir, _engineOpts) {
1127
1140
  }
1128
1141
 
1129
1142
  if (!langId) {
1130
- langId = extToLang ? extToLang.get(ext) : null;
1143
+ langId = extToLang.get(ext);
1131
1144
  if (!langId) continue;
1132
1145
  }
1133
1146
 
@@ -1135,7 +1148,7 @@ export async function buildCFGData(db, fileSymbols, rootDir, _engineOpts) {
1135
1148
  if (!cfgRules) continue;
1136
1149
 
1137
1150
  const complexityRules = COMPLEXITY_RULES.get(langId);
1138
- if (!complexityRules) continue;
1151
+ // complexityRules only needed for WASM fallback path
1139
1152
 
1140
1153
  for (const def of symbols.definitions) {
1141
1154
  if (def.kind !== 'function' && def.kind !== 'method') continue;
@@ -1144,11 +1157,19 @@ export async function buildCFGData(db, fileSymbols, rootDir, _engineOpts) {
1144
1157
  const row = getNodeId.get(def.name, relPath, def.line);
1145
1158
  if (!row) continue;
1146
1159
 
1147
- const funcNode = findFunctionNode(tree.rootNode, def.line, def.endLine, complexityRules);
1148
- if (!funcNode) continue;
1160
+ // Native path: use pre-computed CFG from Rust engine
1161
+ let cfg = null;
1162
+ if (def.cfg?.blocks?.length) {
1163
+ cfg = def.cfg;
1164
+ } else {
1165
+ // WASM fallback: compute CFG from tree-sitter AST
1166
+ if (!tree || !complexityRules) continue;
1167
+ const funcNode = findFunctionNode(tree.rootNode, def.line, def.endLine, complexityRules);
1168
+ if (!funcNode) continue;
1169
+ cfg = buildFunctionCFG(funcNode, langId);
1170
+ }
1149
1171
 
1150
- const cfg = buildFunctionCFG(funcNode, langId);
1151
- if (cfg.blocks.length === 0) continue;
1172
+ if (!cfg || cfg.blocks.length === 0) continue;
1152
1173
 
1153
1174
  // Clear old CFG data for this function
1154
1175
  deleteEdges.run(row.id);
package/src/cli.js CHANGED
@@ -277,6 +277,7 @@ program
277
277
  .option('--limit <number>', 'Max results to return')
278
278
  .option('--offset <number>', 'Skip N results (default: 0)')
279
279
  .option('--ndjson', 'Newline-delimited JSON output')
280
+ .option('--unused', 'Show only exports with zero consumers')
280
281
  .action((file, opts) => {
281
282
  fileExports(file, opts.db, {
282
283
  noTests: resolveNoTests(opts),
@@ -284,6 +285,7 @@ program
284
285
  limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
285
286
  offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
286
287
  ndjson: opts.ndjson,
288
+ unused: opts.unused,
287
289
  });
288
290
  });
289
291
 
@@ -1137,6 +1139,39 @@ program
1137
1139
  });
1138
1140
  });
1139
1141
 
1142
+ program
1143
+ .command('sequence <name>')
1144
+ .description('Generate a Mermaid sequence diagram from call graph edges (participants = files)')
1145
+ .option('--depth <n>', 'Max forward traversal depth', '10')
1146
+ .option('--dataflow', 'Annotate with parameter names and return arrows from dataflow table')
1147
+ .option('-d, --db <path>', 'Path to graph.db')
1148
+ .option('-f, --file <path>', 'Scope to a specific file (partial match)')
1149
+ .option('-k, --kind <kind>', 'Filter by symbol kind')
1150
+ .option('-T, --no-tests', 'Exclude test/spec files from results')
1151
+ .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
1152
+ .option('-j, --json', 'Output as JSON')
1153
+ .option('--limit <number>', 'Max results to return')
1154
+ .option('--offset <number>', 'Skip N results (default: 0)')
1155
+ .option('--ndjson', 'Newline-delimited JSON output')
1156
+ .action(async (name, opts) => {
1157
+ if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
1158
+ console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
1159
+ process.exit(1);
1160
+ }
1161
+ const { sequence } = await import('./sequence.js');
1162
+ sequence(name, opts.db, {
1163
+ depth: parseInt(opts.depth, 10),
1164
+ file: opts.file,
1165
+ kind: opts.kind,
1166
+ noTests: resolveNoTests(opts),
1167
+ json: opts.json,
1168
+ dataflow: opts.dataflow,
1169
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
1170
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
1171
+ ndjson: opts.ndjson,
1172
+ });
1173
+ });
1174
+
1140
1175
  program
1141
1176
  .command('dataflow <name>')
1142
1177
  .description('Show data flow for a function: parameters, return consumers, mutations')
package/src/dataflow.js CHANGED
@@ -1005,11 +1005,19 @@ function collectIdentifiers(node, out, rules) {
1005
1005
  export async function buildDataflowEdges(db, fileSymbols, rootDir, _engineOpts) {
1006
1006
  // Lazily init WASM parsers if needed
1007
1007
  let parsers = null;
1008
- let extToLang = null;
1009
1008
  let needsFallback = false;
1010
1009
 
1010
+ // Always build ext→langId map so native-only builds (where _langId is unset)
1011
+ // can still derive the language from the file extension.
1012
+ const extToLang = new Map();
1013
+ for (const entry of LANGUAGE_REGISTRY) {
1014
+ for (const ext of entry.extensions) {
1015
+ extToLang.set(ext, entry.id);
1016
+ }
1017
+ }
1018
+
1011
1019
  for (const [relPath, symbols] of fileSymbols) {
1012
- if (!symbols._tree) {
1020
+ if (!symbols._tree && !symbols.dataflow) {
1013
1021
  const ext = path.extname(relPath).toLowerCase();
1014
1022
  if (DATAFLOW_EXTENSIONS.has(ext)) {
1015
1023
  needsFallback = true;
@@ -1021,12 +1029,6 @@ export async function buildDataflowEdges(db, fileSymbols, rootDir, _engineOpts)
1021
1029
  if (needsFallback) {
1022
1030
  const { createParsers } = await import('./parser.js');
1023
1031
  parsers = await createParsers();
1024
- extToLang = new Map();
1025
- for (const entry of LANGUAGE_REGISTRY) {
1026
- for (const ext of entry.extensions) {
1027
- extToLang.set(ext, entry.id);
1028
- }
1029
- }
1030
1032
  }
1031
1033
 
1032
1034
  let getParserFn = null;
@@ -1061,41 +1063,45 @@ export async function buildDataflowEdges(db, fileSymbols, rootDir, _engineOpts)
1061
1063
  const ext = path.extname(relPath).toLowerCase();
1062
1064
  if (!DATAFLOW_EXTENSIONS.has(ext)) continue;
1063
1065
 
1064
- let tree = symbols._tree;
1065
- let langId = symbols._langId;
1066
+ // Use native dataflow data if available — skip WASM extraction
1067
+ let data = symbols.dataflow;
1068
+ if (!data) {
1069
+ let tree = symbols._tree;
1070
+ let langId = symbols._langId;
1071
+
1072
+ // WASM fallback if no cached tree
1073
+ if (!tree) {
1074
+ if (!getParserFn) continue;
1075
+ langId = extToLang.get(ext);
1076
+ if (!langId || !DATAFLOW_LANG_IDS.has(langId)) continue;
1077
+
1078
+ const absPath = path.join(rootDir, relPath);
1079
+ let code;
1080
+ try {
1081
+ code = fs.readFileSync(absPath, 'utf-8');
1082
+ } catch {
1083
+ continue;
1084
+ }
1066
1085
 
1067
- // WASM fallback if no cached tree
1068
- if (!tree) {
1069
- if (!extToLang || !getParserFn) continue;
1070
- langId = extToLang.get(ext);
1071
- if (!langId || !DATAFLOW_LANG_IDS.has(langId)) continue;
1086
+ const parser = getParserFn(parsers, absPath);
1087
+ if (!parser) continue;
1072
1088
 
1073
- const absPath = path.join(rootDir, relPath);
1074
- let code;
1075
- try {
1076
- code = fs.readFileSync(absPath, 'utf-8');
1077
- } catch {
1078
- continue;
1089
+ try {
1090
+ tree = parser.parse(code);
1091
+ } catch {
1092
+ continue;
1093
+ }
1079
1094
  }
1080
1095
 
1081
- const parser = getParserFn(parsers, absPath);
1082
- if (!parser) continue;
1083
-
1084
- try {
1085
- tree = parser.parse(code);
1086
- } catch {
1087
- continue;
1096
+ if (!langId) {
1097
+ langId = extToLang.get(ext);
1098
+ if (!langId) continue;
1088
1099
  }
1089
- }
1090
1100
 
1091
- if (!langId) {
1092
- langId = extToLang ? extToLang.get(ext) : null;
1093
- if (!langId) continue;
1094
- }
1095
-
1096
- if (!DATAFLOW_RULES.has(langId)) continue;
1101
+ if (!DATAFLOW_RULES.has(langId)) continue;
1097
1102
 
1098
- const data = extractDataflow(tree, relPath, symbols.definitions, langId);
1103
+ data = extractDataflow(tree, relPath, symbols.definitions, langId);
1104
+ }
1099
1105
 
1100
1106
  // Resolve function names to node IDs in this file first, then globally
1101
1107
  function resolveNode(funcName) {
package/src/db.js CHANGED
@@ -225,6 +225,13 @@ export const MIGRATIONS = [
225
225
  CREATE INDEX IF NOT EXISTS idx_ast_kind_name ON ast_nodes(kind, name);
226
226
  `,
227
227
  },
228
+ {
229
+ version: 14,
230
+ up: `
231
+ ALTER TABLE nodes ADD COLUMN exported INTEGER DEFAULT 0;
232
+ CREATE INDEX IF NOT EXISTS idx_nodes_exported ON nodes(exported);
233
+ `,
234
+ },
228
235
  ];
229
236
 
230
237
  export function getBuildMeta(db, key) {
package/src/flow.js CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { openReadonlyOrFail } from './db.js';
9
9
  import { paginateResult, printNdjson } from './paginate.js';
10
- import { isTestFile, kindIcon } from './queries.js';
10
+ import { findMatchingNodes, isTestFile, kindIcon } from './queries.js';
11
11
  import { FRAMEWORK_ENTRY_PREFIXES } from './structure.js';
12
12
 
13
13
  /**
@@ -95,12 +95,12 @@ export function flowData(name, dbPath, opts = {}) {
95
95
  const noTests = opts.noTests || false;
96
96
 
97
97
  // Phase 1: Direct LIKE match on full name
98
- let matchNode = findBestMatch(db, name, opts);
98
+ let matchNode = findMatchingNodes(db, name, opts)[0] ?? null;
99
99
 
100
100
  // Phase 2: Prefix-stripped matching — try adding framework prefixes
101
101
  if (!matchNode) {
102
102
  for (const prefix of FRAMEWORK_ENTRY_PREFIXES) {
103
- matchNode = findBestMatch(db, `${prefix}${name}`, opts);
103
+ matchNode = findMatchingNodes(db, `${prefix}${name}`, opts)[0] ?? null;
104
104
  if (matchNode) break;
105
105
  }
106
106
  }
@@ -219,73 +219,6 @@ export function flowData(name, dbPath, opts = {}) {
219
219
  return paginateResult(base, 'steps', { limit: opts.limit, offset: opts.offset });
220
220
  }
221
221
 
222
- /**
223
- * Find the best matching node using the same relevance scoring as queries.js findMatchingNodes.
224
- */
225
- function findBestMatch(db, name, opts = {}) {
226
- const kinds = opts.kind
227
- ? [opts.kind]
228
- : [
229
- 'function',
230
- 'method',
231
- 'class',
232
- 'interface',
233
- 'type',
234
- 'struct',
235
- 'enum',
236
- 'trait',
237
- 'record',
238
- 'module',
239
- ];
240
- const placeholders = kinds.map(() => '?').join(', ');
241
- const params = [`%${name}%`, ...kinds];
242
-
243
- let fileCondition = '';
244
- if (opts.file) {
245
- fileCondition = ' AND n.file LIKE ?';
246
- params.push(`%${opts.file}%`);
247
- }
248
-
249
- const rows = db
250
- .prepare(
251
- `SELECT n.*, COALESCE(fi.cnt, 0) AS fan_in
252
- FROM nodes n
253
- LEFT JOIN (
254
- SELECT target_id, COUNT(*) AS cnt FROM edges WHERE kind = 'calls' GROUP BY target_id
255
- ) fi ON fi.target_id = n.id
256
- WHERE n.name LIKE ? AND n.kind IN (${placeholders})${fileCondition}`,
257
- )
258
- .all(...params);
259
-
260
- const noTests = opts.noTests || false;
261
- const nodes = noTests ? rows.filter((n) => !isTestFile(n.file)) : rows;
262
-
263
- if (nodes.length === 0) return null;
264
-
265
- const lowerQuery = name.toLowerCase();
266
- for (const node of nodes) {
267
- const lowerName = node.name.toLowerCase();
268
- const bareName = lowerName.includes('.') ? lowerName.split('.').pop() : lowerName;
269
-
270
- let matchScore;
271
- if (lowerName === lowerQuery || bareName === lowerQuery) {
272
- matchScore = 100;
273
- } else if (lowerName.startsWith(lowerQuery) || bareName.startsWith(lowerQuery)) {
274
- matchScore = 60;
275
- } else if (lowerName.includes(`.${lowerQuery}`) || lowerName.includes(`${lowerQuery}.`)) {
276
- matchScore = 40;
277
- } else {
278
- matchScore = 10;
279
- }
280
-
281
- const fanInBonus = Math.min(Math.log2(node.fan_in + 1) * 5, 25);
282
- node._relevance = matchScore + fanInBonus;
283
- }
284
-
285
- nodes.sort((a, b) => b._relevance - a._relevance);
286
- return nodes[0];
287
- }
288
-
289
222
  /**
290
223
  * CLI formatter — text or JSON output.
291
224
  */
package/src/index.js CHANGED
@@ -121,7 +121,6 @@ export { isNativeAvailable } from './native.js';
121
121
  export { matchOwners, owners, ownersData, ownersForFiles, parseCodeowners } from './owners.js';
122
122
  // Pagination utilities
123
123
  export { MCP_DEFAULTS, MCP_MAX_LIMIT, paginate, paginateResult, printNdjson } from './paginate.js';
124
-
125
124
  // Unified parser API
126
125
  export { getActiveEngine, isWasmAvailable, parseFileAuto, parseFilesAuto } from './parser.js';
127
126
  // Query functions (data-returning)
@@ -170,6 +169,8 @@ export {
170
169
  saveRegistry,
171
170
  unregisterRepo,
172
171
  } from './registry.js';
172
+ // Sequence diagram generation
173
+ export { sequence, sequenceData, sequenceToMermaid } from './sequence.js';
173
174
  // Snapshot management
174
175
  export {
175
176
  snapshotDelete,
package/src/mcp.js CHANGED
@@ -113,6 +113,11 @@ const BASE_TOOLS = [
113
113
  properties: {
114
114
  file: { type: 'string', description: 'File path (partial match supported)' },
115
115
  no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
116
+ unused: {
117
+ type: 'boolean',
118
+ description: 'Show only exports with zero consumers',
119
+ default: false,
120
+ },
116
121
  ...PAGINATION_PROPS,
117
122
  },
118
123
  required: ['file'],
@@ -418,6 +423,43 @@ const BASE_TOOLS = [
418
423
  },
419
424
  },
420
425
  },
426
+ {
427
+ name: 'sequence',
428
+ description:
429
+ 'Generate a Mermaid sequence diagram from call graph edges. Participants are files, messages are function calls between them.',
430
+ inputSchema: {
431
+ type: 'object',
432
+ properties: {
433
+ name: {
434
+ type: 'string',
435
+ description: 'Entry point or function name to trace from (partial match)',
436
+ },
437
+ depth: { type: 'number', description: 'Max forward traversal depth', default: 10 },
438
+ format: {
439
+ type: 'string',
440
+ enum: ['mermaid', 'json'],
441
+ description: 'Output format (default: mermaid)',
442
+ },
443
+ dataflow: {
444
+ type: 'boolean',
445
+ description: 'Annotate with parameter names and return arrows',
446
+ default: false,
447
+ },
448
+ file: {
449
+ type: 'string',
450
+ description: 'Scope search to functions in this file (partial match)',
451
+ },
452
+ kind: {
453
+ type: 'string',
454
+ enum: EVERY_SYMBOL_KIND,
455
+ description: 'Filter to a specific symbol kind',
456
+ },
457
+ no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
458
+ ...PAGINATION_PROPS,
459
+ },
460
+ required: ['name'],
461
+ },
462
+ },
421
463
  {
422
464
  name: 'complexity',
423
465
  description:
@@ -902,6 +944,7 @@ export async function startMCPServer(customDbPath, options = {}) {
902
944
  case 'file_exports':
903
945
  result = exportsData(args.file, dbPath, {
904
946
  noTests: args.no_tests,
947
+ unused: args.unused,
905
948
  limit: Math.min(args.limit ?? MCP_DEFAULTS.file_exports, MCP_MAX_LIMIT),
906
949
  offset: args.offset ?? 0,
907
950
  });
@@ -1165,6 +1208,23 @@ export async function startMCPServer(customDbPath, options = {}) {
1165
1208
  }
1166
1209
  break;
1167
1210
  }
1211
+ case 'sequence': {
1212
+ const { sequenceData, sequenceToMermaid } = await import('./sequence.js');
1213
+ const seqResult = sequenceData(args.name, dbPath, {
1214
+ depth: args.depth,
1215
+ file: args.file,
1216
+ kind: args.kind,
1217
+ dataflow: args.dataflow,
1218
+ noTests: args.no_tests,
1219
+ limit: Math.min(args.limit ?? MCP_DEFAULTS.execution_flow, MCP_MAX_LIMIT),
1220
+ offset: args.offset ?? 0,
1221
+ });
1222
+ result =
1223
+ args.format === 'json'
1224
+ ? seqResult
1225
+ : { text: sequenceToMermaid(seqResult), ...seqResult };
1226
+ break;
1227
+ }
1168
1228
  case 'complexity': {
1169
1229
  const { complexityData } = await import('./complexity.js');
1170
1230
  result = complexityData(dbPath, {
package/src/native.js CHANGED
@@ -12,9 +12,27 @@ import os from 'node:os';
12
12
  let _cached; // undefined = not yet tried, null = failed, object = module
13
13
  let _loadError = null;
14
14
 
15
- /** Map of (platform-arch) → npm package name. */
15
+ /**
16
+ * Detect whether the current Linux environment uses glibc or musl.
17
+ * Returns 'gnu' for glibc, 'musl' for musl, 'gnu' as fallback.
18
+ */
19
+ function detectLibc() {
20
+ try {
21
+ const { readdirSync } = require('node:fs');
22
+ const files = readdirSync('/lib');
23
+ if (files.some((f) => f.startsWith('ld-musl-') && f.endsWith('.so.1'))) {
24
+ return 'musl';
25
+ }
26
+ } catch {}
27
+ return 'gnu';
28
+ }
29
+
30
+ /** Map of (platform-arch[-libc]) → npm package name. */
16
31
  const PLATFORM_PACKAGES = {
17
- 'linux-x64': '@optave/codegraph-linux-x64-gnu',
32
+ 'linux-x64-gnu': '@optave/codegraph-linux-x64-gnu',
33
+ 'linux-x64-musl': '@optave/codegraph-linux-x64-musl',
34
+ 'linux-arm64-gnu': '@optave/codegraph-linux-arm64-gnu',
35
+ 'linux-arm64-musl': '@optave/codegraph-linux-arm64-musl', // not yet published — placeholder for future CI target
18
36
  'darwin-arm64': '@optave/codegraph-darwin-arm64',
19
37
  'darwin-x64': '@optave/codegraph-darwin-x64',
20
38
  'win32-x64': '@optave/codegraph-win32-x64-msvc',
@@ -29,7 +47,9 @@ export function loadNative() {
29
47
 
30
48
  const require = createRequire(import.meta.url);
31
49
 
32
- const key = `${os.platform()}-${os.arch()}`;
50
+ const platform = os.platform();
51
+ const arch = os.arch();
52
+ const key = platform === 'linux' ? `${platform}-${arch}-${detectLibc()}` : `${platform}-${arch}`;
33
53
  const pkg = PLATFORM_PACKAGES[key];
34
54
  if (pkg) {
35
55
  try {