@optave/codegraph 3.0.4 → 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/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,9 +1005,17 @@ 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
1020
  if (!symbols._tree && !symbols.dataflow) {
1013
1021
  const ext = path.extname(relPath).toLowerCase();
@@ -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;
@@ -1069,7 +1071,7 @@ export async function buildDataflowEdges(db, fileSymbols, rootDir, _engineOpts)
1069
1071
 
1070
1072
  // WASM fallback if no cached tree
1071
1073
  if (!tree) {
1072
- if (!extToLang || !getParserFn) continue;
1074
+ if (!getParserFn) continue;
1073
1075
  langId = extToLang.get(ext);
1074
1076
  if (!langId || !DATAFLOW_LANG_IDS.has(langId)) continue;
1075
1077
 
@@ -1092,7 +1094,7 @@ export async function buildDataflowEdges(db, fileSymbols, rootDir, _engineOpts)
1092
1094
  }
1093
1095
 
1094
1096
  if (!langId) {
1095
- langId = extToLang ? extToLang.get(ext) : null;
1097
+ langId = extToLang.get(ext);
1096
1098
  if (!langId) continue;
1097
1099
  }
1098
1100
 
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/parser.js CHANGED
@@ -183,133 +183,55 @@ function resolveEngine(opts = {}) {
183
183
  }
184
184
 
185
185
  /**
186
- * Normalize native engine output to match the camelCase convention
187
- * used by the WASM extractors.
186
+ * Patch native engine output in-place for the few remaining semantic transforms.
187
+ * With #[napi(js_name)] on Rust types, most fields already arrive as camelCase.
188
+ * This only handles:
189
+ * - _lineCount compat for builder.js
190
+ * - Backward compat for older native binaries missing js_name annotations
191
+ * - dataflow argFlows/mutations bindingType → binding wrapper
188
192
  */
189
- function normalizeNativeSymbols(result) {
190
- return {
191
- _lineCount: result.lineCount ?? result.line_count ?? null,
192
- definitions: (result.definitions || []).map((d) => ({
193
- name: d.name,
194
- kind: d.kind,
195
- line: d.line,
196
- endLine: d.endLine ?? d.end_line ?? null,
197
- decorators: d.decorators,
198
- complexity: d.complexity
199
- ? {
200
- cognitive: d.complexity.cognitive,
201
- cyclomatic: d.complexity.cyclomatic,
202
- maxNesting: d.complexity.maxNesting,
203
- halstead: d.complexity.halstead ?? null,
204
- loc: d.complexity.loc ?? null,
205
- maintainabilityIndex: d.complexity.maintainabilityIndex ?? null,
206
- }
207
- : null,
208
- cfg: d.cfg?.blocks?.length
209
- ? {
210
- blocks: d.cfg.blocks.map((b) => ({
211
- index: b.index,
212
- type: b.type,
213
- startLine: b.startLine,
214
- endLine: b.endLine,
215
- label: b.label ?? null,
216
- })),
217
- edges: d.cfg.edges.map((e) => ({
218
- sourceIndex: e.sourceIndex,
219
- targetIndex: e.targetIndex,
220
- kind: e.kind,
221
- })),
222
- }
223
- : null,
224
- children: d.children?.length
225
- ? d.children.map((c) => ({
226
- name: c.name,
227
- kind: c.kind,
228
- line: c.line,
229
- endLine: c.endLine ?? c.end_line ?? null,
230
- }))
231
- : undefined,
232
- })),
233
- calls: (result.calls || []).map((c) => ({
234
- name: c.name,
235
- line: c.line,
236
- dynamic: c.dynamic,
237
- receiver: c.receiver,
238
- })),
239
- imports: (result.imports || []).map((i) => ({
240
- source: i.source,
241
- names: i.names || [],
242
- line: i.line,
243
- typeOnly: i.typeOnly ?? i.type_only,
244
- reexport: i.reexport,
245
- wildcardReexport: i.wildcardReexport ?? i.wildcard_reexport,
246
- pythonImport: i.pythonImport ?? i.python_import,
247
- goImport: i.goImport ?? i.go_import,
248
- rustUse: i.rustUse ?? i.rust_use,
249
- javaImport: i.javaImport ?? i.java_import,
250
- csharpUsing: i.csharpUsing ?? i.csharp_using,
251
- rubyRequire: i.rubyRequire ?? i.ruby_require,
252
- phpUse: i.phpUse ?? i.php_use,
253
- })),
254
- classes: (result.classes || []).map((c) => ({
255
- name: c.name,
256
- extends: c.extends,
257
- implements: c.implements,
258
- line: c.line,
259
- })),
260
- exports: (result.exports || []).map((e) => ({
261
- name: e.name,
262
- kind: e.kind,
263
- line: e.line,
264
- })),
265
- astNodes: (result.astNodes ?? result.ast_nodes ?? []).map((n) => ({
266
- kind: n.kind,
267
- name: n.name,
268
- line: n.line,
269
- text: n.text ?? null,
270
- receiver: n.receiver ?? null,
271
- })),
272
- dataflow: result.dataflow
273
- ? {
274
- parameters: (result.dataflow.parameters || []).map((p) => ({
275
- funcName: p.funcName,
276
- paramName: p.paramName,
277
- paramIndex: p.paramIndex,
278
- line: p.line,
279
- })),
280
- returns: (result.dataflow.returns || []).map((r) => ({
281
- funcName: r.funcName,
282
- expression: r.expression ?? '',
283
- referencedNames: r.referencedNames ?? [],
284
- line: r.line,
285
- })),
286
- assignments: (result.dataflow.assignments || []).map((a) => ({
287
- varName: a.varName,
288
- callerFunc: a.callerFunc ?? null,
289
- sourceCallName: a.sourceCallName,
290
- expression: a.expression ?? '',
291
- line: a.line,
292
- })),
293
- argFlows: (result.dataflow.argFlows ?? []).map((f) => ({
294
- callerFunc: f.callerFunc ?? null,
295
- calleeName: f.calleeName,
296
- argIndex: f.argIndex,
297
- argName: f.argName ?? null,
298
- binding: f.bindingType ? { type: f.bindingType } : null,
299
- confidence: f.confidence,
300
- expression: f.expression ?? '',
301
- line: f.line,
302
- })),
303
- mutations: (result.dataflow.mutations || []).map((m) => ({
304
- funcName: m.funcName ?? null,
305
- receiverName: m.receiverName,
306
- binding: m.bindingType ? { type: m.bindingType } : null,
307
- mutatingExpr: m.mutatingExpr,
308
- line: m.line,
309
- })),
310
- }
311
- : null,
312
- };
193
+ function patchNativeResult(r) {
194
+ // lineCount: napi(js_name) emits "lineCount"; older binaries may emit "line_count"
195
+ r.lineCount = r.lineCount ?? r.line_count ?? null;
196
+ r._lineCount = r.lineCount;
197
+
198
+ // Backward compat for older binaries missing js_name annotations
199
+ if (r.definitions) {
200
+ for (const d of r.definitions) {
201
+ if (d.endLine === undefined && d.end_line !== undefined) {
202
+ d.endLine = d.end_line;
203
+ }
204
+ }
205
+ }
206
+ if (r.imports) {
207
+ for (const i of r.imports) {
208
+ if (i.typeOnly === undefined) i.typeOnly = i.type_only;
209
+ if (i.wildcardReexport === undefined) i.wildcardReexport = i.wildcard_reexport;
210
+ if (i.pythonImport === undefined) i.pythonImport = i.python_import;
211
+ if (i.goImport === undefined) i.goImport = i.go_import;
212
+ if (i.rustUse === undefined) i.rustUse = i.rust_use;
213
+ if (i.javaImport === undefined) i.javaImport = i.java_import;
214
+ if (i.csharpUsing === undefined) i.csharpUsing = i.csharp_using;
215
+ if (i.rubyRequire === undefined) i.rubyRequire = i.ruby_require;
216
+ if (i.phpUse === undefined) i.phpUse = i.php_use;
217
+ }
218
+ }
219
+
220
+ // dataflow: wrap bindingType into binding object for argFlows and mutations
221
+ if (r.dataflow) {
222
+ if (r.dataflow.argFlows) {
223
+ for (const f of r.dataflow.argFlows) {
224
+ f.binding = f.bindingType ? { type: f.bindingType } : null;
225
+ }
226
+ }
227
+ if (r.dataflow.mutations) {
228
+ for (const m of r.dataflow.mutations) {
229
+ m.binding = m.bindingType ? { type: m.bindingType } : null;
230
+ }
231
+ }
232
+ }
233
+
234
+ return r;
313
235
  }
314
236
 
315
237
  /**
@@ -440,8 +362,8 @@ export async function parseFileAuto(filePath, source, opts = {}) {
440
362
  const { native } = resolveEngine(opts);
441
363
 
442
364
  if (native) {
443
- const result = native.parseFile(filePath, source, !!opts.dataflow);
444
- return result ? normalizeNativeSymbols(result) : null;
365
+ const result = native.parseFile(filePath, source, !!opts.dataflow, opts.ast !== false);
366
+ return result ? patchNativeResult(result) : null;
445
367
  }
446
368
 
447
369
  // WASM path
@@ -463,11 +385,16 @@ export async function parseFilesAuto(filePaths, rootDir, opts = {}) {
463
385
  const result = new Map();
464
386
 
465
387
  if (native) {
466
- const nativeResults = native.parseFiles(filePaths, rootDir, !!opts.dataflow);
388
+ const nativeResults = native.parseFiles(
389
+ filePaths,
390
+ rootDir,
391
+ !!opts.dataflow,
392
+ opts.ast !== false,
393
+ );
467
394
  for (const r of nativeResults) {
468
395
  if (!r) continue;
469
396
  const relPath = path.relative(rootDir, r.file).split(path.sep).join('/');
470
- result.set(relPath, normalizeNativeSymbols(r));
397
+ result.set(relPath, patchNativeResult(r));
471
398
  }
472
399
  return result;
473
400
  }
@@ -532,7 +459,7 @@ export function createParseTreeCache() {
532
459
  export async function parseFileIncremental(cache, filePath, source, opts = {}) {
533
460
  if (cache) {
534
461
  const result = cache.parseFile(filePath, source);
535
- return result ? normalizeNativeSymbols(result) : null;
462
+ return result ? patchNativeResult(result) : null;
536
463
  }
537
464
  return parseFileAuto(filePath, source, opts);
538
465
  }