@optave/codegraph 3.1.0 → 3.1.1

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.
Files changed (47) hide show
  1. package/README.md +5 -5
  2. package/grammars/tree-sitter-go.wasm +0 -0
  3. package/package.json +8 -9
  4. package/src/ast-analysis/rules/csharp.js +201 -0
  5. package/src/ast-analysis/rules/go.js +182 -0
  6. package/src/ast-analysis/rules/index.js +82 -0
  7. package/src/ast-analysis/rules/java.js +175 -0
  8. package/src/ast-analysis/rules/javascript.js +246 -0
  9. package/src/ast-analysis/rules/php.js +219 -0
  10. package/src/ast-analysis/rules/python.js +196 -0
  11. package/src/ast-analysis/rules/ruby.js +204 -0
  12. package/src/ast-analysis/rules/rust.js +173 -0
  13. package/src/ast-analysis/shared.js +223 -0
  14. package/src/ast.js +15 -28
  15. package/src/audit.js +4 -5
  16. package/src/boundaries.js +1 -1
  17. package/src/branch-compare.js +84 -79
  18. package/src/builder.js +0 -5
  19. package/src/cfg.js +106 -338
  20. package/src/check.js +3 -3
  21. package/src/cli.js +99 -179
  22. package/src/cochange.js +1 -1
  23. package/src/communities.js +13 -16
  24. package/src/complexity.js +196 -1239
  25. package/src/cycles.js +1 -1
  26. package/src/dataflow.js +269 -694
  27. package/src/db/connection.js +88 -0
  28. package/src/db/migrations.js +312 -0
  29. package/src/db/query-builder.js +280 -0
  30. package/src/db/repository.js +134 -0
  31. package/src/db.js +19 -399
  32. package/src/embedder.js +145 -141
  33. package/src/export.js +1 -1
  34. package/src/flow.js +161 -162
  35. package/src/index.js +34 -1
  36. package/src/kinds.js +49 -0
  37. package/src/manifesto.js +3 -8
  38. package/src/mcp.js +37 -20
  39. package/src/owners.js +132 -132
  40. package/src/queries-cli.js +866 -0
  41. package/src/queries.js +1323 -2267
  42. package/src/result-formatter.js +21 -0
  43. package/src/sequence.js +177 -182
  44. package/src/structure.js +200 -199
  45. package/src/test-filter.js +7 -0
  46. package/src/triage.js +120 -162
  47. package/src/viewer.js +1 -1
package/src/queries.js CHANGED
@@ -5,11 +5,23 @@ import { evaluateBoundaries } from './boundaries.js';
5
5
  import { coChangeForFiles } from './cochange.js';
6
6
  import { loadConfig } from './config.js';
7
7
  import { findCycles } from './cycles.js';
8
- import { findDbPath, openReadonlyOrFail } from './db.js';
8
+ import {
9
+ findDbPath,
10
+ findNodesWithFanIn,
11
+ iterateFunctionNodes,
12
+ listFunctionNodes,
13
+ openReadonlyOrFail,
14
+ testFilterSQL,
15
+ } from './db.js';
16
+ import { ALL_SYMBOL_KINDS } from './kinds.js';
9
17
  import { debug } from './logger.js';
10
18
  import { ownersForFiles } from './owners.js';
11
- import { paginateResult, printNdjson } from './paginate.js';
19
+ import { paginateResult } from './paginate.js';
12
20
  import { LANGUAGE_REGISTRY } from './parser.js';
21
+ import { isTestFile } from './test-filter.js';
22
+
23
+ // Re-export from dedicated module for backward compat
24
+ export { isTestFile, TEST_PATTERN } from './test-filter.js';
13
25
 
14
26
  /**
15
27
  * Resolve a file path relative to repoRoot, rejecting traversal outside the repo.
@@ -21,11 +33,6 @@ function safePath(repoRoot, file) {
21
33
  return resolved;
22
34
  }
23
35
 
24
- const TEST_PATTERN = /\.(test|spec)\.|__test__|__tests__|\.stories\./;
25
- export function isTestFile(filePath) {
26
- return TEST_PATTERN.test(filePath);
27
- }
28
-
29
36
  export const FALSE_POSITIVE_NAMES = new Set([
30
37
  'run',
31
38
  'get',
@@ -60,54 +67,17 @@ export const FALSE_POSITIVE_CALLER_THRESHOLD = 20;
60
67
 
61
68
  const FUNCTION_KINDS = ['function', 'method', 'class'];
62
69
 
63
- // Original 10 kinds used as default query scope
64
- export const CORE_SYMBOL_KINDS = [
65
- 'function',
66
- 'method',
67
- 'class',
68
- 'interface',
69
- 'type',
70
- 'struct',
71
- 'enum',
72
- 'trait',
73
- 'record',
74
- 'module',
75
- ];
76
-
77
- // Sub-declaration kinds (Phase 1)
78
- export const EXTENDED_SYMBOL_KINDS = [
79
- 'parameter',
80
- 'property',
81
- 'constant',
82
- // Phase 2 (reserved, not yet extracted):
83
- // 'constructor', 'namespace', 'decorator', 'getter', 'setter',
84
- ];
85
-
86
- // Full set for --kind validation and MCP enum
87
- export const EVERY_SYMBOL_KIND = [...CORE_SYMBOL_KINDS, ...EXTENDED_SYMBOL_KINDS];
88
-
89
- // Backward compat: ALL_SYMBOL_KINDS stays as the core 10
90
- export const ALL_SYMBOL_KINDS = CORE_SYMBOL_KINDS;
91
-
92
- // ── Edge kind constants ─────────────────────────────────────────────
93
- // Core edge kinds — coupling and dependency relationships
94
- export const CORE_EDGE_KINDS = [
95
- 'imports',
96
- 'imports-type',
97
- 'reexports',
98
- 'calls',
99
- 'extends',
100
- 'implements',
101
- 'contains',
102
- ];
103
-
104
- // Structural edge kinds — parent/child and type relationships
105
- export const STRUCTURAL_EDGE_KINDS = ['parameter_of', 'receiver'];
106
-
107
- // Full set for MCP enum and validation
108
- export const EVERY_EDGE_KIND = [...CORE_EDGE_KINDS, ...STRUCTURAL_EDGE_KINDS];
109
-
110
- export const VALID_ROLES = ['entry', 'core', 'utility', 'adapter', 'dead', 'leaf'];
70
+ // Re-export kind/edge constants from kinds.js (canonical source)
71
+ export {
72
+ ALL_SYMBOL_KINDS,
73
+ CORE_EDGE_KINDS,
74
+ CORE_SYMBOL_KINDS,
75
+ EVERY_EDGE_KIND,
76
+ EVERY_SYMBOL_KIND,
77
+ EXTENDED_SYMBOL_KINDS,
78
+ STRUCTURAL_EDGE_KINDS,
79
+ VALID_ROLES,
80
+ } from './kinds.js';
111
81
 
112
82
  /**
113
83
  * Get all ancestor class names for a given class using extends edges.
@@ -164,26 +134,9 @@ function resolveMethodViaHierarchy(db, methodName) {
164
134
  * Scoring: exact=100, prefix=60, word-boundary=40, substring=10, plus fan-in tiebreaker.
165
135
  */
166
136
  export function findMatchingNodes(db, name, opts = {}) {
167
- const kinds = opts.kind ? [opts.kind] : FUNCTION_KINDS;
168
- const placeholders = kinds.map(() => '?').join(', ');
169
- const params = [`%${name}%`, ...kinds];
170
-
171
- let fileCondition = '';
172
- if (opts.file) {
173
- fileCondition = ' AND n.file LIKE ?';
174
- params.push(`%${opts.file}%`);
175
- }
137
+ const kinds = opts.kind ? [opts.kind] : opts.kinds?.length ? opts.kinds : FUNCTION_KINDS;
176
138
 
177
- const rows = db
178
- .prepare(`
179
- SELECT n.*, COALESCE(fi.cnt, 0) AS fan_in
180
- FROM nodes n
181
- LEFT JOIN (
182
- SELECT target_id, COUNT(*) AS cnt FROM edges WHERE kind = 'calls' GROUP BY target_id
183
- ) fi ON fi.target_id = n.id
184
- WHERE n.name LIKE ? AND n.kind IN (${placeholders})${fileCondition}
185
- `)
186
- .all(...params);
139
+ const rows = findNodesWithFanIn(db, `%${name}%`, { kinds, file: opts.file });
187
140
 
188
141
  const nodes = opts.noTests ? rows.filter((n) => !isTestFile(n.file)) : rows;
189
142
 
@@ -240,621 +193,570 @@ export function kindIcon(kind) {
240
193
 
241
194
  export function queryNameData(name, customDbPath, opts = {}) {
242
195
  const db = openReadonlyOrFail(customDbPath);
243
- const noTests = opts.noTests || false;
244
- let nodes = db.prepare(`SELECT * FROM nodes WHERE name LIKE ?`).all(`%${name}%`);
245
- if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
246
- if (nodes.length === 0) {
247
- db.close();
248
- return { query: name, results: [] };
249
- }
250
-
251
- const hc = new Map();
252
- const results = nodes.map((node) => {
253
- let callees = db
254
- .prepare(`
255
- SELECT n.name, n.kind, n.file, n.line, e.kind as edge_kind
256
- FROM edges e JOIN nodes n ON e.target_id = n.id
257
- WHERE e.source_id = ?
258
- `)
259
- .all(node.id);
196
+ try {
197
+ const noTests = opts.noTests || false;
198
+ let nodes = db.prepare(`SELECT * FROM nodes WHERE name LIKE ?`).all(`%${name}%`);
199
+ if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
200
+ if (nodes.length === 0) {
201
+ return { query: name, results: [] };
202
+ }
203
+
204
+ const hc = new Map();
205
+ const results = nodes.map((node) => {
206
+ let callees = db
207
+ .prepare(`
208
+ SELECT n.name, n.kind, n.file, n.line, e.kind as edge_kind
209
+ FROM edges e JOIN nodes n ON e.target_id = n.id
210
+ WHERE e.source_id = ?
211
+ `)
212
+ .all(node.id);
260
213
 
261
- let callers = db
262
- .prepare(`
263
- SELECT n.name, n.kind, n.file, n.line, e.kind as edge_kind
264
- FROM edges e JOIN nodes n ON e.source_id = n.id
265
- WHERE e.target_id = ?
266
- `)
267
- .all(node.id);
214
+ let callers = db
215
+ .prepare(`
216
+ SELECT n.name, n.kind, n.file, n.line, e.kind as edge_kind
217
+ FROM edges e JOIN nodes n ON e.source_id = n.id
218
+ WHERE e.target_id = ?
219
+ `)
220
+ .all(node.id);
268
221
 
269
- if (noTests) {
270
- callees = callees.filter((c) => !isTestFile(c.file));
271
- callers = callers.filter((c) => !isTestFile(c.file));
272
- }
222
+ if (noTests) {
223
+ callees = callees.filter((c) => !isTestFile(c.file));
224
+ callers = callers.filter((c) => !isTestFile(c.file));
225
+ }
273
226
 
274
- return {
275
- ...normalizeSymbol(node, db, hc),
276
- callees: callees.map((c) => ({
277
- name: c.name,
278
- kind: c.kind,
279
- file: c.file,
280
- line: c.line,
281
- edgeKind: c.edge_kind,
282
- })),
283
- callers: callers.map((c) => ({
284
- name: c.name,
285
- kind: c.kind,
286
- file: c.file,
287
- line: c.line,
288
- edgeKind: c.edge_kind,
289
- })),
290
- };
291
- });
227
+ return {
228
+ ...normalizeSymbol(node, db, hc),
229
+ callees: callees.map((c) => ({
230
+ name: c.name,
231
+ kind: c.kind,
232
+ file: c.file,
233
+ line: c.line,
234
+ edgeKind: c.edge_kind,
235
+ })),
236
+ callers: callers.map((c) => ({
237
+ name: c.name,
238
+ kind: c.kind,
239
+ file: c.file,
240
+ line: c.line,
241
+ edgeKind: c.edge_kind,
242
+ })),
243
+ };
244
+ });
292
245
 
293
- db.close();
294
- const base = { query: name, results };
295
- return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
246
+ const base = { query: name, results };
247
+ return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
248
+ } finally {
249
+ db.close();
250
+ }
296
251
  }
297
252
 
298
253
  export function impactAnalysisData(file, customDbPath, opts = {}) {
299
254
  const db = openReadonlyOrFail(customDbPath);
300
- const noTests = opts.noTests || false;
301
- const fileNodes = db
302
- .prepare(`SELECT * FROM nodes WHERE file LIKE ? AND kind = 'file'`)
303
- .all(`%${file}%`);
304
- if (fileNodes.length === 0) {
305
- db.close();
306
- return { file, sources: [], levels: {}, totalDependents: 0 };
307
- }
308
-
309
- const visited = new Set();
310
- const queue = [];
311
- const levels = new Map();
312
-
313
- for (const fn of fileNodes) {
314
- visited.add(fn.id);
315
- queue.push(fn.id);
316
- levels.set(fn.id, 0);
317
- }
318
-
319
- while (queue.length > 0) {
320
- const current = queue.shift();
321
- const level = levels.get(current);
322
- const dependents = db
323
- .prepare(`
324
- SELECT n.* FROM edges e JOIN nodes n ON e.source_id = n.id
325
- WHERE e.target_id = ? AND e.kind IN ('imports', 'imports-type')
326
- `)
327
- .all(current);
328
- for (const dep of dependents) {
329
- if (!visited.has(dep.id) && (!noTests || !isTestFile(dep.file))) {
330
- visited.add(dep.id);
331
- queue.push(dep.id);
332
- levels.set(dep.id, level + 1);
255
+ try {
256
+ const noTests = opts.noTests || false;
257
+ const fileNodes = db
258
+ .prepare(`SELECT * FROM nodes WHERE file LIKE ? AND kind = 'file'`)
259
+ .all(`%${file}%`);
260
+ if (fileNodes.length === 0) {
261
+ return { file, sources: [], levels: {}, totalDependents: 0 };
262
+ }
263
+
264
+ const visited = new Set();
265
+ const queue = [];
266
+ const levels = new Map();
267
+
268
+ for (const fn of fileNodes) {
269
+ visited.add(fn.id);
270
+ queue.push(fn.id);
271
+ levels.set(fn.id, 0);
272
+ }
273
+
274
+ while (queue.length > 0) {
275
+ const current = queue.shift();
276
+ const level = levels.get(current);
277
+ const dependents = db
278
+ .prepare(`
279
+ SELECT n.* FROM edges e JOIN nodes n ON e.source_id = n.id
280
+ WHERE e.target_id = ? AND e.kind IN ('imports', 'imports-type')
281
+ `)
282
+ .all(current);
283
+ for (const dep of dependents) {
284
+ if (!visited.has(dep.id) && (!noTests || !isTestFile(dep.file))) {
285
+ visited.add(dep.id);
286
+ queue.push(dep.id);
287
+ levels.set(dep.id, level + 1);
288
+ }
333
289
  }
334
290
  }
335
- }
336
291
 
337
- const byLevel = {};
338
- for (const [id, level] of levels) {
339
- if (level === 0) continue;
340
- if (!byLevel[level]) byLevel[level] = [];
341
- const node = db.prepare('SELECT * FROM nodes WHERE id = ?').get(id);
342
- if (node) byLevel[level].push({ file: node.file });
343
- }
292
+ const byLevel = {};
293
+ for (const [id, level] of levels) {
294
+ if (level === 0) continue;
295
+ if (!byLevel[level]) byLevel[level] = [];
296
+ const node = db.prepare('SELECT * FROM nodes WHERE id = ?').get(id);
297
+ if (node) byLevel[level].push({ file: node.file });
298
+ }
344
299
 
345
- db.close();
346
- return {
347
- file,
348
- sources: fileNodes.map((f) => f.file),
349
- levels: byLevel,
350
- totalDependents: visited.size - fileNodes.length,
351
- };
300
+ return {
301
+ file,
302
+ sources: fileNodes.map((f) => f.file),
303
+ levels: byLevel,
304
+ totalDependents: visited.size - fileNodes.length,
305
+ };
306
+ } finally {
307
+ db.close();
308
+ }
352
309
  }
353
310
 
354
311
  export function moduleMapData(customDbPath, limit = 20, opts = {}) {
355
312
  const db = openReadonlyOrFail(customDbPath);
356
- const noTests = opts.noTests || false;
357
-
358
- const testFilter = noTests
359
- ? `AND n.file NOT LIKE '%.test.%'
360
- AND n.file NOT LIKE '%.spec.%'
361
- AND n.file NOT LIKE '%__test__%'
362
- AND n.file NOT LIKE '%__tests__%'
363
- AND n.file NOT LIKE '%.stories.%'`
364
- : '';
365
-
366
- const nodes = db
367
- .prepare(`
368
- SELECT n.*,
369
- (SELECT COUNT(*) FROM edges WHERE source_id = n.id AND kind NOT IN ('contains', 'parameter_of', 'receiver')) as out_edges,
370
- (SELECT COUNT(*) FROM edges WHERE target_id = n.id AND kind NOT IN ('contains', 'parameter_of', 'receiver')) as in_edges
371
- FROM nodes n
372
- WHERE n.kind = 'file'
373
- ${testFilter}
374
- ORDER BY (SELECT COUNT(*) FROM edges WHERE target_id = n.id AND kind NOT IN ('contains', 'parameter_of', 'receiver')) DESC
375
- LIMIT ?
376
- `)
377
- .all(limit);
378
-
379
- const topNodes = nodes.map((n) => ({
380
- file: n.file,
381
- dir: path.dirname(n.file) || '.',
382
- inEdges: n.in_edges,
383
- outEdges: n.out_edges,
384
- coupling: n.in_edges + n.out_edges,
385
- }));
386
-
387
- const totalNodes = db.prepare('SELECT COUNT(*) as c FROM nodes').get().c;
388
- const totalEdges = db.prepare('SELECT COUNT(*) as c FROM edges').get().c;
389
- const totalFiles = db.prepare("SELECT COUNT(*) as c FROM nodes WHERE kind = 'file'").get().c;
390
-
391
- db.close();
392
- return { limit, topNodes, stats: { totalFiles, totalNodes, totalEdges } };
393
- }
394
-
395
- export function fileDepsData(file, customDbPath, opts = {}) {
396
- const db = openReadonlyOrFail(customDbPath);
397
- const noTests = opts.noTests || false;
398
- const fileNodes = db
399
- .prepare(`SELECT * FROM nodes WHERE file LIKE ? AND kind = 'file'`)
400
- .all(`%${file}%`);
401
- if (fileNodes.length === 0) {
402
- db.close();
403
- return { file, results: [] };
404
- }
313
+ try {
314
+ const noTests = opts.noTests || false;
405
315
 
406
- const results = fileNodes.map((fn) => {
407
- let importsTo = db
408
- .prepare(`
409
- SELECT n.file, e.kind as edge_kind FROM edges e JOIN nodes n ON e.target_id = n.id
410
- WHERE e.source_id = ? AND e.kind IN ('imports', 'imports-type')
411
- `)
412
- .all(fn.id);
413
- if (noTests) importsTo = importsTo.filter((i) => !isTestFile(i.file));
316
+ const testFilter = testFilterSQL('n.file', noTests);
414
317
 
415
- let importedBy = db
318
+ const nodes = db
416
319
  .prepare(`
417
- SELECT n.file, e.kind as edge_kind FROM edges e JOIN nodes n ON e.source_id = n.id
418
- WHERE e.target_id = ? AND e.kind IN ('imports', 'imports-type')
320
+ SELECT n.*,
321
+ (SELECT COUNT(*) FROM edges WHERE source_id = n.id AND kind NOT IN ('contains', 'parameter_of', 'receiver')) as out_edges,
322
+ (SELECT COUNT(*) FROM edges WHERE target_id = n.id AND kind NOT IN ('contains', 'parameter_of', 'receiver')) as in_edges
323
+ FROM nodes n
324
+ WHERE n.kind = 'file'
325
+ ${testFilter}
326
+ ORDER BY (SELECT COUNT(*) FROM edges WHERE target_id = n.id AND kind NOT IN ('contains', 'parameter_of', 'receiver')) DESC
327
+ LIMIT ?
419
328
  `)
420
- .all(fn.id);
421
- if (noTests) importedBy = importedBy.filter((i) => !isTestFile(i.file));
422
-
423
- const defs = db
424
- .prepare(`SELECT * FROM nodes WHERE file = ? AND kind != 'file' ORDER BY line`)
425
- .all(fn.file);
329
+ .all(limit);
330
+
331
+ const topNodes = nodes.map((n) => ({
332
+ file: n.file,
333
+ dir: path.dirname(n.file) || '.',
334
+ inEdges: n.in_edges,
335
+ outEdges: n.out_edges,
336
+ coupling: n.in_edges + n.out_edges,
337
+ }));
426
338
 
427
- return {
428
- file: fn.file,
429
- imports: importsTo.map((i) => ({ file: i.file, typeOnly: i.edge_kind === 'imports-type' })),
430
- importedBy: importedBy.map((i) => ({ file: i.file })),
431
- definitions: defs.map((d) => ({ name: d.name, kind: d.kind, line: d.line })),
432
- };
433
- });
339
+ const totalNodes = db.prepare('SELECT COUNT(*) as c FROM nodes').get().c;
340
+ const totalEdges = db.prepare('SELECT COUNT(*) as c FROM edges').get().c;
341
+ const totalFiles = db.prepare("SELECT COUNT(*) as c FROM nodes WHERE kind = 'file'").get().c;
434
342
 
435
- db.close();
436
- const base = { file, results };
437
- return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
343
+ return { limit, topNodes, stats: { totalFiles, totalNodes, totalEdges } };
344
+ } finally {
345
+ db.close();
346
+ }
438
347
  }
439
348
 
440
- export function fnDepsData(name, customDbPath, opts = {}) {
349
+ export function fileDepsData(file, customDbPath, opts = {}) {
441
350
  const db = openReadonlyOrFail(customDbPath);
442
- const depth = opts.depth || 3;
443
- const noTests = opts.noTests || false;
444
- const hc = new Map();
351
+ try {
352
+ const noTests = opts.noTests || false;
353
+ const fileNodes = db
354
+ .prepare(`SELECT * FROM nodes WHERE file LIKE ? AND kind = 'file'`)
355
+ .all(`%${file}%`);
356
+ if (fileNodes.length === 0) {
357
+ return { file, results: [] };
358
+ }
359
+
360
+ const results = fileNodes.map((fn) => {
361
+ let importsTo = db
362
+ .prepare(`
363
+ SELECT n.file, e.kind as edge_kind FROM edges e JOIN nodes n ON e.target_id = n.id
364
+ WHERE e.source_id = ? AND e.kind IN ('imports', 'imports-type')
365
+ `)
366
+ .all(fn.id);
367
+ if (noTests) importsTo = importsTo.filter((i) => !isTestFile(i.file));
368
+
369
+ let importedBy = db
370
+ .prepare(`
371
+ SELECT n.file, e.kind as edge_kind FROM edges e JOIN nodes n ON e.source_id = n.id
372
+ WHERE e.target_id = ? AND e.kind IN ('imports', 'imports-type')
373
+ `)
374
+ .all(fn.id);
375
+ if (noTests) importedBy = importedBy.filter((i) => !isTestFile(i.file));
376
+
377
+ const defs = db
378
+ .prepare(`SELECT * FROM nodes WHERE file = ? AND kind != 'file' ORDER BY line`)
379
+ .all(fn.file);
445
380
 
446
- const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
447
- if (nodes.length === 0) {
381
+ return {
382
+ file: fn.file,
383
+ imports: importsTo.map((i) => ({ file: i.file, typeOnly: i.edge_kind === 'imports-type' })),
384
+ importedBy: importedBy.map((i) => ({ file: i.file })),
385
+ definitions: defs.map((d) => ({ name: d.name, kind: d.kind, line: d.line })),
386
+ };
387
+ });
388
+
389
+ const base = { file, results };
390
+ return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
391
+ } finally {
448
392
  db.close();
449
- return { name, results: [] };
450
393
  }
394
+ }
451
395
 
452
- const results = nodes.map((node) => {
453
- const callees = db
454
- .prepare(`
455
- SELECT n.name, n.kind, n.file, n.line, e.kind as edge_kind
456
- FROM edges e JOIN nodes n ON e.target_id = n.id
457
- WHERE e.source_id = ? AND e.kind = 'calls'
458
- `)
459
- .all(node.id);
460
- const filteredCallees = noTests ? callees.filter((c) => !isTestFile(c.file)) : callees;
461
-
462
- let callers = db
463
- .prepare(`
464
- SELECT n.name, n.kind, n.file, n.line, e.kind as edge_kind
465
- FROM edges e JOIN nodes n ON e.source_id = n.id
466
- WHERE e.target_id = ? AND e.kind = 'calls'
467
- `)
468
- .all(node.id);
396
+ export function fnDepsData(name, customDbPath, opts = {}) {
397
+ const db = openReadonlyOrFail(customDbPath);
398
+ try {
399
+ const depth = opts.depth || 3;
400
+ const noTests = opts.noTests || false;
401
+ const hc = new Map();
469
402
 
470
- if (node.kind === 'method' && node.name.includes('.')) {
471
- const methodName = node.name.split('.').pop();
472
- const relatedMethods = resolveMethodViaHierarchy(db, methodName);
473
- for (const rm of relatedMethods) {
474
- if (rm.id === node.id) continue;
475
- const extraCallers = db
476
- .prepare(`
477
- SELECT n.name, n.kind, n.file, n.line, e.kind as edge_kind
478
- FROM edges e JOIN nodes n ON e.source_id = n.id
479
- WHERE e.target_id = ? AND e.kind = 'calls'
480
- `)
481
- .all(rm.id);
482
- callers.push(...extraCallers.map((c) => ({ ...c, viaHierarchy: rm.name })));
483
- }
403
+ const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
404
+ if (nodes.length === 0) {
405
+ return { name, results: [] };
484
406
  }
485
- if (noTests) callers = callers.filter((c) => !isTestFile(c.file));
486
407
 
487
- // Transitive callers
488
- const transitiveCallers = {};
489
- if (depth > 1) {
490
- const visited = new Set([node.id]);
491
- let frontier = callers
492
- .map((c) => {
493
- const row = db
494
- .prepare('SELECT id FROM nodes WHERE name = ? AND kind = ? AND file = ? AND line = ?')
495
- .get(c.name, c.kind, c.file, c.line);
496
- return row ? { ...c, id: row.id } : null;
497
- })
498
- .filter(Boolean);
499
-
500
- for (let d = 2; d <= depth; d++) {
501
- const nextFrontier = [];
502
- for (const f of frontier) {
503
- if (visited.has(f.id)) continue;
504
- visited.add(f.id);
505
- const upstream = db
408
+ const results = nodes.map((node) => {
409
+ const callees = db
410
+ .prepare(`
411
+ SELECT n.name, n.kind, n.file, n.line, e.kind as edge_kind
412
+ FROM edges e JOIN nodes n ON e.target_id = n.id
413
+ WHERE e.source_id = ? AND e.kind = 'calls'
414
+ `)
415
+ .all(node.id);
416
+ const filteredCallees = noTests ? callees.filter((c) => !isTestFile(c.file)) : callees;
417
+
418
+ let callers = db
419
+ .prepare(`
420
+ SELECT n.name, n.kind, n.file, n.line, e.kind as edge_kind
421
+ FROM edges e JOIN nodes n ON e.source_id = n.id
422
+ WHERE e.target_id = ? AND e.kind = 'calls'
423
+ `)
424
+ .all(node.id);
425
+
426
+ if (node.kind === 'method' && node.name.includes('.')) {
427
+ const methodName = node.name.split('.').pop();
428
+ const relatedMethods = resolveMethodViaHierarchy(db, methodName);
429
+ for (const rm of relatedMethods) {
430
+ if (rm.id === node.id) continue;
431
+ const extraCallers = db
506
432
  .prepare(`
507
- SELECT n.name, n.kind, n.file, n.line
433
+ SELECT n.name, n.kind, n.file, n.line, e.kind as edge_kind
508
434
  FROM edges e JOIN nodes n ON e.source_id = n.id
509
435
  WHERE e.target_id = ? AND e.kind = 'calls'
510
436
  `)
511
- .all(f.id);
512
- for (const u of upstream) {
513
- if (noTests && isTestFile(u.file)) continue;
514
- const uid = db
437
+ .all(rm.id);
438
+ callers.push(...extraCallers.map((c) => ({ ...c, viaHierarchy: rm.name })));
439
+ }
440
+ }
441
+ if (noTests) callers = callers.filter((c) => !isTestFile(c.file));
442
+
443
+ // Transitive callers
444
+ const transitiveCallers = {};
445
+ if (depth > 1) {
446
+ const visited = new Set([node.id]);
447
+ let frontier = callers
448
+ .map((c) => {
449
+ const row = db
515
450
  .prepare('SELECT id FROM nodes WHERE name = ? AND kind = ? AND file = ? AND line = ?')
516
- .get(u.name, u.kind, u.file, u.line)?.id;
517
- if (uid && !visited.has(uid)) {
518
- nextFrontier.push({ ...u, id: uid });
451
+ .get(c.name, c.kind, c.file, c.line);
452
+ return row ? { ...c, id: row.id } : null;
453
+ })
454
+ .filter(Boolean);
455
+
456
+ for (let d = 2; d <= depth; d++) {
457
+ const nextFrontier = [];
458
+ for (const f of frontier) {
459
+ if (visited.has(f.id)) continue;
460
+ visited.add(f.id);
461
+ const upstream = db
462
+ .prepare(`
463
+ SELECT n.name, n.kind, n.file, n.line
464
+ FROM edges e JOIN nodes n ON e.source_id = n.id
465
+ WHERE e.target_id = ? AND e.kind = 'calls'
466
+ `)
467
+ .all(f.id);
468
+ for (const u of upstream) {
469
+ if (noTests && isTestFile(u.file)) continue;
470
+ const uid = db
471
+ .prepare(
472
+ 'SELECT id FROM nodes WHERE name = ? AND kind = ? AND file = ? AND line = ?',
473
+ )
474
+ .get(u.name, u.kind, u.file, u.line)?.id;
475
+ if (uid && !visited.has(uid)) {
476
+ nextFrontier.push({ ...u, id: uid });
477
+ }
519
478
  }
520
479
  }
480
+ if (nextFrontier.length > 0) {
481
+ transitiveCallers[d] = nextFrontier.map((n) => ({
482
+ name: n.name,
483
+ kind: n.kind,
484
+ file: n.file,
485
+ line: n.line,
486
+ }));
487
+ }
488
+ frontier = nextFrontier;
489
+ if (frontier.length === 0) break;
521
490
  }
522
- if (nextFrontier.length > 0) {
523
- transitiveCallers[d] = nextFrontier.map((n) => ({
524
- name: n.name,
525
- kind: n.kind,
526
- file: n.file,
527
- line: n.line,
528
- }));
529
- }
530
- frontier = nextFrontier;
531
- if (frontier.length === 0) break;
532
491
  }
533
- }
534
492
 
535
- return {
536
- ...normalizeSymbol(node, db, hc),
537
- callees: filteredCallees.map((c) => ({
538
- name: c.name,
539
- kind: c.kind,
540
- file: c.file,
541
- line: c.line,
542
- })),
543
- callers: callers.map((c) => ({
544
- name: c.name,
545
- kind: c.kind,
546
- file: c.file,
547
- line: c.line,
548
- viaHierarchy: c.viaHierarchy || undefined,
549
- })),
550
- transitiveCallers,
551
- };
552
- });
493
+ return {
494
+ ...normalizeSymbol(node, db, hc),
495
+ callees: filteredCallees.map((c) => ({
496
+ name: c.name,
497
+ kind: c.kind,
498
+ file: c.file,
499
+ line: c.line,
500
+ })),
501
+ callers: callers.map((c) => ({
502
+ name: c.name,
503
+ kind: c.kind,
504
+ file: c.file,
505
+ line: c.line,
506
+ viaHierarchy: c.viaHierarchy || undefined,
507
+ })),
508
+ transitiveCallers,
509
+ };
510
+ });
553
511
 
554
- db.close();
555
- const base = { name, results };
556
- return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
512
+ const base = { name, results };
513
+ return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
514
+ } finally {
515
+ db.close();
516
+ }
557
517
  }
558
518
 
559
519
  export function fnImpactData(name, customDbPath, opts = {}) {
560
520
  const db = openReadonlyOrFail(customDbPath);
561
- const maxDepth = opts.depth || 5;
562
- const noTests = opts.noTests || false;
563
- const hc = new Map();
521
+ try {
522
+ const maxDepth = opts.depth || 5;
523
+ const noTests = opts.noTests || false;
524
+ const hc = new Map();
564
525
 
565
- const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
566
- if (nodes.length === 0) {
567
- db.close();
568
- return { name, results: [] };
569
- }
526
+ const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
527
+ if (nodes.length === 0) {
528
+ return { name, results: [] };
529
+ }
530
+
531
+ const results = nodes.map((node) => {
532
+ const visited = new Set([node.id]);
533
+ const levels = {};
534
+ let frontier = [node.id];
570
535
 
571
- const results = nodes.map((node) => {
572
- const visited = new Set([node.id]);
573
- const levels = {};
574
- let frontier = [node.id];
575
-
576
- for (let d = 1; d <= maxDepth; d++) {
577
- const nextFrontier = [];
578
- for (const fid of frontier) {
579
- const callers = db
580
- .prepare(`
581
- SELECT DISTINCT n.id, n.name, n.kind, n.file, n.line
582
- FROM edges e JOIN nodes n ON e.source_id = n.id
583
- WHERE e.target_id = ? AND e.kind = 'calls'
584
- `)
585
- .all(fid);
586
- for (const c of callers) {
587
- if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) {
588
- visited.add(c.id);
589
- nextFrontier.push(c.id);
590
- if (!levels[d]) levels[d] = [];
591
- levels[d].push({ name: c.name, kind: c.kind, file: c.file, line: c.line });
536
+ for (let d = 1; d <= maxDepth; d++) {
537
+ const nextFrontier = [];
538
+ for (const fid of frontier) {
539
+ const callers = db
540
+ .prepare(`
541
+ SELECT DISTINCT n.id, n.name, n.kind, n.file, n.line
542
+ FROM edges e JOIN nodes n ON e.source_id = n.id
543
+ WHERE e.target_id = ? AND e.kind = 'calls'
544
+ `)
545
+ .all(fid);
546
+ for (const c of callers) {
547
+ if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) {
548
+ visited.add(c.id);
549
+ nextFrontier.push(c.id);
550
+ if (!levels[d]) levels[d] = [];
551
+ levels[d].push({ name: c.name, kind: c.kind, file: c.file, line: c.line });
552
+ }
592
553
  }
593
554
  }
555
+ frontier = nextFrontier;
556
+ if (frontier.length === 0) break;
594
557
  }
595
- frontier = nextFrontier;
596
- if (frontier.length === 0) break;
597
- }
598
558
 
599
- return {
600
- ...normalizeSymbol(node, db, hc),
601
- levels,
602
- totalDependents: visited.size - 1,
603
- };
604
- });
559
+ return {
560
+ ...normalizeSymbol(node, db, hc),
561
+ levels,
562
+ totalDependents: visited.size - 1,
563
+ };
564
+ });
605
565
 
606
- db.close();
607
- const base = { name, results };
608
- return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
566
+ const base = { name, results };
567
+ return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
568
+ } finally {
569
+ db.close();
570
+ }
609
571
  }
610
572
 
611
573
  export function pathData(from, to, customDbPath, opts = {}) {
612
574
  const db = openReadonlyOrFail(customDbPath);
613
- const noTests = opts.noTests || false;
614
- const maxDepth = opts.maxDepth || 10;
615
- const edgeKinds = opts.edgeKinds || ['calls'];
616
- const reverse = opts.reverse || false;
617
-
618
- const fromNodes = findMatchingNodes(db, from, {
619
- noTests,
620
- file: opts.fromFile,
621
- kind: opts.kind,
622
- });
623
- if (fromNodes.length === 0) {
624
- db.close();
625
- return {
626
- from,
627
- to,
628
- found: false,
629
- error: `No symbol matching "${from}"`,
630
- fromCandidates: [],
631
- toCandidates: [],
632
- };
633
- }
575
+ try {
576
+ const noTests = opts.noTests || false;
577
+ const maxDepth = opts.maxDepth || 10;
578
+ const edgeKinds = opts.edgeKinds || ['calls'];
579
+ const reverse = opts.reverse || false;
634
580
 
635
- const toNodes = findMatchingNodes(db, to, {
636
- noTests,
637
- file: opts.toFile,
638
- kind: opts.kind,
639
- });
640
- if (toNodes.length === 0) {
641
- db.close();
642
- return {
643
- from,
644
- to,
645
- found: false,
646
- error: `No symbol matching "${to}"`,
647
- fromCandidates: fromNodes
648
- .slice(0, 5)
649
- .map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line })),
650
- toCandidates: [],
651
- };
652
- }
581
+ const fromNodes = findMatchingNodes(db, from, {
582
+ noTests,
583
+ file: opts.fromFile,
584
+ kind: opts.kind,
585
+ });
586
+ if (fromNodes.length === 0) {
587
+ return {
588
+ from,
589
+ to,
590
+ found: false,
591
+ error: `No symbol matching "${from}"`,
592
+ fromCandidates: [],
593
+ toCandidates: [],
594
+ };
595
+ }
653
596
 
654
- const sourceNode = fromNodes[0];
655
- const targetNode = toNodes[0];
597
+ const toNodes = findMatchingNodes(db, to, {
598
+ noTests,
599
+ file: opts.toFile,
600
+ kind: opts.kind,
601
+ });
602
+ if (toNodes.length === 0) {
603
+ return {
604
+ from,
605
+ to,
606
+ found: false,
607
+ error: `No symbol matching "${to}"`,
608
+ fromCandidates: fromNodes
609
+ .slice(0, 5)
610
+ .map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line })),
611
+ toCandidates: [],
612
+ };
613
+ }
656
614
 
657
- const fromCandidates = fromNodes
658
- .slice(0, 5)
659
- .map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line }));
660
- const toCandidates = toNodes
661
- .slice(0, 5)
662
- .map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line }));
615
+ const sourceNode = fromNodes[0];
616
+ const targetNode = toNodes[0];
663
617
 
664
- // Self-path
665
- if (sourceNode.id === targetNode.id) {
666
- db.close();
667
- return {
668
- from,
669
- to,
670
- fromCandidates,
671
- toCandidates,
672
- found: true,
673
- hops: 0,
674
- path: [
675
- {
676
- name: sourceNode.name,
677
- kind: sourceNode.kind,
678
- file: sourceNode.file,
679
- line: sourceNode.line,
680
- edgeKind: null,
681
- },
682
- ],
683
- alternateCount: 0,
684
- edgeKinds,
685
- reverse,
686
- maxDepth,
687
- };
688
- }
618
+ const fromCandidates = fromNodes
619
+ .slice(0, 5)
620
+ .map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line }));
621
+ const toCandidates = toNodes
622
+ .slice(0, 5)
623
+ .map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line }));
624
+
625
+ // Self-path
626
+ if (sourceNode.id === targetNode.id) {
627
+ return {
628
+ from,
629
+ to,
630
+ fromCandidates,
631
+ toCandidates,
632
+ found: true,
633
+ hops: 0,
634
+ path: [
635
+ {
636
+ name: sourceNode.name,
637
+ kind: sourceNode.kind,
638
+ file: sourceNode.file,
639
+ line: sourceNode.line,
640
+ edgeKind: null,
641
+ },
642
+ ],
643
+ alternateCount: 0,
644
+ edgeKinds,
645
+ reverse,
646
+ maxDepth,
647
+ };
648
+ }
689
649
 
690
- // Build edge kind filter
691
- const kindPlaceholders = edgeKinds.map(() => '?').join(', ');
692
-
693
- // BFS — direction depends on `reverse` flag
694
- // Forward: source_id → target_id (A calls... calls B)
695
- // Reverse: target_id → source_id (B is called by... called by A)
696
- const neighborQuery = reverse
697
- ? `SELECT n.id, n.name, n.kind, n.file, n.line, e.kind AS edge_kind
698
- FROM edges e JOIN nodes n ON e.source_id = n.id
699
- WHERE e.target_id = ? AND e.kind IN (${kindPlaceholders})`
700
- : `SELECT n.id, n.name, n.kind, n.file, n.line, e.kind AS edge_kind
701
- FROM edges e JOIN nodes n ON e.target_id = n.id
702
- WHERE e.source_id = ? AND e.kind IN (${kindPlaceholders})`;
703
- const neighborStmt = db.prepare(neighborQuery);
704
-
705
- const visited = new Set([sourceNode.id]);
706
- // parent map: nodeId → { parentId, edgeKind }
707
- const parent = new Map();
708
- let queue = [sourceNode.id];
709
- let found = false;
710
- let alternateCount = 0;
711
- let foundDepth = -1;
712
-
713
- for (let depth = 1; depth <= maxDepth; depth++) {
714
- const nextQueue = [];
715
- for (const currentId of queue) {
716
- const neighbors = neighborStmt.all(currentId, ...edgeKinds);
717
- for (const n of neighbors) {
718
- if (noTests && isTestFile(n.file)) continue;
719
- if (n.id === targetNode.id) {
720
- if (!found) {
721
- found = true;
722
- foundDepth = depth;
650
+ // Build edge kind filter
651
+ const kindPlaceholders = edgeKinds.map(() => '?').join(', ');
652
+
653
+ // BFS — direction depends on `reverse` flag
654
+ // Forward: source_id → target_id (A calls... calls B)
655
+ // Reverse: target_id → source_id (B is called by... called by A)
656
+ const neighborQuery = reverse
657
+ ? `SELECT n.id, n.name, n.kind, n.file, n.line, e.kind AS edge_kind
658
+ FROM edges e JOIN nodes n ON e.source_id = n.id
659
+ WHERE e.target_id = ? AND e.kind IN (${kindPlaceholders})`
660
+ : `SELECT n.id, n.name, n.kind, n.file, n.line, e.kind AS edge_kind
661
+ FROM edges e JOIN nodes n ON e.target_id = n.id
662
+ WHERE e.source_id = ? AND e.kind IN (${kindPlaceholders})`;
663
+ const neighborStmt = db.prepare(neighborQuery);
664
+
665
+ const visited = new Set([sourceNode.id]);
666
+ // parent map: nodeId → { parentId, edgeKind }
667
+ const parent = new Map();
668
+ let queue = [sourceNode.id];
669
+ let found = false;
670
+ let alternateCount = 0;
671
+ let foundDepth = -1;
672
+
673
+ for (let depth = 1; depth <= maxDepth; depth++) {
674
+ const nextQueue = [];
675
+ for (const currentId of queue) {
676
+ const neighbors = neighborStmt.all(currentId, ...edgeKinds);
677
+ for (const n of neighbors) {
678
+ if (noTests && isTestFile(n.file)) continue;
679
+ if (n.id === targetNode.id) {
680
+ if (!found) {
681
+ found = true;
682
+ foundDepth = depth;
683
+ parent.set(n.id, { parentId: currentId, edgeKind: n.edge_kind });
684
+ }
685
+ alternateCount++;
686
+ continue;
687
+ }
688
+ if (!visited.has(n.id)) {
689
+ visited.add(n.id);
723
690
  parent.set(n.id, { parentId: currentId, edgeKind: n.edge_kind });
691
+ nextQueue.push(n.id);
724
692
  }
725
- alternateCount++;
726
- continue;
727
- }
728
- if (!visited.has(n.id)) {
729
- visited.add(n.id);
730
- parent.set(n.id, { parentId: currentId, edgeKind: n.edge_kind });
731
- nextQueue.push(n.id);
732
693
  }
733
694
  }
695
+ if (found) break;
696
+ queue = nextQueue;
697
+ if (queue.length === 0) break;
734
698
  }
735
- if (found) break;
736
- queue = nextQueue;
737
- if (queue.length === 0) break;
738
- }
739
699
 
740
- if (!found) {
741
- db.close();
700
+ if (!found) {
701
+ return {
702
+ from,
703
+ to,
704
+ fromCandidates,
705
+ toCandidates,
706
+ found: false,
707
+ hops: null,
708
+ path: [],
709
+ alternateCount: 0,
710
+ edgeKinds,
711
+ reverse,
712
+ maxDepth,
713
+ };
714
+ }
715
+
716
+ // alternateCount includes the one we kept; subtract 1 for "alternates"
717
+ alternateCount = Math.max(0, alternateCount - 1);
718
+
719
+ // Reconstruct path from target back to source
720
+ const pathIds = [targetNode.id];
721
+ let cur = targetNode.id;
722
+ while (cur !== sourceNode.id) {
723
+ const p = parent.get(cur);
724
+ pathIds.push(p.parentId);
725
+ cur = p.parentId;
726
+ }
727
+ pathIds.reverse();
728
+
729
+ // Build path with node info
730
+ const nodeCache = new Map();
731
+ const getNode = (id) => {
732
+ if (nodeCache.has(id)) return nodeCache.get(id);
733
+ const row = db.prepare('SELECT name, kind, file, line FROM nodes WHERE id = ?').get(id);
734
+ nodeCache.set(id, row);
735
+ return row;
736
+ };
737
+
738
+ const resultPath = pathIds.map((id, idx) => {
739
+ const node = getNode(id);
740
+ const edgeKind = idx === 0 ? null : parent.get(id).edgeKind;
741
+ return { name: node.name, kind: node.kind, file: node.file, line: node.line, edgeKind };
742
+ });
743
+
742
744
  return {
743
745
  from,
744
746
  to,
745
747
  fromCandidates,
746
748
  toCandidates,
747
- found: false,
748
- hops: null,
749
- path: [],
750
- alternateCount: 0,
749
+ found: true,
750
+ hops: foundDepth,
751
+ path: resultPath,
752
+ alternateCount,
751
753
  edgeKinds,
752
754
  reverse,
753
755
  maxDepth,
754
756
  };
757
+ } finally {
758
+ db.close();
755
759
  }
756
-
757
- // alternateCount includes the one we kept; subtract 1 for "alternates"
758
- alternateCount = Math.max(0, alternateCount - 1);
759
-
760
- // Reconstruct path from target back to source
761
- const pathIds = [targetNode.id];
762
- let cur = targetNode.id;
763
- while (cur !== sourceNode.id) {
764
- const p = parent.get(cur);
765
- pathIds.push(p.parentId);
766
- cur = p.parentId;
767
- }
768
- pathIds.reverse();
769
-
770
- // Build path with node info
771
- const nodeCache = new Map();
772
- const getNode = (id) => {
773
- if (nodeCache.has(id)) return nodeCache.get(id);
774
- const row = db.prepare('SELECT name, kind, file, line FROM nodes WHERE id = ?').get(id);
775
- nodeCache.set(id, row);
776
- return row;
777
- };
778
-
779
- const resultPath = pathIds.map((id, idx) => {
780
- const node = getNode(id);
781
- const edgeKind = idx === 0 ? null : parent.get(id).edgeKind;
782
- return { name: node.name, kind: node.kind, file: node.file, line: node.line, edgeKind };
783
- });
784
-
785
- db.close();
786
- return {
787
- from,
788
- to,
789
- fromCandidates,
790
- toCandidates,
791
- found: true,
792
- hops: foundDepth,
793
- path: resultPath,
794
- alternateCount,
795
- edgeKinds,
796
- reverse,
797
- maxDepth,
798
- };
799
- }
800
-
801
- export function symbolPath(from, to, customDbPath, opts = {}) {
802
- const data = pathData(from, to, customDbPath, opts);
803
- if (opts.json) {
804
- console.log(JSON.stringify(data, null, 2));
805
- return;
806
- }
807
-
808
- if (data.error) {
809
- console.log(data.error);
810
- return;
811
- }
812
-
813
- if (!data.found) {
814
- const dir = data.reverse ? 'reverse ' : '';
815
- console.log(`No ${dir}path from "${from}" to "${to}" within ${data.maxDepth} hops.`);
816
- if (data.fromCandidates.length > 1) {
817
- console.log(
818
- `\n "${from}" matched ${data.fromCandidates.length} symbols — using top match: ${data.fromCandidates[0].name} (${data.fromCandidates[0].file}:${data.fromCandidates[0].line})`,
819
- );
820
- }
821
- if (data.toCandidates.length > 1) {
822
- console.log(
823
- ` "${to}" matched ${data.toCandidates.length} symbols — using top match: ${data.toCandidates[0].name} (${data.toCandidates[0].file}:${data.toCandidates[0].line})`,
824
- );
825
- }
826
- return;
827
- }
828
-
829
- if (data.hops === 0) {
830
- console.log(`\n"${from}" and "${to}" resolve to the same symbol (0 hops):`);
831
- const n = data.path[0];
832
- console.log(` ${kindIcon(n.kind)} ${n.name} (${n.kind}) -- ${n.file}:${n.line}\n`);
833
- return;
834
- }
835
-
836
- const dir = data.reverse ? ' (reverse)' : '';
837
- console.log(
838
- `\nPath from ${from} to ${to} (${data.hops} ${data.hops === 1 ? 'hop' : 'hops'})${dir}:\n`,
839
- );
840
- for (let i = 0; i < data.path.length; i++) {
841
- const n = data.path[i];
842
- const indent = ' '.repeat(i + 1);
843
- if (i === 0) {
844
- console.log(`${indent}${kindIcon(n.kind)} ${n.name} (${n.kind}) -- ${n.file}:${n.line}`);
845
- } else {
846
- console.log(
847
- `${indent}--[${n.edgeKind}]--> ${kindIcon(n.kind)} ${n.name} (${n.kind}) -- ${n.file}:${n.line}`,
848
- );
849
- }
850
- }
851
-
852
- if (data.alternateCount > 0) {
853
- console.log(
854
- `\n (${data.alternateCount} alternate shortest ${data.alternateCount === 1 ? 'path' : 'paths'} at same depth)`,
855
- );
856
- }
857
- console.log();
858
760
  }
859
761
 
860
762
  /**
@@ -863,236 +765,235 @@ export function symbolPath(from, to, customDbPath, opts = {}) {
863
765
  */
864
766
  export function diffImpactData(customDbPath, opts = {}) {
865
767
  const db = openReadonlyOrFail(customDbPath);
866
- const noTests = opts.noTests || false;
867
- const maxDepth = opts.depth || 3;
868
-
869
- const dbPath = findDbPath(customDbPath);
870
- const repoRoot = path.resolve(path.dirname(dbPath), '..');
871
-
872
- // Verify we're in a git repository before running git diff
873
- let checkDir = repoRoot;
874
- let isGitRepo = false;
875
- while (checkDir) {
876
- if (fs.existsSync(path.join(checkDir, '.git'))) {
877
- isGitRepo = true;
878
- break;
879
- }
880
- const parent = path.dirname(checkDir);
881
- if (parent === checkDir) break;
882
- checkDir = parent;
883
- }
884
- if (!isGitRepo) {
885
- db.close();
886
- return { error: `Not a git repository: ${repoRoot}` };
887
- }
888
-
889
- let diffOutput;
890
768
  try {
891
- const args = opts.staged
892
- ? ['diff', '--cached', '--unified=0', '--no-color']
893
- : ['diff', opts.ref || 'HEAD', '--unified=0', '--no-color'];
894
- diffOutput = execFileSync('git', args, {
895
- cwd: repoRoot,
896
- encoding: 'utf-8',
897
- maxBuffer: 10 * 1024 * 1024,
898
- stdio: ['pipe', 'pipe', 'pipe'],
899
- });
900
- } catch (e) {
901
- db.close();
902
- return { error: `Failed to run git diff: ${e.message}` };
903
- }
769
+ const noTests = opts.noTests || false;
770
+ const maxDepth = opts.depth || 3;
904
771
 
905
- if (!diffOutput.trim()) {
906
- db.close();
907
- return {
908
- changedFiles: 0,
909
- newFiles: [],
910
- affectedFunctions: [],
911
- affectedFiles: [],
912
- summary: null,
913
- };
914
- }
772
+ const dbPath = findDbPath(customDbPath);
773
+ const repoRoot = path.resolve(path.dirname(dbPath), '..');
915
774
 
916
- const changedRanges = new Map();
917
- const newFiles = new Set();
918
- let currentFile = null;
919
- let prevIsDevNull = false;
920
- for (const line of diffOutput.split('\n')) {
921
- if (line.startsWith('--- /dev/null')) {
922
- prevIsDevNull = true;
923
- continue;
924
- }
925
- if (line.startsWith('--- ')) {
926
- prevIsDevNull = false;
927
- continue;
928
- }
929
- const fileMatch = line.match(/^\+\+\+ b\/(.+)/);
930
- if (fileMatch) {
931
- currentFile = fileMatch[1];
932
- if (!changedRanges.has(currentFile)) changedRanges.set(currentFile, []);
933
- if (prevIsDevNull) newFiles.add(currentFile);
934
- prevIsDevNull = false;
935
- continue;
936
- }
937
- const hunkMatch = line.match(/^@@ .+ \+(\d+)(?:,(\d+))? @@/);
938
- if (hunkMatch && currentFile) {
939
- const start = parseInt(hunkMatch[1], 10);
940
- const count = parseInt(hunkMatch[2] || '1', 10);
941
- changedRanges.get(currentFile).push({ start, end: start + count - 1 });
775
+ // Verify we're in a git repository before running git diff
776
+ let checkDir = repoRoot;
777
+ let isGitRepo = false;
778
+ while (checkDir) {
779
+ if (fs.existsSync(path.join(checkDir, '.git'))) {
780
+ isGitRepo = true;
781
+ break;
782
+ }
783
+ const parent = path.dirname(checkDir);
784
+ if (parent === checkDir) break;
785
+ checkDir = parent;
786
+ }
787
+ if (!isGitRepo) {
788
+ return { error: `Not a git repository: ${repoRoot}` };
942
789
  }
943
- }
944
790
 
945
- if (changedRanges.size === 0) {
946
- db.close();
947
- return {
948
- changedFiles: 0,
949
- newFiles: [],
950
- affectedFunctions: [],
951
- affectedFiles: [],
952
- summary: null,
953
- };
954
- }
791
+ let diffOutput;
792
+ try {
793
+ const args = opts.staged
794
+ ? ['diff', '--cached', '--unified=0', '--no-color']
795
+ : ['diff', opts.ref || 'HEAD', '--unified=0', '--no-color'];
796
+ diffOutput = execFileSync('git', args, {
797
+ cwd: repoRoot,
798
+ encoding: 'utf-8',
799
+ maxBuffer: 10 * 1024 * 1024,
800
+ stdio: ['pipe', 'pipe', 'pipe'],
801
+ });
802
+ } catch (e) {
803
+ return { error: `Failed to run git diff: ${e.message}` };
804
+ }
955
805
 
956
- const affectedFunctions = [];
957
- for (const [file, ranges] of changedRanges) {
958
- if (noTests && isTestFile(file)) continue;
959
- const defs = db
960
- .prepare(
961
- `SELECT * FROM nodes WHERE file = ? AND kind IN ('function', 'method', 'class') ORDER BY line`,
962
- )
963
- .all(file);
964
- for (let i = 0; i < defs.length; i++) {
965
- const def = defs[i];
966
- const endLine = def.end_line || (defs[i + 1] ? defs[i + 1].line - 1 : 999999);
967
- for (const range of ranges) {
968
- if (range.start <= endLine && range.end >= def.line) {
969
- affectedFunctions.push(def);
970
- break;
971
- }
806
+ if (!diffOutput.trim()) {
807
+ return {
808
+ changedFiles: 0,
809
+ newFiles: [],
810
+ affectedFunctions: [],
811
+ affectedFiles: [],
812
+ summary: null,
813
+ };
814
+ }
815
+
816
+ const changedRanges = new Map();
817
+ const newFiles = new Set();
818
+ let currentFile = null;
819
+ let prevIsDevNull = false;
820
+ for (const line of diffOutput.split('\n')) {
821
+ if (line.startsWith('--- /dev/null')) {
822
+ prevIsDevNull = true;
823
+ continue;
824
+ }
825
+ if (line.startsWith('--- ')) {
826
+ prevIsDevNull = false;
827
+ continue;
828
+ }
829
+ const fileMatch = line.match(/^\+\+\+ b\/(.+)/);
830
+ if (fileMatch) {
831
+ currentFile = fileMatch[1];
832
+ if (!changedRanges.has(currentFile)) changedRanges.set(currentFile, []);
833
+ if (prevIsDevNull) newFiles.add(currentFile);
834
+ prevIsDevNull = false;
835
+ continue;
836
+ }
837
+ const hunkMatch = line.match(/^@@ .+ \+(\d+)(?:,(\d+))? @@/);
838
+ if (hunkMatch && currentFile) {
839
+ const start = parseInt(hunkMatch[1], 10);
840
+ const count = parseInt(hunkMatch[2] || '1', 10);
841
+ changedRanges.get(currentFile).push({ start, end: start + count - 1 });
972
842
  }
973
843
  }
974
- }
975
844
 
976
- const allAffected = new Set();
977
- const functionResults = affectedFunctions.map((fn) => {
978
- const visited = new Set([fn.id]);
979
- let frontier = [fn.id];
980
- let totalCallers = 0;
981
- const levels = {};
982
- const edges = [];
983
- const idToKey = new Map();
984
- idToKey.set(fn.id, `${fn.file}::${fn.name}:${fn.line}`);
985
- for (let d = 1; d <= maxDepth; d++) {
986
- const nextFrontier = [];
987
- for (const fid of frontier) {
988
- const callers = db
989
- .prepare(`
990
- SELECT DISTINCT n.id, n.name, n.kind, n.file, n.line
991
- FROM edges e JOIN nodes n ON e.source_id = n.id
992
- WHERE e.target_id = ? AND e.kind = 'calls'
993
- `)
994
- .all(fid);
995
- for (const c of callers) {
996
- if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) {
997
- visited.add(c.id);
998
- nextFrontier.push(c.id);
999
- allAffected.add(`${c.file}:${c.name}`);
1000
- const callerKey = `${c.file}::${c.name}:${c.line}`;
1001
- idToKey.set(c.id, callerKey);
1002
- if (!levels[d]) levels[d] = [];
1003
- levels[d].push({ name: c.name, kind: c.kind, file: c.file, line: c.line });
1004
- edges.push({ from: idToKey.get(fid), to: callerKey });
1005
- totalCallers++;
845
+ if (changedRanges.size === 0) {
846
+ return {
847
+ changedFiles: 0,
848
+ newFiles: [],
849
+ affectedFunctions: [],
850
+ affectedFiles: [],
851
+ summary: null,
852
+ };
853
+ }
854
+
855
+ const affectedFunctions = [];
856
+ for (const [file, ranges] of changedRanges) {
857
+ if (noTests && isTestFile(file)) continue;
858
+ const defs = db
859
+ .prepare(
860
+ `SELECT * FROM nodes WHERE file = ? AND kind IN ('function', 'method', 'class') ORDER BY line`,
861
+ )
862
+ .all(file);
863
+ for (let i = 0; i < defs.length; i++) {
864
+ const def = defs[i];
865
+ const endLine = def.end_line || (defs[i + 1] ? defs[i + 1].line - 1 : 999999);
866
+ for (const range of ranges) {
867
+ if (range.start <= endLine && range.end >= def.line) {
868
+ affectedFunctions.push(def);
869
+ break;
1006
870
  }
1007
871
  }
1008
872
  }
1009
- frontier = nextFrontier;
1010
- if (frontier.length === 0) break;
1011
873
  }
1012
- return {
1013
- name: fn.name,
1014
- kind: fn.kind,
1015
- file: fn.file,
1016
- line: fn.line,
1017
- transitiveCallers: totalCallers,
1018
- levels,
1019
- edges,
1020
- };
1021
- });
1022
874
 
1023
- const affectedFiles = new Set();
1024
- for (const key of allAffected) affectedFiles.add(key.split(':')[0]);
1025
-
1026
- // Look up historically coupled files from co-change data
1027
- let historicallyCoupled = [];
1028
- try {
1029
- db.prepare('SELECT 1 FROM co_changes LIMIT 1').get();
1030
- const changedFilesList = [...changedRanges.keys()];
1031
- const coResults = coChangeForFiles(changedFilesList, db, {
1032
- minJaccard: 0.3,
1033
- limit: 20,
1034
- noTests,
875
+ const allAffected = new Set();
876
+ const functionResults = affectedFunctions.map((fn) => {
877
+ const visited = new Set([fn.id]);
878
+ let frontier = [fn.id];
879
+ let totalCallers = 0;
880
+ const levels = {};
881
+ const edges = [];
882
+ const idToKey = new Map();
883
+ idToKey.set(fn.id, `${fn.file}::${fn.name}:${fn.line}`);
884
+ for (let d = 1; d <= maxDepth; d++) {
885
+ const nextFrontier = [];
886
+ for (const fid of frontier) {
887
+ const callers = db
888
+ .prepare(`
889
+ SELECT DISTINCT n.id, n.name, n.kind, n.file, n.line
890
+ FROM edges e JOIN nodes n ON e.source_id = n.id
891
+ WHERE e.target_id = ? AND e.kind = 'calls'
892
+ `)
893
+ .all(fid);
894
+ for (const c of callers) {
895
+ if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) {
896
+ visited.add(c.id);
897
+ nextFrontier.push(c.id);
898
+ allAffected.add(`${c.file}:${c.name}`);
899
+ const callerKey = `${c.file}::${c.name}:${c.line}`;
900
+ idToKey.set(c.id, callerKey);
901
+ if (!levels[d]) levels[d] = [];
902
+ levels[d].push({ name: c.name, kind: c.kind, file: c.file, line: c.line });
903
+ edges.push({ from: idToKey.get(fid), to: callerKey });
904
+ totalCallers++;
905
+ }
906
+ }
907
+ }
908
+ frontier = nextFrontier;
909
+ if (frontier.length === 0) break;
910
+ }
911
+ return {
912
+ name: fn.name,
913
+ kind: fn.kind,
914
+ file: fn.file,
915
+ line: fn.line,
916
+ transitiveCallers: totalCallers,
917
+ levels,
918
+ edges,
919
+ };
1035
920
  });
1036
- // Exclude files already found via static analysis
1037
- historicallyCoupled = coResults.filter((r) => !affectedFiles.has(r.file));
1038
- } catch {
1039
- /* co_changes table doesn't exist — skip silently */
1040
- }
1041
921
 
1042
- // Look up CODEOWNERS for changed + affected files
1043
- let ownership = null;
1044
- try {
1045
- const allFilePaths = [...new Set([...changedRanges.keys(), ...affectedFiles])];
1046
- const ownerResult = ownersForFiles(allFilePaths, repoRoot);
1047
- if (ownerResult.affectedOwners.length > 0) {
1048
- ownership = {
1049
- owners: Object.fromEntries(ownerResult.owners),
1050
- affectedOwners: ownerResult.affectedOwners,
1051
- suggestedReviewers: ownerResult.suggestedReviewers,
1052
- };
1053
- }
1054
- } catch {
1055
- /* CODEOWNERS missing or unreadable — skip silently */
1056
- }
922
+ const affectedFiles = new Set();
923
+ for (const key of allAffected) affectedFiles.add(key.split(':')[0]);
1057
924
 
1058
- // Check boundary violations scoped to changed files
1059
- let boundaryViolations = [];
1060
- let boundaryViolationCount = 0;
1061
- try {
1062
- const config = loadConfig(repoRoot);
1063
- const boundaryConfig = config.manifesto?.boundaries;
1064
- if (boundaryConfig) {
1065
- const result = evaluateBoundaries(db, boundaryConfig, {
1066
- scopeFiles: [...changedRanges.keys()],
925
+ // Look up historically coupled files from co-change data
926
+ let historicallyCoupled = [];
927
+ try {
928
+ db.prepare('SELECT 1 FROM co_changes LIMIT 1').get();
929
+ const changedFilesList = [...changedRanges.keys()];
930
+ const coResults = coChangeForFiles(changedFilesList, db, {
931
+ minJaccard: 0.3,
932
+ limit: 20,
1067
933
  noTests,
1068
934
  });
1069
- boundaryViolations = result.violations;
1070
- boundaryViolationCount = result.violationCount;
935
+ // Exclude files already found via static analysis
936
+ historicallyCoupled = coResults.filter((r) => !affectedFiles.has(r.file));
937
+ } catch {
938
+ /* co_changes table doesn't exist — skip silently */
1071
939
  }
1072
- } catch {
1073
- /* boundary check failed — skip silently */
1074
- }
1075
940
 
1076
- db.close();
1077
- const base = {
1078
- changedFiles: changedRanges.size,
1079
- newFiles: [...newFiles],
1080
- affectedFunctions: functionResults,
1081
- affectedFiles: [...affectedFiles],
1082
- historicallyCoupled,
1083
- ownership,
1084
- boundaryViolations,
1085
- boundaryViolationCount,
1086
- summary: {
1087
- functionsChanged: affectedFunctions.length,
1088
- callersAffected: allAffected.size,
1089
- filesAffected: affectedFiles.size,
1090
- historicallyCoupledCount: historicallyCoupled.length,
1091
- ownersAffected: ownership ? ownership.affectedOwners.length : 0,
941
+ // Look up CODEOWNERS for changed + affected files
942
+ let ownership = null;
943
+ try {
944
+ const allFilePaths = [...new Set([...changedRanges.keys(), ...affectedFiles])];
945
+ const ownerResult = ownersForFiles(allFilePaths, repoRoot);
946
+ if (ownerResult.affectedOwners.length > 0) {
947
+ ownership = {
948
+ owners: Object.fromEntries(ownerResult.owners),
949
+ affectedOwners: ownerResult.affectedOwners,
950
+ suggestedReviewers: ownerResult.suggestedReviewers,
951
+ };
952
+ }
953
+ } catch {
954
+ /* CODEOWNERS missing or unreadable — skip silently */
955
+ }
956
+
957
+ // Check boundary violations scoped to changed files
958
+ let boundaryViolations = [];
959
+ let boundaryViolationCount = 0;
960
+ try {
961
+ const config = loadConfig(repoRoot);
962
+ const boundaryConfig = config.manifesto?.boundaries;
963
+ if (boundaryConfig) {
964
+ const result = evaluateBoundaries(db, boundaryConfig, {
965
+ scopeFiles: [...changedRanges.keys()],
966
+ noTests,
967
+ });
968
+ boundaryViolations = result.violations;
969
+ boundaryViolationCount = result.violationCount;
970
+ }
971
+ } catch {
972
+ /* boundary check failed — skip silently */
973
+ }
974
+
975
+ const base = {
976
+ changedFiles: changedRanges.size,
977
+ newFiles: [...newFiles],
978
+ affectedFunctions: functionResults,
979
+ affectedFiles: [...affectedFiles],
980
+ historicallyCoupled,
981
+ ownership,
982
+ boundaryViolations,
1092
983
  boundaryViolationCount,
1093
- },
1094
- };
1095
- return paginateResult(base, 'affectedFunctions', { limit: opts.limit, offset: opts.offset });
984
+ summary: {
985
+ functionsChanged: affectedFunctions.length,
986
+ callersAffected: allAffected.size,
987
+ filesAffected: affectedFiles.size,
988
+ historicallyCoupledCount: historicallyCoupled.length,
989
+ ownersAffected: ownership ? ownership.affectedOwners.length : 0,
990
+ boundaryViolationCount,
991
+ },
992
+ };
993
+ return paginateResult(base, 'affectedFunctions', { limit: opts.limit, offset: opts.offset });
994
+ } finally {
995
+ db.close();
996
+ }
1096
997
  }
1097
998
 
1098
999
  export function diffImpactMermaid(customDbPath, opts = {}) {
@@ -1211,35 +1112,20 @@ export function diffImpactMermaid(customDbPath, opts = {}) {
1211
1112
 
1212
1113
  export function listFunctionsData(customDbPath, opts = {}) {
1213
1114
  const db = openReadonlyOrFail(customDbPath);
1214
- const noTests = opts.noTests || false;
1215
- const kinds = ['function', 'method', 'class'];
1216
- const placeholders = kinds.map(() => '?').join(', ');
1217
-
1218
- const conditions = [`kind IN (${placeholders})`];
1219
- const params = [...kinds];
1220
-
1221
- if (opts.file) {
1222
- conditions.push('file LIKE ?');
1223
- params.push(`%${opts.file}%`);
1224
- }
1225
- if (opts.pattern) {
1226
- conditions.push('name LIKE ?');
1227
- params.push(`%${opts.pattern}%`);
1228
- }
1115
+ try {
1116
+ const noTests = opts.noTests || false;
1229
1117
 
1230
- let rows = db
1231
- .prepare(
1232
- `SELECT name, kind, file, line, end_line, role FROM nodes WHERE ${conditions.join(' AND ')} ORDER BY file, line`,
1233
- )
1234
- .all(...params);
1118
+ let rows = listFunctionNodes(db, { file: opts.file, pattern: opts.pattern });
1235
1119
 
1236
- if (noTests) rows = rows.filter((r) => !isTestFile(r.file));
1120
+ if (noTests) rows = rows.filter((r) => !isTestFile(r.file));
1237
1121
 
1238
- const hc = new Map();
1239
- const functions = rows.map((r) => normalizeSymbol(r, db, hc));
1240
- db.close();
1241
- const base = { count: functions.length, functions };
1242
- return paginateResult(base, 'functions', { limit: opts.limit, offset: opts.offset });
1122
+ const hc = new Map();
1123
+ const functions = rows.map((r) => normalizeSymbol(r, db, hc));
1124
+ const base = { count: functions.length, functions };
1125
+ return paginateResult(base, 'functions', { limit: opts.limit, offset: opts.offset });
1126
+ } finally {
1127
+ db.close();
1128
+ }
1243
1129
  }
1244
1130
 
1245
1131
  /**
@@ -1253,27 +1139,10 @@ export function listFunctionsData(customDbPath, opts = {}) {
1253
1139
  */
1254
1140
  export function* iterListFunctions(customDbPath, opts = {}) {
1255
1141
  const db = openReadonlyOrFail(customDbPath);
1256
- try {
1257
- const noTests = opts.noTests || false;
1258
- const kinds = ['function', 'method', 'class'];
1259
- const placeholders = kinds.map(() => '?').join(', ');
1260
-
1261
- const conditions = [`kind IN (${placeholders})`];
1262
- const params = [...kinds];
1263
-
1264
- if (opts.file) {
1265
- conditions.push('file LIKE ?');
1266
- params.push(`%${opts.file}%`);
1267
- }
1268
- if (opts.pattern) {
1269
- conditions.push('name LIKE ?');
1270
- params.push(`%${opts.pattern}%`);
1271
- }
1142
+ try {
1143
+ const noTests = opts.noTests || false;
1272
1144
 
1273
- const stmt = db.prepare(
1274
- `SELECT name, kind, file, line, end_line, role FROM nodes WHERE ${conditions.join(' AND ')} ORDER BY file, line`,
1275
- );
1276
- for (const row of stmt.iterate(...params)) {
1145
+ for (const row of iterateFunctionNodes(db, { file: opts.file, pattern: opts.pattern })) {
1277
1146
  if (noTests && isTestFile(row.file)) continue;
1278
1147
  yield {
1279
1148
  name: row.name,
@@ -1383,569 +1252,247 @@ export function* iterWhere(target, customDbPath, opts = {}) {
1383
1252
 
1384
1253
  export function statsData(customDbPath, opts = {}) {
1385
1254
  const db = openReadonlyOrFail(customDbPath);
1386
- const noTests = opts.noTests || false;
1387
-
1388
- // Build set of test file IDs for filtering nodes and edges
1389
- let testFileIds = null;
1390
- if (noTests) {
1391
- const allFileNodes = db.prepare("SELECT id, file FROM nodes WHERE kind = 'file'").all();
1392
- testFileIds = new Set();
1393
- const testFiles = new Set();
1394
- for (const n of allFileNodes) {
1395
- if (isTestFile(n.file)) {
1396
- testFileIds.add(n.id);
1397
- testFiles.add(n.file);
1255
+ try {
1256
+ const noTests = opts.noTests || false;
1257
+
1258
+ // Build set of test file IDs for filtering nodes and edges
1259
+ let testFileIds = null;
1260
+ if (noTests) {
1261
+ const allFileNodes = db.prepare("SELECT id, file FROM nodes WHERE kind = 'file'").all();
1262
+ testFileIds = new Set();
1263
+ const testFiles = new Set();
1264
+ for (const n of allFileNodes) {
1265
+ if (isTestFile(n.file)) {
1266
+ testFileIds.add(n.id);
1267
+ testFiles.add(n.file);
1268
+ }
1269
+ }
1270
+ // Also collect non-file node IDs that belong to test files
1271
+ const allNodes = db.prepare('SELECT id, file FROM nodes').all();
1272
+ for (const n of allNodes) {
1273
+ if (testFiles.has(n.file)) testFileIds.add(n.id);
1398
1274
  }
1399
1275
  }
1400
- // Also collect non-file node IDs that belong to test files
1401
- const allNodes = db.prepare('SELECT id, file FROM nodes').all();
1402
- for (const n of allNodes) {
1403
- if (testFiles.has(n.file)) testFileIds.add(n.id);
1404
- }
1405
- }
1406
-
1407
- // Node breakdown by kind
1408
- let nodeRows;
1409
- if (noTests) {
1410
- const allNodes = db.prepare('SELECT id, kind, file FROM nodes').all();
1411
- const filtered = allNodes.filter((n) => !testFileIds.has(n.id));
1412
- const counts = {};
1413
- for (const n of filtered) counts[n.kind] = (counts[n.kind] || 0) + 1;
1414
- nodeRows = Object.entries(counts).map(([kind, c]) => ({ kind, c }));
1415
- } else {
1416
- nodeRows = db.prepare('SELECT kind, COUNT(*) as c FROM nodes GROUP BY kind').all();
1417
- }
1418
- const nodesByKind = {};
1419
- let totalNodes = 0;
1420
- for (const r of nodeRows) {
1421
- nodesByKind[r.kind] = r.c;
1422
- totalNodes += r.c;
1423
- }
1424
1276
 
1425
- // Edge breakdown by kind
1426
- let edgeRows;
1427
- if (noTests) {
1428
- const allEdges = db.prepare('SELECT source_id, target_id, kind FROM edges').all();
1429
- const filtered = allEdges.filter(
1430
- (e) => !testFileIds.has(e.source_id) && !testFileIds.has(e.target_id),
1431
- );
1432
- const counts = {};
1433
- for (const e of filtered) counts[e.kind] = (counts[e.kind] || 0) + 1;
1434
- edgeRows = Object.entries(counts).map(([kind, c]) => ({ kind, c }));
1435
- } else {
1436
- edgeRows = db.prepare('SELECT kind, COUNT(*) as c FROM edges GROUP BY kind').all();
1437
- }
1438
- const edgesByKind = {};
1439
- let totalEdges = 0;
1440
- for (const r of edgeRows) {
1441
- edgesByKind[r.kind] = r.c;
1442
- totalEdges += r.c;
1443
- }
1444
-
1445
- // File/language distribution — map extensions via LANGUAGE_REGISTRY
1446
- const extToLang = new Map();
1447
- for (const entry of LANGUAGE_REGISTRY) {
1448
- for (const ext of entry.extensions) {
1449
- extToLang.set(ext, entry.id);
1450
- }
1451
- }
1452
- let fileNodes = db.prepare("SELECT file FROM nodes WHERE kind = 'file'").all();
1453
- if (noTests) fileNodes = fileNodes.filter((n) => !isTestFile(n.file));
1454
- const byLanguage = {};
1455
- for (const row of fileNodes) {
1456
- const ext = path.extname(row.file).toLowerCase();
1457
- const lang = extToLang.get(ext) || 'other';
1458
- byLanguage[lang] = (byLanguage[lang] || 0) + 1;
1459
- }
1460
- const langCount = Object.keys(byLanguage).length;
1461
-
1462
- // Cycles
1463
- const fileCycles = findCycles(db, { fileLevel: true, noTests });
1464
- const fnCycles = findCycles(db, { fileLevel: false, noTests });
1465
-
1466
- // Top 5 coupling hotspots (fan-in + fan-out, file nodes)
1467
- const testFilter = noTests
1468
- ? `AND n.file NOT LIKE '%.test.%'
1469
- AND n.file NOT LIKE '%.spec.%'
1470
- AND n.file NOT LIKE '%__test__%'
1471
- AND n.file NOT LIKE '%__tests__%'
1472
- AND n.file NOT LIKE '%.stories.%'`
1473
- : '';
1474
- const hotspotRows = db
1475
- .prepare(`
1476
- SELECT n.file,
1477
- (SELECT COUNT(*) FROM edges WHERE target_id = n.id) as fan_in,
1478
- (SELECT COUNT(*) FROM edges WHERE source_id = n.id) as fan_out
1479
- FROM nodes n
1480
- WHERE n.kind = 'file' ${testFilter}
1481
- ORDER BY (SELECT COUNT(*) FROM edges WHERE target_id = n.id)
1482
- + (SELECT COUNT(*) FROM edges WHERE source_id = n.id) DESC
1483
- `)
1484
- .all();
1485
- const filteredHotspots = noTests ? hotspotRows.filter((r) => !isTestFile(r.file)) : hotspotRows;
1486
- const hotspots = filteredHotspots.slice(0, 5).map((r) => ({
1487
- file: r.file,
1488
- fanIn: r.fan_in,
1489
- fanOut: r.fan_out,
1490
- }));
1491
-
1492
- // Embeddings metadata
1493
- let embeddings = null;
1494
- try {
1495
- const count = db.prepare('SELECT COUNT(*) as c FROM embeddings').get();
1496
- if (count && count.c > 0) {
1497
- const meta = {};
1498
- const metaRows = db.prepare('SELECT key, value FROM embedding_meta').all();
1499
- for (const r of metaRows) meta[r.key] = r.value;
1500
- embeddings = {
1501
- count: count.c,
1502
- model: meta.model || null,
1503
- dim: meta.dim ? parseInt(meta.dim, 10) : null,
1504
- builtAt: meta.built_at || null,
1505
- };
1277
+ // Node breakdown by kind
1278
+ let nodeRows;
1279
+ if (noTests) {
1280
+ const allNodes = db.prepare('SELECT id, kind, file FROM nodes').all();
1281
+ const filtered = allNodes.filter((n) => !testFileIds.has(n.id));
1282
+ const counts = {};
1283
+ for (const n of filtered) counts[n.kind] = (counts[n.kind] || 0) + 1;
1284
+ nodeRows = Object.entries(counts).map(([kind, c]) => ({ kind, c }));
1285
+ } else {
1286
+ nodeRows = db.prepare('SELECT kind, COUNT(*) as c FROM nodes GROUP BY kind').all();
1506
1287
  }
1507
- } catch {
1508
- /* embeddings table may not exist */
1509
- }
1510
-
1511
- // Graph quality metrics
1512
- const qualityTestFilter = testFilter.replace(/n\.file/g, 'file');
1513
- const totalCallable = db
1514
- .prepare(
1515
- `SELECT COUNT(*) as c FROM nodes WHERE kind IN ('function', 'method') ${qualityTestFilter}`,
1516
- )
1517
- .get().c;
1518
- const callableWithCallers = db
1519
- .prepare(`
1520
- SELECT COUNT(DISTINCT e.target_id) as c FROM edges e
1521
- JOIN nodes n ON e.target_id = n.id
1522
- WHERE e.kind = 'calls' AND n.kind IN ('function', 'method') ${testFilter}
1523
- `)
1524
- .get().c;
1525
- const callerCoverage = totalCallable > 0 ? callableWithCallers / totalCallable : 0;
1526
-
1527
- const totalCallEdges = db.prepare("SELECT COUNT(*) as c FROM edges WHERE kind = 'calls'").get().c;
1528
- const highConfCallEdges = db
1529
- .prepare("SELECT COUNT(*) as c FROM edges WHERE kind = 'calls' AND confidence >= 0.7")
1530
- .get().c;
1531
- const callConfidence = totalCallEdges > 0 ? highConfCallEdges / totalCallEdges : 0;
1532
-
1533
- // False-positive warnings: generic names with > threshold callers
1534
- const fpRows = db
1535
- .prepare(`
1536
- SELECT n.name, n.file, n.line, COUNT(e.source_id) as caller_count
1537
- FROM nodes n
1538
- LEFT JOIN edges e ON n.id = e.target_id AND e.kind = 'calls'
1539
- WHERE n.kind IN ('function', 'method')
1540
- GROUP BY n.id
1541
- HAVING caller_count > ?
1542
- ORDER BY caller_count DESC
1543
- `)
1544
- .all(FALSE_POSITIVE_CALLER_THRESHOLD);
1545
- const falsePositiveWarnings = fpRows
1546
- .filter((r) =>
1547
- FALSE_POSITIVE_NAMES.has(r.name.includes('.') ? r.name.split('.').pop() : r.name),
1548
- )
1549
- .map((r) => ({ name: r.name, file: r.file, line: r.line, callerCount: r.caller_count }));
1550
-
1551
- // Edges from suspicious nodes
1552
- let fpEdgeCount = 0;
1553
- for (const fp of falsePositiveWarnings) fpEdgeCount += fp.callerCount;
1554
- const falsePositiveRatio = totalCallEdges > 0 ? fpEdgeCount / totalCallEdges : 0;
1555
-
1556
- const score = Math.round(
1557
- callerCoverage * 40 + callConfidence * 40 + (1 - falsePositiveRatio) * 20,
1558
- );
1559
-
1560
- const quality = {
1561
- score,
1562
- callerCoverage: {
1563
- ratio: callerCoverage,
1564
- covered: callableWithCallers,
1565
- total: totalCallable,
1566
- },
1567
- callConfidence: {
1568
- ratio: callConfidence,
1569
- highConf: highConfCallEdges,
1570
- total: totalCallEdges,
1571
- },
1572
- falsePositiveWarnings,
1573
- };
1574
-
1575
- // Role distribution
1576
- let roleRows;
1577
- if (noTests) {
1578
- const allRoleNodes = db.prepare('SELECT role, file FROM nodes WHERE role IS NOT NULL').all();
1579
- const filtered = allRoleNodes.filter((n) => !isTestFile(n.file));
1580
- const counts = {};
1581
- for (const n of filtered) counts[n.role] = (counts[n.role] || 0) + 1;
1582
- roleRows = Object.entries(counts).map(([role, c]) => ({ role, c }));
1583
- } else {
1584
- roleRows = db
1585
- .prepare('SELECT role, COUNT(*) as c FROM nodes WHERE role IS NOT NULL GROUP BY role')
1586
- .all();
1587
- }
1588
- const roles = {};
1589
- for (const r of roleRows) roles[r.role] = r.c;
1590
-
1591
- // Complexity summary
1592
- let complexity = null;
1593
- try {
1594
- const cRows = db
1595
- .prepare(
1596
- `SELECT fc.cognitive, fc.cyclomatic, fc.max_nesting, fc.maintainability_index
1597
- FROM function_complexity fc JOIN nodes n ON fc.node_id = n.id
1598
- WHERE n.kind IN ('function','method') ${testFilter}`,
1599
- )
1600
- .all();
1601
- if (cRows.length > 0) {
1602
- const miValues = cRows.map((r) => r.maintainability_index || 0);
1603
- complexity = {
1604
- analyzed: cRows.length,
1605
- avgCognitive: +(cRows.reduce((s, r) => s + r.cognitive, 0) / cRows.length).toFixed(1),
1606
- avgCyclomatic: +(cRows.reduce((s, r) => s + r.cyclomatic, 0) / cRows.length).toFixed(1),
1607
- maxCognitive: Math.max(...cRows.map((r) => r.cognitive)),
1608
- maxCyclomatic: Math.max(...cRows.map((r) => r.cyclomatic)),
1609
- avgMI: +(miValues.reduce((s, v) => s + v, 0) / miValues.length).toFixed(1),
1610
- minMI: +Math.min(...miValues).toFixed(1),
1611
- };
1288
+ const nodesByKind = {};
1289
+ let totalNodes = 0;
1290
+ for (const r of nodeRows) {
1291
+ nodesByKind[r.kind] = r.c;
1292
+ totalNodes += r.c;
1612
1293
  }
1613
- } catch {
1614
- /* table may not exist in older DBs */
1615
- }
1616
-
1617
- db.close();
1618
- return {
1619
- nodes: { total: totalNodes, byKind: nodesByKind },
1620
- edges: { total: totalEdges, byKind: edgesByKind },
1621
- files: { total: fileNodes.length, languages: langCount, byLanguage },
1622
- cycles: { fileLevel: fileCycles.length, functionLevel: fnCycles.length },
1623
- hotspots,
1624
- embeddings,
1625
- quality,
1626
- roles,
1627
- complexity,
1628
- };
1629
- }
1630
-
1631
- export async function stats(customDbPath, opts = {}) {
1632
- const data = statsData(customDbPath, { noTests: opts.noTests });
1633
-
1634
- // Community detection summary (async import for lazy-loading)
1635
- try {
1636
- const { communitySummaryForStats } = await import('./communities.js');
1637
- data.communities = communitySummaryForStats(customDbPath, { noTests: opts.noTests });
1638
- } catch {
1639
- /* graphology may not be available */
1640
- }
1641
-
1642
- if (opts.json) {
1643
- console.log(JSON.stringify(data, null, 2));
1644
- return;
1645
- }
1646
1294
 
1647
- // Human-readable output
1648
- console.log('\n# Codegraph Stats\n');
1649
-
1650
- // Nodes
1651
- console.log(`Nodes: ${data.nodes.total} total`);
1652
- const kindEntries = Object.entries(data.nodes.byKind).sort((a, b) => b[1] - a[1]);
1653
- const kindParts = kindEntries.map(([k, v]) => `${k} ${v}`);
1654
- // Print in rows of 3
1655
- for (let i = 0; i < kindParts.length; i += 3) {
1656
- const row = kindParts
1657
- .slice(i, i + 3)
1658
- .map((p) => p.padEnd(18))
1659
- .join('');
1660
- console.log(` ${row}`);
1661
- }
1662
-
1663
- // Edges
1664
- console.log(`\nEdges: ${data.edges.total} total`);
1665
- const edgeEntries = Object.entries(data.edges.byKind).sort((a, b) => b[1] - a[1]);
1666
- const edgeParts = edgeEntries.map(([k, v]) => `${k} ${v}`);
1667
- for (let i = 0; i < edgeParts.length; i += 3) {
1668
- const row = edgeParts
1669
- .slice(i, i + 3)
1670
- .map((p) => p.padEnd(18))
1671
- .join('');
1672
- console.log(` ${row}`);
1673
- }
1674
-
1675
- // Files
1676
- console.log(`\nFiles: ${data.files.total} (${data.files.languages} languages)`);
1677
- const langEntries = Object.entries(data.files.byLanguage).sort((a, b) => b[1] - a[1]);
1678
- const langParts = langEntries.map(([k, v]) => `${k} ${v}`);
1679
- for (let i = 0; i < langParts.length; i += 3) {
1680
- const row = langParts
1681
- .slice(i, i + 3)
1682
- .map((p) => p.padEnd(18))
1683
- .join('');
1684
- console.log(` ${row}`);
1685
- }
1686
-
1687
- // Cycles
1688
- console.log(
1689
- `\nCycles: ${data.cycles.fileLevel} file-level, ${data.cycles.functionLevel} function-level`,
1690
- );
1691
-
1692
- // Hotspots
1693
- if (data.hotspots.length > 0) {
1694
- console.log(`\nTop ${data.hotspots.length} coupling hotspots:`);
1695
- for (let i = 0; i < data.hotspots.length; i++) {
1696
- const h = data.hotspots[i];
1697
- console.log(
1698
- ` ${String(i + 1).padStart(2)}. ${h.file.padEnd(35)} fan-in: ${String(h.fanIn).padStart(3)} fan-out: ${String(h.fanOut).padStart(3)}`,
1295
+ // Edge breakdown by kind
1296
+ let edgeRows;
1297
+ if (noTests) {
1298
+ const allEdges = db.prepare('SELECT source_id, target_id, kind FROM edges').all();
1299
+ const filtered = allEdges.filter(
1300
+ (e) => !testFileIds.has(e.source_id) && !testFileIds.has(e.target_id),
1699
1301
  );
1302
+ const counts = {};
1303
+ for (const e of filtered) counts[e.kind] = (counts[e.kind] || 0) + 1;
1304
+ edgeRows = Object.entries(counts).map(([kind, c]) => ({ kind, c }));
1305
+ } else {
1306
+ edgeRows = db.prepare('SELECT kind, COUNT(*) as c FROM edges GROUP BY kind').all();
1307
+ }
1308
+ const edgesByKind = {};
1309
+ let totalEdges = 0;
1310
+ for (const r of edgeRows) {
1311
+ edgesByKind[r.kind] = r.c;
1312
+ totalEdges += r.c;
1700
1313
  }
1701
- }
1702
-
1703
- // Embeddings
1704
- if (data.embeddings) {
1705
- const e = data.embeddings;
1706
- console.log(
1707
- `\nEmbeddings: ${e.count} vectors (${e.model || 'unknown'}, ${e.dim || '?'}d) built ${e.builtAt || 'unknown'}`,
1708
- );
1709
- } else {
1710
- console.log('\nEmbeddings: not built');
1711
- }
1712
1314
 
1713
- // Quality
1714
- if (data.quality) {
1715
- const q = data.quality;
1716
- const cc = q.callerCoverage;
1717
- const cf = q.callConfidence;
1718
- console.log(`\nGraph Quality: ${q.score}/100`);
1719
- console.log(
1720
- ` Caller coverage: ${(cc.ratio * 100).toFixed(1)}% (${cc.covered}/${cc.total} functions have >=1 caller)`,
1721
- );
1722
- console.log(
1723
- ` Call confidence: ${(cf.ratio * 100).toFixed(1)}% (${cf.highConf}/${cf.total} call edges are high-confidence)`,
1724
- );
1725
- if (q.falsePositiveWarnings.length > 0) {
1726
- console.log(' False-positive warnings:');
1727
- for (const fp of q.falsePositiveWarnings) {
1728
- console.log(` ! ${fp.name} (${fp.callerCount} callers) -- ${fp.file}:${fp.line}`);
1315
+ // File/language distribution — map extensions via LANGUAGE_REGISTRY
1316
+ const extToLang = new Map();
1317
+ for (const entry of LANGUAGE_REGISTRY) {
1318
+ for (const ext of entry.extensions) {
1319
+ extToLang.set(ext, entry.id);
1729
1320
  }
1730
1321
  }
1731
- }
1732
-
1733
- // Roles
1734
- if (data.roles && Object.keys(data.roles).length > 0) {
1735
- const total = Object.values(data.roles).reduce((a, b) => a + b, 0);
1736
- console.log(`\nRoles: ${total} classified symbols`);
1737
- const roleParts = Object.entries(data.roles)
1738
- .sort((a, b) => b[1] - a[1])
1739
- .map(([k, v]) => `${k} ${v}`);
1740
- for (let i = 0; i < roleParts.length; i += 3) {
1741
- const row = roleParts
1742
- .slice(i, i + 3)
1743
- .map((p) => p.padEnd(18))
1744
- .join('');
1745
- console.log(` ${row}`);
1322
+ let fileNodes = db.prepare("SELECT file FROM nodes WHERE kind = 'file'").all();
1323
+ if (noTests) fileNodes = fileNodes.filter((n) => !isTestFile(n.file));
1324
+ const byLanguage = {};
1325
+ for (const row of fileNodes) {
1326
+ const ext = path.extname(row.file).toLowerCase();
1327
+ const lang = extToLang.get(ext) || 'other';
1328
+ byLanguage[lang] = (byLanguage[lang] || 0) + 1;
1746
1329
  }
1747
- }
1748
-
1749
- // Complexity
1750
- if (data.complexity) {
1751
- const cx = data.complexity;
1752
- const miPart = cx.avgMI != null ? ` | avg MI: ${cx.avgMI} | min MI: ${cx.minMI}` : '';
1753
- console.log(
1754
- `\nComplexity: ${cx.analyzed} functions | avg cognitive: ${cx.avgCognitive} | avg cyclomatic: ${cx.avgCyclomatic} | max cognitive: ${cx.maxCognitive}${miPart}`,
1755
- );
1756
- }
1757
-
1758
- // Communities
1759
- if (data.communities) {
1760
- const cm = data.communities;
1761
- console.log(
1762
- `\nCommunities: ${cm.communityCount} detected | modularity: ${cm.modularity} | drift: ${cm.driftScore}%`,
1763
- );
1764
- }
1765
-
1766
- console.log();
1767
- }
1768
-
1769
- // ─── Human-readable output (original formatting) ───────────────────────
1770
-
1771
- export function queryName(name, customDbPath, opts = {}) {
1772
- const data = queryNameData(name, customDbPath, {
1773
- noTests: opts.noTests,
1774
- limit: opts.limit,
1775
- offset: opts.offset,
1776
- });
1777
- if (opts.ndjson) {
1778
- printNdjson(data, 'results');
1779
- return;
1780
- }
1781
- if (opts.json) {
1782
- console.log(JSON.stringify(data, null, 2));
1783
- return;
1784
- }
1785
- if (data.results.length === 0) {
1786
- console.log(`No results for "${name}"`);
1787
- return;
1788
- }
1330
+ const langCount = Object.keys(byLanguage).length;
1789
1331
 
1790
- console.log(`\nResults for "${name}":\n`);
1791
- for (const r of data.results) {
1792
- console.log(` ${kindIcon(r.kind)} ${r.name} (${r.kind}) -- ${r.file}:${r.line}`);
1793
- if (r.callees.length > 0) {
1794
- console.log(` -> calls/uses:`);
1795
- for (const c of r.callees.slice(0, 15))
1796
- console.log(` -> ${c.name} (${c.edgeKind}) ${c.file}:${c.line}`);
1797
- if (r.callees.length > 15) console.log(` ... and ${r.callees.length - 15} more`);
1798
- }
1799
- if (r.callers.length > 0) {
1800
- console.log(` <- called by:`);
1801
- for (const c of r.callers.slice(0, 15))
1802
- console.log(` <- ${c.name} (${c.edgeKind}) ${c.file}:${c.line}`);
1803
- if (r.callers.length > 15) console.log(` ... and ${r.callers.length - 15} more`);
1804
- }
1805
- console.log();
1806
- }
1807
- }
1332
+ // Cycles
1333
+ const fileCycles = findCycles(db, { fileLevel: true, noTests });
1334
+ const fnCycles = findCycles(db, { fileLevel: false, noTests });
1808
1335
 
1809
- export function impactAnalysis(file, customDbPath, opts = {}) {
1810
- const data = impactAnalysisData(file, customDbPath, opts);
1811
- if (opts.ndjson) {
1812
- printNdjson(data, 'sources');
1813
- return;
1814
- }
1815
- if (opts.json) {
1816
- console.log(JSON.stringify(data, null, 2));
1817
- return;
1818
- }
1819
- if (data.sources.length === 0) {
1820
- console.log(`No file matching "${file}" in graph`);
1821
- return;
1822
- }
1336
+ // Top 5 coupling hotspots (fan-in + fan-out, file nodes)
1337
+ const testFilter = testFilterSQL('n.file', noTests);
1338
+ const hotspotRows = db
1339
+ .prepare(`
1340
+ SELECT n.file,
1341
+ (SELECT COUNT(*) FROM edges WHERE target_id = n.id) as fan_in,
1342
+ (SELECT COUNT(*) FROM edges WHERE source_id = n.id) as fan_out
1343
+ FROM nodes n
1344
+ WHERE n.kind = 'file' ${testFilter}
1345
+ ORDER BY (SELECT COUNT(*) FROM edges WHERE target_id = n.id)
1346
+ + (SELECT COUNT(*) FROM edges WHERE source_id = n.id) DESC
1347
+ `)
1348
+ .all();
1349
+ const filteredHotspots = noTests ? hotspotRows.filter((r) => !isTestFile(r.file)) : hotspotRows;
1350
+ const hotspots = filteredHotspots.slice(0, 5).map((r) => ({
1351
+ file: r.file,
1352
+ fanIn: r.fan_in,
1353
+ fanOut: r.fan_out,
1354
+ }));
1823
1355
 
1824
- console.log(`\nImpact analysis for files matching "${file}":\n`);
1825
- for (const s of data.sources) console.log(` # ${s} (source)`);
1826
-
1827
- const levels = data.levels;
1828
- if (Object.keys(levels).length === 0) {
1829
- console.log(` No dependents found.`);
1830
- } else {
1831
- for (const level of Object.keys(levels).sort((a, b) => a - b)) {
1832
- const nodes = levels[level];
1833
- console.log(
1834
- `\n ${'--'.repeat(parseInt(level, 10))} Level ${level} (${nodes.length} files):`,
1835
- );
1836
- for (const n of nodes.slice(0, 30))
1837
- console.log(` ${' '.repeat(parseInt(level, 10))}^ ${n.file}`);
1838
- if (nodes.length > 30) console.log(` ... and ${nodes.length - 30} more`);
1356
+ // Embeddings metadata
1357
+ let embeddings = null;
1358
+ try {
1359
+ const count = db.prepare('SELECT COUNT(*) as c FROM embeddings').get();
1360
+ if (count && count.c > 0) {
1361
+ const meta = {};
1362
+ const metaRows = db.prepare('SELECT key, value FROM embedding_meta').all();
1363
+ for (const r of metaRows) meta[r.key] = r.value;
1364
+ embeddings = {
1365
+ count: count.c,
1366
+ model: meta.model || null,
1367
+ dim: meta.dim ? parseInt(meta.dim, 10) : null,
1368
+ builtAt: meta.built_at || null,
1369
+ };
1370
+ }
1371
+ } catch {
1372
+ /* embeddings table may not exist */
1839
1373
  }
1840
- }
1841
- console.log(`\n Total: ${data.totalDependents} files transitively depend on "${file}"\n`);
1842
- }
1843
1374
 
1844
- export function moduleMap(customDbPath, limit = 20, opts = {}) {
1845
- const data = moduleMapData(customDbPath, limit, { noTests: opts.noTests });
1846
- if (opts.json) {
1847
- console.log(JSON.stringify(data, null, 2));
1848
- return;
1849
- }
1375
+ // Graph quality metrics
1376
+ const qualityTestFilter = testFilter.replace(/n\.file/g, 'file');
1377
+ const totalCallable = db
1378
+ .prepare(
1379
+ `SELECT COUNT(*) as c FROM nodes WHERE kind IN ('function', 'method') ${qualityTestFilter}`,
1380
+ )
1381
+ .get().c;
1382
+ const callableWithCallers = db
1383
+ .prepare(`
1384
+ SELECT COUNT(DISTINCT e.target_id) as c FROM edges e
1385
+ JOIN nodes n ON e.target_id = n.id
1386
+ WHERE e.kind = 'calls' AND n.kind IN ('function', 'method') ${testFilter}
1387
+ `)
1388
+ .get().c;
1389
+ const callerCoverage = totalCallable > 0 ? callableWithCallers / totalCallable : 0;
1390
+
1391
+ const totalCallEdges = db
1392
+ .prepare("SELECT COUNT(*) as c FROM edges WHERE kind = 'calls'")
1393
+ .get().c;
1394
+ const highConfCallEdges = db
1395
+ .prepare("SELECT COUNT(*) as c FROM edges WHERE kind = 'calls' AND confidence >= 0.7")
1396
+ .get().c;
1397
+ const callConfidence = totalCallEdges > 0 ? highConfCallEdges / totalCallEdges : 0;
1398
+
1399
+ // False-positive warnings: generic names with > threshold callers
1400
+ const fpRows = db
1401
+ .prepare(`
1402
+ SELECT n.name, n.file, n.line, COUNT(e.source_id) as caller_count
1403
+ FROM nodes n
1404
+ LEFT JOIN edges e ON n.id = e.target_id AND e.kind = 'calls'
1405
+ WHERE n.kind IN ('function', 'method')
1406
+ GROUP BY n.id
1407
+ HAVING caller_count > ?
1408
+ ORDER BY caller_count DESC
1409
+ `)
1410
+ .all(FALSE_POSITIVE_CALLER_THRESHOLD);
1411
+ const falsePositiveWarnings = fpRows
1412
+ .filter((r) =>
1413
+ FALSE_POSITIVE_NAMES.has(r.name.includes('.') ? r.name.split('.').pop() : r.name),
1414
+ )
1415
+ .map((r) => ({ name: r.name, file: r.file, line: r.line, callerCount: r.caller_count }));
1850
1416
 
1851
- console.log(`\nModule map (top ${limit} most-connected nodes):\n`);
1852
- const dirs = new Map();
1853
- for (const n of data.topNodes) {
1854
- if (!dirs.has(n.dir)) dirs.set(n.dir, []);
1855
- dirs.get(n.dir).push(n);
1856
- }
1857
- for (const [dir, files] of [...dirs].sort()) {
1858
- console.log(` [${dir}/]`);
1859
- for (const f of files) {
1860
- const coupling = f.inEdges + f.outEdges;
1861
- const bar = '#'.repeat(Math.min(coupling, 40));
1862
- console.log(
1863
- ` ${path.basename(f.file).padEnd(35)} <-${String(f.inEdges).padStart(3)} ->${String(f.outEdges).padStart(3)} =${String(coupling).padStart(3)} ${bar}`,
1864
- );
1865
- }
1866
- }
1867
- console.log(
1868
- `\n Total: ${data.stats.totalFiles} files, ${data.stats.totalNodes} symbols, ${data.stats.totalEdges} edges\n`,
1869
- );
1870
- }
1417
+ // Edges from suspicious nodes
1418
+ let fpEdgeCount = 0;
1419
+ for (const fp of falsePositiveWarnings) fpEdgeCount += fp.callerCount;
1420
+ const falsePositiveRatio = totalCallEdges > 0 ? fpEdgeCount / totalCallEdges : 0;
1871
1421
 
1872
- export function fileDeps(file, customDbPath, opts = {}) {
1873
- const data = fileDepsData(file, customDbPath, opts);
1874
- if (opts.ndjson) {
1875
- printNdjson(data, 'results');
1876
- return;
1877
- }
1878
- if (opts.json) {
1879
- console.log(JSON.stringify(data, null, 2));
1880
- return;
1881
- }
1882
- if (data.results.length === 0) {
1883
- console.log(`No file matching "${file}" in graph`);
1884
- return;
1885
- }
1422
+ const score = Math.round(
1423
+ callerCoverage * 40 + callConfidence * 40 + (1 - falsePositiveRatio) * 20,
1424
+ );
1886
1425
 
1887
- for (const r of data.results) {
1888
- console.log(`\n# ${r.file}\n`);
1889
- console.log(` -> Imports (${r.imports.length}):`);
1890
- for (const i of r.imports) {
1891
- const typeTag = i.typeOnly ? ' (type-only)' : '';
1892
- console.log(` -> ${i.file}${typeTag}`);
1893
- }
1894
- console.log(`\n <- Imported by (${r.importedBy.length}):`);
1895
- for (const i of r.importedBy) console.log(` <- ${i.file}`);
1896
- if (r.definitions.length > 0) {
1897
- console.log(`\n Definitions (${r.definitions.length}):`);
1898
- for (const d of r.definitions.slice(0, 30))
1899
- console.log(` ${kindIcon(d.kind)} ${d.name} :${d.line}`);
1900
- if (r.definitions.length > 30) console.log(` ... and ${r.definitions.length - 30} more`);
1901
- }
1902
- console.log();
1903
- }
1904
- }
1426
+ const quality = {
1427
+ score,
1428
+ callerCoverage: {
1429
+ ratio: callerCoverage,
1430
+ covered: callableWithCallers,
1431
+ total: totalCallable,
1432
+ },
1433
+ callConfidence: {
1434
+ ratio: callConfidence,
1435
+ highConf: highConfCallEdges,
1436
+ total: totalCallEdges,
1437
+ },
1438
+ falsePositiveWarnings,
1439
+ };
1905
1440
 
1906
- export function fnDeps(name, customDbPath, opts = {}) {
1907
- const data = fnDepsData(name, customDbPath, opts);
1908
- if (opts.ndjson) {
1909
- printNdjson(data, 'results');
1910
- return;
1911
- }
1912
- if (opts.json) {
1913
- console.log(JSON.stringify(data, null, 2));
1914
- return;
1915
- }
1916
- if (data.results.length === 0) {
1917
- console.log(`No function/method/class matching "${name}"`);
1918
- return;
1919
- }
1441
+ // Role distribution
1442
+ let roleRows;
1443
+ if (noTests) {
1444
+ const allRoleNodes = db.prepare('SELECT role, file FROM nodes WHERE role IS NOT NULL').all();
1445
+ const filtered = allRoleNodes.filter((n) => !isTestFile(n.file));
1446
+ const counts = {};
1447
+ for (const n of filtered) counts[n.role] = (counts[n.role] || 0) + 1;
1448
+ roleRows = Object.entries(counts).map(([role, c]) => ({ role, c }));
1449
+ } else {
1450
+ roleRows = db
1451
+ .prepare('SELECT role, COUNT(*) as c FROM nodes WHERE role IS NOT NULL GROUP BY role')
1452
+ .all();
1453
+ }
1454
+ const roles = {};
1455
+ for (const r of roleRows) roles[r.role] = r.c;
1920
1456
 
1921
- for (const r of data.results) {
1922
- console.log(`\n${kindIcon(r.kind)} ${r.name} (${r.kind}) -- ${r.file}:${r.line}\n`);
1923
- if (r.callees.length > 0) {
1924
- console.log(` -> Calls (${r.callees.length}):`);
1925
- for (const c of r.callees)
1926
- console.log(` -> ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`);
1927
- }
1928
- if (r.callers.length > 0) {
1929
- console.log(`\n <- Called by (${r.callers.length}):`);
1930
- for (const c of r.callers) {
1931
- const via = c.viaHierarchy ? ` (via ${c.viaHierarchy})` : '';
1932
- console.log(` <- ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}${via}`);
1457
+ // Complexity summary
1458
+ let complexity = null;
1459
+ try {
1460
+ const cRows = db
1461
+ .prepare(
1462
+ `SELECT fc.cognitive, fc.cyclomatic, fc.max_nesting, fc.maintainability_index
1463
+ FROM function_complexity fc JOIN nodes n ON fc.node_id = n.id
1464
+ WHERE n.kind IN ('function','method') ${testFilter}`,
1465
+ )
1466
+ .all();
1467
+ if (cRows.length > 0) {
1468
+ const miValues = cRows.map((r) => r.maintainability_index || 0);
1469
+ complexity = {
1470
+ analyzed: cRows.length,
1471
+ avgCognitive: +(cRows.reduce((s, r) => s + r.cognitive, 0) / cRows.length).toFixed(1),
1472
+ avgCyclomatic: +(cRows.reduce((s, r) => s + r.cyclomatic, 0) / cRows.length).toFixed(1),
1473
+ maxCognitive: Math.max(...cRows.map((r) => r.cognitive)),
1474
+ maxCyclomatic: Math.max(...cRows.map((r) => r.cyclomatic)),
1475
+ avgMI: +(miValues.reduce((s, v) => s + v, 0) / miValues.length).toFixed(1),
1476
+ minMI: +Math.min(...miValues).toFixed(1),
1477
+ };
1933
1478
  }
1479
+ } catch {
1480
+ /* table may not exist in older DBs */
1934
1481
  }
1935
- for (const [d, fns] of Object.entries(r.transitiveCallers)) {
1936
- console.log(
1937
- `\n ${'<-'.repeat(parseInt(d, 10))} Transitive callers (depth ${d}, ${fns.length}):`,
1938
- );
1939
- for (const n of fns.slice(0, 20))
1940
- console.log(
1941
- ` ${' '.repeat(parseInt(d, 10) - 1)}<- ${kindIcon(n.kind)} ${n.name} ${n.file}:${n.line}`,
1942
- );
1943
- if (fns.length > 20) console.log(` ... and ${fns.length - 20} more`);
1944
- }
1945
- if (r.callees.length === 0 && r.callers.length === 0) {
1946
- console.log(` (no call edges found -- may be invoked dynamically or via re-exports)`);
1947
- }
1948
- console.log();
1482
+
1483
+ return {
1484
+ nodes: { total: totalNodes, byKind: nodesByKind },
1485
+ edges: { total: totalEdges, byKind: edgesByKind },
1486
+ files: { total: fileNodes.length, languages: langCount, byLanguage },
1487
+ cycles: { fileLevel: fileCycles.length, functionLevel: fnCycles.length },
1488
+ hotspots,
1489
+ embeddings,
1490
+ quality,
1491
+ roles,
1492
+ complexity,
1493
+ };
1494
+ } finally {
1495
+ db.close();
1949
1496
  }
1950
1497
  }
1951
1498
 
@@ -2063,347 +1610,242 @@ function extractSignature(fileLines, line) {
2063
1610
 
2064
1611
  export function contextData(name, customDbPath, opts = {}) {
2065
1612
  const db = openReadonlyOrFail(customDbPath);
2066
- const depth = opts.depth || 0;
2067
- const noSource = opts.noSource || false;
2068
- const noTests = opts.noTests || false;
2069
- const includeTests = opts.includeTests || false;
2070
-
2071
- const dbPath = findDbPath(customDbPath);
2072
- const repoRoot = path.resolve(path.dirname(dbPath), '..');
2073
-
2074
- const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
2075
- if (nodes.length === 0) {
2076
- db.close();
2077
- return { name, results: [] };
2078
- }
1613
+ try {
1614
+ const depth = opts.depth || 0;
1615
+ const noSource = opts.noSource || false;
1616
+ const noTests = opts.noTests || false;
1617
+ const includeTests = opts.includeTests || false;
2079
1618
 
2080
- // No hardcoded slice — pagination handles bounding via limit/offset
1619
+ const dbPath = findDbPath(customDbPath);
1620
+ const repoRoot = path.resolve(path.dirname(dbPath), '..');
2081
1621
 
2082
- // File-lines cache to avoid re-reading the same file
2083
- const fileCache = new Map();
2084
- function getFileLines(file) {
2085
- if (fileCache.has(file)) return fileCache.get(file);
2086
- try {
2087
- const absPath = safePath(repoRoot, file);
2088
- if (!absPath) {
2089
- fileCache.set(file, null);
2090
- return null;
2091
- }
2092
- const lines = fs.readFileSync(absPath, 'utf-8').split('\n');
2093
- fileCache.set(file, lines);
2094
- return lines;
2095
- } catch (e) {
2096
- debug(`getFileLines failed for ${file}: ${e.message}`);
2097
- fileCache.set(file, null);
2098
- return null;
1622
+ const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
1623
+ if (nodes.length === 0) {
1624
+ return { name, results: [] };
2099
1625
  }
2100
- }
2101
-
2102
- const results = nodes.map((node) => {
2103
- const fileLines = getFileLines(node.file);
2104
-
2105
- // Source
2106
- const source = noSource ? null : readSourceRange(repoRoot, node.file, node.line, node.end_line);
2107
1626
 
2108
- // Signature
2109
- const signature = fileLines ? extractSignature(fileLines, node.line) : null;
2110
-
2111
- // Callees
2112
- const calleeRows = db
2113
- .prepare(
2114
- `SELECT n.id, n.name, n.kind, n.file, n.line, n.end_line
2115
- FROM edges e JOIN nodes n ON e.target_id = n.id
2116
- WHERE e.source_id = ? AND e.kind = 'calls'`,
2117
- )
2118
- .all(node.id);
2119
- const filteredCallees = noTests ? calleeRows.filter((c) => !isTestFile(c.file)) : calleeRows;
2120
-
2121
- const callees = filteredCallees.map((c) => {
2122
- const cLines = getFileLines(c.file);
2123
- const summary = cLines ? extractSummary(cLines, c.line) : null;
2124
- let calleeSource = null;
2125
- if (depth >= 1) {
2126
- calleeSource = readSourceRange(repoRoot, c.file, c.line, c.end_line);
2127
- }
2128
- return {
2129
- name: c.name,
2130
- kind: c.kind,
2131
- file: c.file,
2132
- line: c.line,
2133
- endLine: c.end_line || null,
2134
- summary,
2135
- source: calleeSource,
2136
- };
2137
- });
1627
+ // No hardcoded slice — pagination handles bounding via limit/offset
2138
1628
 
2139
- // Deep callee expansion via BFS (depth > 1, capped at 5)
2140
- if (depth > 1) {
2141
- const visited = new Set(filteredCallees.map((c) => c.id));
2142
- visited.add(node.id);
2143
- let frontier = filteredCallees.map((c) => c.id);
2144
- const maxDepth = Math.min(depth, 5);
2145
- for (let d = 2; d <= maxDepth; d++) {
2146
- const nextFrontier = [];
2147
- for (const fid of frontier) {
2148
- const deeper = db
2149
- .prepare(
2150
- `SELECT n.id, n.name, n.kind, n.file, n.line, n.end_line
2151
- FROM edges e JOIN nodes n ON e.target_id = n.id
2152
- WHERE e.source_id = ? AND e.kind = 'calls'`,
2153
- )
2154
- .all(fid);
2155
- for (const c of deeper) {
2156
- if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) {
2157
- visited.add(c.id);
2158
- nextFrontier.push(c.id);
2159
- const cLines = getFileLines(c.file);
2160
- callees.push({
2161
- name: c.name,
2162
- kind: c.kind,
2163
- file: c.file,
2164
- line: c.line,
2165
- endLine: c.end_line || null,
2166
- summary: cLines ? extractSummary(cLines, c.line) : null,
2167
- source: readSourceRange(repoRoot, c.file, c.line, c.end_line),
2168
- });
2169
- }
2170
- }
1629
+ // File-lines cache to avoid re-reading the same file
1630
+ const fileCache = new Map();
1631
+ function getFileLines(file) {
1632
+ if (fileCache.has(file)) return fileCache.get(file);
1633
+ try {
1634
+ const absPath = safePath(repoRoot, file);
1635
+ if (!absPath) {
1636
+ fileCache.set(file, null);
1637
+ return null;
2171
1638
  }
2172
- frontier = nextFrontier;
2173
- if (frontier.length === 0) break;
1639
+ const lines = fs.readFileSync(absPath, 'utf-8').split('\n');
1640
+ fileCache.set(file, lines);
1641
+ return lines;
1642
+ } catch (e) {
1643
+ debug(`getFileLines failed for ${file}: ${e.message}`);
1644
+ fileCache.set(file, null);
1645
+ return null;
2174
1646
  }
2175
1647
  }
2176
1648
 
2177
- // Callers
2178
- let callerRows = db
2179
- .prepare(
2180
- `SELECT n.name, n.kind, n.file, n.line
2181
- FROM edges e JOIN nodes n ON e.source_id = n.id
2182
- WHERE e.target_id = ? AND e.kind = 'calls'`,
2183
- )
2184
- .all(node.id);
2185
-
2186
- // Method hierarchy resolution
2187
- if (node.kind === 'method' && node.name.includes('.')) {
2188
- const methodName = node.name.split('.').pop();
2189
- const relatedMethods = resolveMethodViaHierarchy(db, methodName);
2190
- for (const rm of relatedMethods) {
2191
- if (rm.id === node.id) continue;
2192
- const extraCallers = db
2193
- .prepare(
2194
- `SELECT n.name, n.kind, n.file, n.line
2195
- FROM edges e JOIN nodes n ON e.source_id = n.id
2196
- WHERE e.target_id = ? AND e.kind = 'calls'`,
2197
- )
2198
- .all(rm.id);
2199
- callerRows.push(...extraCallers.map((c) => ({ ...c, viaHierarchy: rm.name })));
2200
- }
2201
- }
2202
- if (noTests) callerRows = callerRows.filter((c) => !isTestFile(c.file));
1649
+ const results = nodes.map((node) => {
1650
+ const fileLines = getFileLines(node.file);
2203
1651
 
2204
- const callers = callerRows.map((c) => ({
2205
- name: c.name,
2206
- kind: c.kind,
2207
- file: c.file,
2208
- line: c.line,
2209
- viaHierarchy: c.viaHierarchy || undefined,
2210
- }));
1652
+ // Source
1653
+ const source = noSource
1654
+ ? null
1655
+ : readSourceRange(repoRoot, node.file, node.line, node.end_line);
2211
1656
 
2212
- // Related tests: callers that live in test files
2213
- const testCallerRows = db
2214
- .prepare(
2215
- `SELECT n.name, n.kind, n.file, n.line
2216
- FROM edges e JOIN nodes n ON e.source_id = n.id
2217
- WHERE e.target_id = ? AND e.kind = 'calls'`,
2218
- )
2219
- .all(node.id);
2220
- const testCallers = testCallerRows.filter((c) => isTestFile(c.file));
2221
-
2222
- const testsByFile = new Map();
2223
- for (const tc of testCallers) {
2224
- if (!testsByFile.has(tc.file)) testsByFile.set(tc.file, []);
2225
- testsByFile.get(tc.file).push(tc);
2226
- }
2227
-
2228
- const relatedTests = [];
2229
- for (const [file] of testsByFile) {
2230
- const tLines = getFileLines(file);
2231
- const testNames = [];
2232
- if (tLines) {
2233
- for (const tl of tLines) {
2234
- const tm = tl.match(/(?:it|test|describe)\s*\(\s*['"`]([^'"`]+)['"`]/);
2235
- if (tm) testNames.push(tm[1]);
2236
- }
2237
- }
2238
- const testSource = includeTests && tLines ? tLines.join('\n') : undefined;
2239
- relatedTests.push({
2240
- file,
2241
- testCount: testNames.length,
2242
- testNames,
2243
- source: testSource,
2244
- });
2245
- }
1657
+ // Signature
1658
+ const signature = fileLines ? extractSignature(fileLines, node.line) : null;
2246
1659
 
2247
- // Complexity metrics
2248
- let complexityMetrics = null;
2249
- try {
2250
- const cRow = db
1660
+ // Callees
1661
+ const calleeRows = db
2251
1662
  .prepare(
2252
- 'SELECT cognitive, cyclomatic, max_nesting, maintainability_index, halstead_volume FROM function_complexity WHERE node_id = ?',
2253
- )
2254
- .get(node.id);
2255
- if (cRow) {
2256
- complexityMetrics = {
2257
- cognitive: cRow.cognitive,
2258
- cyclomatic: cRow.cyclomatic,
2259
- maxNesting: cRow.max_nesting,
2260
- maintainabilityIndex: cRow.maintainability_index || 0,
2261
- halsteadVolume: cRow.halstead_volume || 0,
2262
- };
2263
- }
2264
- } catch {
2265
- /* table may not exist */
2266
- }
2267
-
2268
- // Children (parameters, properties, constants)
2269
- let nodeChildren = [];
2270
- try {
2271
- nodeChildren = db
2272
- .prepare('SELECT name, kind, line, end_line FROM nodes WHERE parent_id = ? ORDER BY line')
2273
- .all(node.id)
2274
- .map((c) => ({ name: c.name, kind: c.kind, line: c.line, endLine: c.end_line || null }));
2275
- } catch {
2276
- /* parent_id column may not exist */
2277
- }
2278
-
2279
- return {
2280
- name: node.name,
2281
- kind: node.kind,
2282
- file: node.file,
2283
- line: node.line,
2284
- role: node.role || null,
2285
- endLine: node.end_line || null,
2286
- source,
2287
- signature,
2288
- complexity: complexityMetrics,
2289
- children: nodeChildren.length > 0 ? nodeChildren : undefined,
2290
- callees,
2291
- callers,
2292
- relatedTests,
2293
- };
2294
- });
2295
-
2296
- db.close();
2297
- const base = { name, results };
2298
- return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
2299
- }
2300
-
2301
- export function context(name, customDbPath, opts = {}) {
2302
- const data = contextData(name, customDbPath, opts);
2303
- if (opts.ndjson) {
2304
- printNdjson(data, 'results');
2305
- return;
2306
- }
2307
- if (opts.json) {
2308
- console.log(JSON.stringify(data, null, 2));
2309
- return;
2310
- }
2311
- if (data.results.length === 0) {
2312
- console.log(`No function/method/class matching "${name}"`);
2313
- return;
2314
- }
1663
+ `SELECT n.id, n.name, n.kind, n.file, n.line, n.end_line
1664
+ FROM edges e JOIN nodes n ON e.target_id = n.id
1665
+ WHERE e.source_id = ? AND e.kind = 'calls'`,
1666
+ )
1667
+ .all(node.id);
1668
+ const filteredCallees = noTests ? calleeRows.filter((c) => !isTestFile(c.file)) : calleeRows;
1669
+
1670
+ const callees = filteredCallees.map((c) => {
1671
+ const cLines = getFileLines(c.file);
1672
+ const summary = cLines ? extractSummary(cLines, c.line) : null;
1673
+ let calleeSource = null;
1674
+ if (depth >= 1) {
1675
+ calleeSource = readSourceRange(repoRoot, c.file, c.line, c.end_line);
1676
+ }
1677
+ return {
1678
+ name: c.name,
1679
+ kind: c.kind,
1680
+ file: c.file,
1681
+ line: c.line,
1682
+ endLine: c.end_line || null,
1683
+ summary,
1684
+ source: calleeSource,
1685
+ };
1686
+ });
2315
1687
 
2316
- for (const r of data.results) {
2317
- const lineRange = r.endLine ? `${r.line}-${r.endLine}` : `${r.line}`;
2318
- const roleTag = r.role ? ` [${r.role}]` : '';
2319
- console.log(`\n# ${r.name} (${r.kind})${roleTag} — ${r.file}:${lineRange}\n`);
1688
+ // Deep callee expansion via BFS (depth > 1, capped at 5)
1689
+ if (depth > 1) {
1690
+ const visited = new Set(filteredCallees.map((c) => c.id));
1691
+ visited.add(node.id);
1692
+ let frontier = filteredCallees.map((c) => c.id);
1693
+ const maxDepth = Math.min(depth, 5);
1694
+ for (let d = 2; d <= maxDepth; d++) {
1695
+ const nextFrontier = [];
1696
+ for (const fid of frontier) {
1697
+ const deeper = db
1698
+ .prepare(
1699
+ `SELECT n.id, n.name, n.kind, n.file, n.line, n.end_line
1700
+ FROM edges e JOIN nodes n ON e.target_id = n.id
1701
+ WHERE e.source_id = ? AND e.kind = 'calls'`,
1702
+ )
1703
+ .all(fid);
1704
+ for (const c of deeper) {
1705
+ if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) {
1706
+ visited.add(c.id);
1707
+ nextFrontier.push(c.id);
1708
+ const cLines = getFileLines(c.file);
1709
+ callees.push({
1710
+ name: c.name,
1711
+ kind: c.kind,
1712
+ file: c.file,
1713
+ line: c.line,
1714
+ endLine: c.end_line || null,
1715
+ summary: cLines ? extractSummary(cLines, c.line) : null,
1716
+ source: readSourceRange(repoRoot, c.file, c.line, c.end_line),
1717
+ });
1718
+ }
1719
+ }
1720
+ }
1721
+ frontier = nextFrontier;
1722
+ if (frontier.length === 0) break;
1723
+ }
1724
+ }
2320
1725
 
2321
- // Signature
2322
- if (r.signature) {
2323
- console.log('## Type/Shape Info');
2324
- if (r.signature.params != null) console.log(` Parameters: (${r.signature.params})`);
2325
- if (r.signature.returnType) console.log(` Returns: ${r.signature.returnType}`);
2326
- console.log();
2327
- }
1726
+ // Callers
1727
+ let callerRows = db
1728
+ .prepare(
1729
+ `SELECT n.name, n.kind, n.file, n.line
1730
+ FROM edges e JOIN nodes n ON e.source_id = n.id
1731
+ WHERE e.target_id = ? AND e.kind = 'calls'`,
1732
+ )
1733
+ .all(node.id);
2328
1734
 
2329
- // Children
2330
- if (r.children && r.children.length > 0) {
2331
- console.log(`## Children (${r.children.length})`);
2332
- for (const c of r.children) {
2333
- console.log(` ${kindIcon(c.kind)} ${c.name} :${c.line}`);
1735
+ // Method hierarchy resolution
1736
+ if (node.kind === 'method' && node.name.includes('.')) {
1737
+ const methodName = node.name.split('.').pop();
1738
+ const relatedMethods = resolveMethodViaHierarchy(db, methodName);
1739
+ for (const rm of relatedMethods) {
1740
+ if (rm.id === node.id) continue;
1741
+ const extraCallers = db
1742
+ .prepare(
1743
+ `SELECT n.name, n.kind, n.file, n.line
1744
+ FROM edges e JOIN nodes n ON e.source_id = n.id
1745
+ WHERE e.target_id = ? AND e.kind = 'calls'`,
1746
+ )
1747
+ .all(rm.id);
1748
+ callerRows.push(...extraCallers.map((c) => ({ ...c, viaHierarchy: rm.name })));
1749
+ }
2334
1750
  }
2335
- console.log();
2336
- }
1751
+ if (noTests) callerRows = callerRows.filter((c) => !isTestFile(c.file));
2337
1752
 
2338
- // Complexity
2339
- if (r.complexity) {
2340
- const cx = r.complexity;
2341
- const miPart = cx.maintainabilityIndex ? ` | MI: ${cx.maintainabilityIndex}` : '';
2342
- console.log('## Complexity');
2343
- console.log(
2344
- ` Cognitive: ${cx.cognitive} | Cyclomatic: ${cx.cyclomatic} | Max Nesting: ${cx.maxNesting}${miPart}`,
2345
- );
2346
- console.log();
2347
- }
1753
+ const callers = callerRows.map((c) => ({
1754
+ name: c.name,
1755
+ kind: c.kind,
1756
+ file: c.file,
1757
+ line: c.line,
1758
+ viaHierarchy: c.viaHierarchy || undefined,
1759
+ }));
1760
+
1761
+ // Related tests: callers that live in test files
1762
+ const testCallerRows = db
1763
+ .prepare(
1764
+ `SELECT n.name, n.kind, n.file, n.line
1765
+ FROM edges e JOIN nodes n ON e.source_id = n.id
1766
+ WHERE e.target_id = ? AND e.kind = 'calls'`,
1767
+ )
1768
+ .all(node.id);
1769
+ const testCallers = testCallerRows.filter((c) => isTestFile(c.file));
2348
1770
 
2349
- // Source
2350
- if (r.source) {
2351
- console.log('## Source');
2352
- for (const line of r.source.split('\n')) {
2353
- console.log(` ${line}`);
1771
+ const testsByFile = new Map();
1772
+ for (const tc of testCallers) {
1773
+ if (!testsByFile.has(tc.file)) testsByFile.set(tc.file, []);
1774
+ testsByFile.get(tc.file).push(tc);
2354
1775
  }
2355
- console.log();
2356
- }
2357
-
2358
- // Callees
2359
- if (r.callees.length > 0) {
2360
- console.log(`## Direct Dependencies (${r.callees.length})`);
2361
- for (const c of r.callees) {
2362
- const summary = c.summary ? ` — ${c.summary}` : '';
2363
- console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}${summary}`);
2364
- if (c.source) {
2365
- for (const line of c.source.split('\n').slice(0, 10)) {
2366
- console.log(` | ${line}`);
1776
+
1777
+ const relatedTests = [];
1778
+ for (const [file] of testsByFile) {
1779
+ const tLines = getFileLines(file);
1780
+ const testNames = [];
1781
+ if (tLines) {
1782
+ for (const tl of tLines) {
1783
+ const tm = tl.match(/(?:it|test|describe)\s*\(\s*['"`]([^'"`]+)['"`]/);
1784
+ if (tm) testNames.push(tm[1]);
2367
1785
  }
2368
1786
  }
1787
+ const testSource = includeTests && tLines ? tLines.join('\n') : undefined;
1788
+ relatedTests.push({
1789
+ file,
1790
+ testCount: testNames.length,
1791
+ testNames,
1792
+ source: testSource,
1793
+ });
2369
1794
  }
2370
- console.log();
2371
- }
2372
1795
 
2373
- // Callers
2374
- if (r.callers.length > 0) {
2375
- console.log(`## Callers (${r.callers.length})`);
2376
- for (const c of r.callers) {
2377
- const via = c.viaHierarchy ? ` (via ${c.viaHierarchy})` : '';
2378
- console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}${via}`);
1796
+ // Complexity metrics
1797
+ let complexityMetrics = null;
1798
+ try {
1799
+ const cRow = db
1800
+ .prepare(
1801
+ 'SELECT cognitive, cyclomatic, max_nesting, maintainability_index, halstead_volume FROM function_complexity WHERE node_id = ?',
1802
+ )
1803
+ .get(node.id);
1804
+ if (cRow) {
1805
+ complexityMetrics = {
1806
+ cognitive: cRow.cognitive,
1807
+ cyclomatic: cRow.cyclomatic,
1808
+ maxNesting: cRow.max_nesting,
1809
+ maintainabilityIndex: cRow.maintainability_index || 0,
1810
+ halsteadVolume: cRow.halstead_volume || 0,
1811
+ };
1812
+ }
1813
+ } catch {
1814
+ /* table may not exist */
2379
1815
  }
2380
- console.log();
2381
- }
2382
1816
 
2383
- // Related tests
2384
- if (r.relatedTests.length > 0) {
2385
- console.log('## Related Tests');
2386
- for (const t of r.relatedTests) {
2387
- console.log(` ${t.file} ${t.testCount} tests`);
2388
- for (const tn of t.testNames) {
2389
- console.log(` - ${tn}`);
2390
- }
2391
- if (t.source) {
2392
- console.log(' Source:');
2393
- for (const line of t.source.split('\n').slice(0, 20)) {
2394
- console.log(` | ${line}`);
2395
- }
2396
- }
1817
+ // Children (parameters, properties, constants)
1818
+ let nodeChildren = [];
1819
+ try {
1820
+ nodeChildren = db
1821
+ .prepare('SELECT name, kind, line, end_line FROM nodes WHERE parent_id = ? ORDER BY line')
1822
+ .all(node.id)
1823
+ .map((c) => ({ name: c.name, kind: c.kind, line: c.line, endLine: c.end_line || null }));
1824
+ } catch {
1825
+ /* parent_id column may not exist */
2397
1826
  }
2398
- console.log();
2399
- }
2400
1827
 
2401
- if (r.callees.length === 0 && r.callers.length === 0 && r.relatedTests.length === 0) {
2402
- console.log(
2403
- ' (no call edges or tests found — may be invoked dynamically or via re-exports)',
2404
- );
2405
- console.log();
2406
- }
1828
+ return {
1829
+ name: node.name,
1830
+ kind: node.kind,
1831
+ file: node.file,
1832
+ line: node.line,
1833
+ role: node.role || null,
1834
+ endLine: node.end_line || null,
1835
+ source,
1836
+ signature,
1837
+ complexity: complexityMetrics,
1838
+ children: nodeChildren.length > 0 ? nodeChildren : undefined,
1839
+ callees,
1840
+ callers,
1841
+ relatedTests,
1842
+ };
1843
+ });
1844
+
1845
+ const base = { name, results };
1846
+ return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
1847
+ } finally {
1848
+ db.close();
2407
1849
  }
2408
1850
  }
2409
1851
 
@@ -2411,62 +1853,42 @@ export function context(name, customDbPath, opts = {}) {
2411
1853
 
2412
1854
  export function childrenData(name, customDbPath, opts = {}) {
2413
1855
  const db = openReadonlyOrFail(customDbPath);
2414
- const noTests = opts.noTests || false;
2415
-
2416
- const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
2417
- if (nodes.length === 0) {
2418
- db.close();
2419
- return { name, results: [] };
2420
- }
1856
+ try {
1857
+ const noTests = opts.noTests || false;
2421
1858
 
2422
- const results = nodes.map((node) => {
2423
- let children;
2424
- try {
2425
- children = db
2426
- .prepare('SELECT name, kind, line, end_line FROM nodes WHERE parent_id = ? ORDER BY line')
2427
- .all(node.id);
2428
- } catch {
2429
- children = [];
1859
+ const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
1860
+ if (nodes.length === 0) {
1861
+ return { name, results: [] };
2430
1862
  }
2431
- if (noTests) children = children.filter((c) => !isTestFile(c.file || node.file));
2432
- return {
2433
- name: node.name,
2434
- kind: node.kind,
2435
- file: node.file,
2436
- line: node.line,
2437
- children: children.map((c) => ({
2438
- name: c.name,
2439
- kind: c.kind,
2440
- line: c.line,
2441
- endLine: c.end_line || null,
2442
- })),
2443
- };
2444
- });
2445
-
2446
- db.close();
2447
- const base = { name, results };
2448
- return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
2449
- }
2450
1863
 
2451
- export function children(name, customDbPath, opts = {}) {
2452
- const data = childrenData(name, customDbPath, opts);
2453
- if (opts.json) {
2454
- console.log(JSON.stringify(data, null, 2));
2455
- return;
2456
- }
2457
- if (data.results.length === 0) {
2458
- console.log(`No symbol matching "${name}"`);
2459
- return;
2460
- }
2461
- for (const r of data.results) {
2462
- console.log(`\n${kindIcon(r.kind)} ${r.name} ${r.file}:${r.line}`);
2463
- if (r.children.length === 0) {
2464
- console.log(' (no children)');
2465
- } else {
2466
- for (const c of r.children) {
2467
- console.log(` ${kindIcon(c.kind)} ${c.name} :${c.line}`);
1864
+ const results = nodes.map((node) => {
1865
+ let children;
1866
+ try {
1867
+ children = db
1868
+ .prepare('SELECT name, kind, line, end_line FROM nodes WHERE parent_id = ? ORDER BY line')
1869
+ .all(node.id);
1870
+ } catch {
1871
+ children = [];
2468
1872
  }
2469
- }
1873
+ if (noTests) children = children.filter((c) => !isTestFile(c.file || node.file));
1874
+ return {
1875
+ name: node.name,
1876
+ kind: node.kind,
1877
+ file: node.file,
1878
+ line: node.line,
1879
+ children: children.map((c) => ({
1880
+ name: c.name,
1881
+ kind: c.kind,
1882
+ line: c.line,
1883
+ endLine: c.end_line || null,
1884
+ })),
1885
+ };
1886
+ });
1887
+
1888
+ const base = { name, results };
1889
+ return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
1890
+ } finally {
1891
+ db.close();
2470
1892
  }
2471
1893
  }
2472
1894
 
@@ -2664,200 +2086,73 @@ function explainFunctionImpl(db, target, noTests, getFileLines) {
2664
2086
 
2665
2087
  export function explainData(target, customDbPath, opts = {}) {
2666
2088
  const db = openReadonlyOrFail(customDbPath);
2667
- const noTests = opts.noTests || false;
2668
- const depth = opts.depth || 0;
2669
- const kind = isFileLikeTarget(target) ? 'file' : 'function';
2670
-
2671
- const dbPath = findDbPath(customDbPath);
2672
- const repoRoot = path.resolve(path.dirname(dbPath), '..');
2673
-
2674
- const fileCache = new Map();
2675
- function getFileLines(file) {
2676
- if (fileCache.has(file)) return fileCache.get(file);
2677
- try {
2678
- const absPath = safePath(repoRoot, file);
2679
- if (!absPath) {
2089
+ try {
2090
+ const noTests = opts.noTests || false;
2091
+ const depth = opts.depth || 0;
2092
+ const kind = isFileLikeTarget(target) ? 'file' : 'function';
2093
+
2094
+ const dbPath = findDbPath(customDbPath);
2095
+ const repoRoot = path.resolve(path.dirname(dbPath), '..');
2096
+
2097
+ const fileCache = new Map();
2098
+ function getFileLines(file) {
2099
+ if (fileCache.has(file)) return fileCache.get(file);
2100
+ try {
2101
+ const absPath = safePath(repoRoot, file);
2102
+ if (!absPath) {
2103
+ fileCache.set(file, null);
2104
+ return null;
2105
+ }
2106
+ const lines = fs.readFileSync(absPath, 'utf-8').split('\n');
2107
+ fileCache.set(file, lines);
2108
+ return lines;
2109
+ } catch (e) {
2110
+ debug(`getFileLines failed for ${file}: ${e.message}`);
2680
2111
  fileCache.set(file, null);
2681
2112
  return null;
2682
2113
  }
2683
- const lines = fs.readFileSync(absPath, 'utf-8').split('\n');
2684
- fileCache.set(file, lines);
2685
- return lines;
2686
- } catch (e) {
2687
- debug(`getFileLines failed for ${file}: ${e.message}`);
2688
- fileCache.set(file, null);
2689
- return null;
2690
2114
  }
2691
- }
2692
2115
 
2693
- const results =
2694
- kind === 'file'
2695
- ? explainFileImpl(db, target, getFileLines)
2696
- : explainFunctionImpl(db, target, noTests, getFileLines);
2697
-
2698
- // Recursive dependency explanation for function targets
2699
- if (kind === 'function' && depth > 0 && results.length > 0) {
2700
- const visited = new Set(results.map((r) => `${r.name}:${r.file}:${r.line}`));
2701
-
2702
- function explainCallees(parentResults, currentDepth) {
2703
- if (currentDepth <= 0) return;
2704
- for (const r of parentResults) {
2705
- const newCallees = [];
2706
- for (const callee of r.callees) {
2707
- const key = `${callee.name}:${callee.file}:${callee.line}`;
2708
- if (visited.has(key)) continue;
2709
- visited.add(key);
2710
- const calleeResults = explainFunctionImpl(db, callee.name, noTests, getFileLines);
2711
- const exact = calleeResults.find(
2712
- (cr) => cr.file === callee.file && cr.line === callee.line,
2713
- );
2714
- if (exact) {
2715
- exact._depth = (r._depth || 0) + 1;
2716
- newCallees.push(exact);
2116
+ const results =
2117
+ kind === 'file'
2118
+ ? explainFileImpl(db, target, getFileLines)
2119
+ : explainFunctionImpl(db, target, noTests, getFileLines);
2120
+
2121
+ // Recursive dependency explanation for function targets
2122
+ if (kind === 'function' && depth > 0 && results.length > 0) {
2123
+ const visited = new Set(results.map((r) => `${r.name}:${r.file}:${r.line}`));
2124
+
2125
+ function explainCallees(parentResults, currentDepth) {
2126
+ if (currentDepth <= 0) return;
2127
+ for (const r of parentResults) {
2128
+ const newCallees = [];
2129
+ for (const callee of r.callees) {
2130
+ const key = `${callee.name}:${callee.file}:${callee.line}`;
2131
+ if (visited.has(key)) continue;
2132
+ visited.add(key);
2133
+ const calleeResults = explainFunctionImpl(db, callee.name, noTests, getFileLines);
2134
+ const exact = calleeResults.find(
2135
+ (cr) => cr.file === callee.file && cr.line === callee.line,
2136
+ );
2137
+ if (exact) {
2138
+ exact._depth = (r._depth || 0) + 1;
2139
+ newCallees.push(exact);
2140
+ }
2141
+ }
2142
+ if (newCallees.length > 0) {
2143
+ r.depDetails = newCallees;
2144
+ explainCallees(newCallees, currentDepth - 1);
2717
2145
  }
2718
2146
  }
2719
- if (newCallees.length > 0) {
2720
- r.depDetails = newCallees;
2721
- explainCallees(newCallees, currentDepth - 1);
2722
- }
2723
- }
2724
- }
2725
-
2726
- explainCallees(results, depth);
2727
- }
2728
-
2729
- db.close();
2730
- const base = { target, kind, results };
2731
- return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
2732
- }
2733
-
2734
- export function explain(target, customDbPath, opts = {}) {
2735
- const data = explainData(target, customDbPath, opts);
2736
- if (opts.ndjson) {
2737
- printNdjson(data, 'results');
2738
- return;
2739
- }
2740
- if (opts.json) {
2741
- console.log(JSON.stringify(data, null, 2));
2742
- return;
2743
- }
2744
- if (data.results.length === 0) {
2745
- console.log(`No ${data.kind === 'file' ? 'file' : 'function/symbol'} matching "${target}"`);
2746
- return;
2747
- }
2748
-
2749
- if (data.kind === 'file') {
2750
- for (const r of data.results) {
2751
- const publicCount = r.publicApi.length;
2752
- const internalCount = r.internal.length;
2753
- const lineInfo = r.lineCount ? `${r.lineCount} lines, ` : '';
2754
- console.log(`\n# ${r.file}`);
2755
- console.log(
2756
- ` ${lineInfo}${r.symbolCount} symbols (${publicCount} exported, ${internalCount} internal)`,
2757
- );
2758
-
2759
- if (r.imports.length > 0) {
2760
- console.log(` Imports: ${r.imports.map((i) => i.file).join(', ')}`);
2761
- }
2762
- if (r.importedBy.length > 0) {
2763
- console.log(` Imported by: ${r.importedBy.map((i) => i.file).join(', ')}`);
2764
- }
2765
-
2766
- if (r.publicApi.length > 0) {
2767
- console.log(`\n## Exported`);
2768
- for (const s of r.publicApi) {
2769
- const sig = s.signature?.params != null ? `(${s.signature.params})` : '';
2770
- const roleTag = s.role ? ` [${s.role}]` : '';
2771
- const summary = s.summary ? ` -- ${s.summary}` : '';
2772
- console.log(` ${kindIcon(s.kind)} ${s.name}${sig}${roleTag} :${s.line}${summary}`);
2773
- }
2774
- }
2775
-
2776
- if (r.internal.length > 0) {
2777
- console.log(`\n## Internal`);
2778
- for (const s of r.internal) {
2779
- const sig = s.signature?.params != null ? `(${s.signature.params})` : '';
2780
- const roleTag = s.role ? ` [${s.role}]` : '';
2781
- const summary = s.summary ? ` -- ${s.summary}` : '';
2782
- console.log(` ${kindIcon(s.kind)} ${s.name}${sig}${roleTag} :${s.line}${summary}`);
2783
- }
2784
- }
2785
-
2786
- if (r.dataFlow.length > 0) {
2787
- console.log(`\n## Data Flow`);
2788
- for (const df of r.dataFlow) {
2789
- console.log(` ${df.caller} -> ${df.callees.join(', ')}`);
2790
- }
2791
- }
2792
- console.log();
2793
- }
2794
- } else {
2795
- function printFunctionExplain(r, indent = '') {
2796
- const lineRange = r.endLine ? `${r.line}-${r.endLine}` : `${r.line}`;
2797
- const lineInfo = r.lineCount ? `${r.lineCount} lines` : '';
2798
- const summaryPart = r.summary ? ` | ${r.summary}` : '';
2799
- const roleTag = r.role ? ` [${r.role}]` : '';
2800
- const depthLevel = r._depth || 0;
2801
- const heading = depthLevel === 0 ? '#' : '##'.padEnd(depthLevel + 2, '#');
2802
- console.log(`\n${indent}${heading} ${r.name} (${r.kind})${roleTag} ${r.file}:${lineRange}`);
2803
- if (lineInfo || r.summary) {
2804
- console.log(`${indent} ${lineInfo}${summaryPart}`);
2805
- }
2806
- if (r.signature) {
2807
- if (r.signature.params != null)
2808
- console.log(`${indent} Parameters: (${r.signature.params})`);
2809
- if (r.signature.returnType) console.log(`${indent} Returns: ${r.signature.returnType}`);
2810
- }
2811
-
2812
- if (r.complexity) {
2813
- const cx = r.complexity;
2814
- const miPart = cx.maintainabilityIndex ? ` MI=${cx.maintainabilityIndex}` : '';
2815
- console.log(
2816
- `${indent} Complexity: cognitive=${cx.cognitive} cyclomatic=${cx.cyclomatic} nesting=${cx.maxNesting}${miPart}`,
2817
- );
2818
- }
2819
-
2820
- if (r.callees.length > 0) {
2821
- console.log(`\n${indent} Calls (${r.callees.length}):`);
2822
- for (const c of r.callees) {
2823
- console.log(`${indent} ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`);
2824
- }
2825
- }
2826
-
2827
- if (r.callers.length > 0) {
2828
- console.log(`\n${indent} Called by (${r.callers.length}):`);
2829
- for (const c of r.callers) {
2830
- console.log(`${indent} ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`);
2831
- }
2832
- }
2833
-
2834
- if (r.relatedTests.length > 0) {
2835
- const label = r.relatedTests.length === 1 ? 'file' : 'files';
2836
- console.log(`\n${indent} Tests (${r.relatedTests.length} ${label}):`);
2837
- for (const t of r.relatedTests) {
2838
- console.log(`${indent} ${t.file}`);
2839
- }
2840
- }
2841
-
2842
- if (r.callees.length === 0 && r.callers.length === 0) {
2843
- console.log(
2844
- `${indent} (no call edges found -- may be invoked dynamically or via re-exports)`,
2845
- );
2846
2147
  }
2847
2148
 
2848
- // Render recursive dependency details
2849
- if (r.depDetails && r.depDetails.length > 0) {
2850
- console.log(`\n${indent} --- Dependencies (depth ${depthLevel + 1}) ---`);
2851
- for (const dep of r.depDetails) {
2852
- printFunctionExplain(dep, `${indent} `);
2853
- }
2854
- }
2855
- console.log();
2149
+ explainCallees(results, depth);
2856
2150
  }
2857
2151
 
2858
- for (const r of data.results) {
2859
- printFunctionExplain(r);
2860
- }
2152
+ const base = { target, kind, results };
2153
+ return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
2154
+ } finally {
2155
+ db.close();
2861
2156
  }
2862
2157
  }
2863
2158
 
@@ -2987,148 +2282,59 @@ function whereFileImpl(db, target) {
2987
2282
 
2988
2283
  export function whereData(target, customDbPath, opts = {}) {
2989
2284
  const db = openReadonlyOrFail(customDbPath);
2990
- const noTests = opts.noTests || false;
2991
- const fileMode = opts.file || false;
2992
-
2993
- const results = fileMode ? whereFileImpl(db, target) : whereSymbolImpl(db, target, noTests);
2994
-
2995
- db.close();
2996
- const base = { target, mode: fileMode ? 'file' : 'symbol', results };
2997
- return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
2998
- }
2999
-
3000
- export function where(target, customDbPath, opts = {}) {
3001
- const data = whereData(target, customDbPath, opts);
3002
- if (opts.ndjson) {
3003
- printNdjson(data, 'results');
3004
- return;
3005
- }
3006
- if (opts.json) {
3007
- console.log(JSON.stringify(data, null, 2));
3008
- return;
3009
- }
2285
+ try {
2286
+ const noTests = opts.noTests || false;
2287
+ const fileMode = opts.file || false;
3010
2288
 
3011
- if (data.results.length === 0) {
3012
- console.log(
3013
- data.mode === 'file'
3014
- ? `No file matching "${target}" in graph`
3015
- : `No symbol matching "${target}" in graph`,
3016
- );
3017
- return;
3018
- }
2289
+ const results = fileMode ? whereFileImpl(db, target) : whereSymbolImpl(db, target, noTests);
3019
2290
 
3020
- if (data.mode === 'symbol') {
3021
- for (const r of data.results) {
3022
- const roleTag = r.role ? ` [${r.role}]` : '';
3023
- const tag = r.exported ? ' (exported)' : '';
3024
- console.log(`\n${kindIcon(r.kind)} ${r.name}${roleTag} ${r.file}:${r.line}${tag}`);
3025
- if (r.uses.length > 0) {
3026
- const useStrs = r.uses.map((u) => `${u.file}:${u.line}`);
3027
- console.log(` Used in: ${useStrs.join(', ')}`);
3028
- } else {
3029
- console.log(' No uses found');
3030
- }
3031
- }
3032
- } else {
3033
- for (const r of data.results) {
3034
- console.log(`\n# ${r.file}`);
3035
- if (r.symbols.length > 0) {
3036
- const symStrs = r.symbols.map((s) => `${s.name}:${s.line}`);
3037
- console.log(` Symbols: ${symStrs.join(', ')}`);
3038
- }
3039
- if (r.imports.length > 0) {
3040
- console.log(` Imports: ${r.imports.join(', ')}`);
3041
- }
3042
- if (r.importedBy.length > 0) {
3043
- console.log(` Imported by: ${r.importedBy.join(', ')}`);
3044
- }
3045
- if (r.exported.length > 0) {
3046
- console.log(` Exported: ${r.exported.join(', ')}`);
3047
- }
3048
- }
2291
+ const base = { target, mode: fileMode ? 'file' : 'symbol', results };
2292
+ return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
2293
+ } finally {
2294
+ db.close();
3049
2295
  }
3050
- console.log();
3051
2296
  }
3052
2297
 
3053
2298
  // ─── rolesData ──────────────────────────────────────────────────────────
3054
2299
 
3055
2300
  export function rolesData(customDbPath, opts = {}) {
3056
2301
  const db = openReadonlyOrFail(customDbPath);
3057
- const noTests = opts.noTests || false;
3058
- const filterRole = opts.role || null;
3059
- const filterFile = opts.file || null;
3060
-
3061
- const conditions = ['role IS NOT NULL'];
3062
- const params = [];
3063
-
3064
- if (filterRole) {
3065
- conditions.push('role = ?');
3066
- params.push(filterRole);
3067
- }
3068
- if (filterFile) {
3069
- conditions.push('file LIKE ?');
3070
- params.push(`%${filterFile}%`);
3071
- }
3072
-
3073
- let rows = db
3074
- .prepare(
3075
- `SELECT name, kind, file, line, end_line, role FROM nodes WHERE ${conditions.join(' AND ')} ORDER BY role, file, line`,
3076
- )
3077
- .all(...params);
3078
-
3079
- if (noTests) rows = rows.filter((r) => !isTestFile(r.file));
3080
-
3081
- const summary = {};
3082
- for (const r of rows) {
3083
- summary[r.role] = (summary[r.role] || 0) + 1;
3084
- }
3085
-
3086
- const hc = new Map();
3087
- const symbols = rows.map((r) => normalizeSymbol(r, db, hc));
3088
- db.close();
3089
- const base = { count: symbols.length, summary, symbols };
3090
- return paginateResult(base, 'symbols', { limit: opts.limit, offset: opts.offset });
3091
- }
3092
-
3093
- export function roles(customDbPath, opts = {}) {
3094
- const data = rolesData(customDbPath, opts);
3095
- if (opts.ndjson) {
3096
- printNdjson(data, 'symbols');
3097
- return;
3098
- }
3099
- if (opts.json) {
3100
- console.log(JSON.stringify(data, null, 2));
3101
- return;
3102
- }
2302
+ try {
2303
+ const noTests = opts.noTests || false;
2304
+ const filterRole = opts.role || null;
2305
+ const filterFile = opts.file || null;
3103
2306
 
3104
- if (data.count === 0) {
3105
- console.log('No classified symbols found. Run "codegraph build" first.');
3106
- return;
3107
- }
2307
+ const conditions = ['role IS NOT NULL'];
2308
+ const params = [];
3108
2309
 
3109
- const total = data.count;
3110
- console.log(`\nNode roles (${total} symbols):\n`);
2310
+ if (filterRole) {
2311
+ conditions.push('role = ?');
2312
+ params.push(filterRole);
2313
+ }
2314
+ if (filterFile) {
2315
+ conditions.push('file LIKE ?');
2316
+ params.push(`%${filterFile}%`);
2317
+ }
3111
2318
 
3112
- const summaryParts = Object.entries(data.summary)
3113
- .sort((a, b) => b[1] - a[1])
3114
- .map(([role, count]) => `${role}: ${count}`);
3115
- console.log(` ${summaryParts.join(' ')}\n`);
2319
+ let rows = db
2320
+ .prepare(
2321
+ `SELECT name, kind, file, line, end_line, role FROM nodes WHERE ${conditions.join(' AND ')} ORDER BY role, file, line`,
2322
+ )
2323
+ .all(...params);
3116
2324
 
3117
- const byRole = {};
3118
- for (const s of data.symbols) {
3119
- if (!byRole[s.role]) byRole[s.role] = [];
3120
- byRole[s.role].push(s);
3121
- }
2325
+ if (noTests) rows = rows.filter((r) => !isTestFile(r.file));
3122
2326
 
3123
- for (const [role, symbols] of Object.entries(byRole)) {
3124
- console.log(`## ${role} (${symbols.length})`);
3125
- for (const s of symbols.slice(0, 30)) {
3126
- console.log(` ${kindIcon(s.kind)} ${s.name} ${s.file}:${s.line}`);
2327
+ const summary = {};
2328
+ for (const r of rows) {
2329
+ summary[r.role] = (summary[r.role] || 0) + 1;
3127
2330
  }
3128
- if (symbols.length > 30) {
3129
- console.log(` ... and ${symbols.length - 30} more`);
3130
- }
3131
- console.log();
2331
+
2332
+ const hc = new Map();
2333
+ const symbols = rows.map((r) => normalizeSymbol(r, db, hc));
2334
+ const base = { count: symbols.length, summary, symbols };
2335
+ return paginateResult(base, 'symbols', { limit: opts.limit, offset: opts.offset });
2336
+ } finally {
2337
+ db.close();
3132
2338
  }
3133
2339
  }
3134
2340
 
@@ -3232,203 +2438,53 @@ function exportsFileImpl(db, target, noTests, getFileLines, unused) {
3232
2438
 
3233
2439
  export function exportsData(file, customDbPath, opts = {}) {
3234
2440
  const db = openReadonlyOrFail(customDbPath);
3235
- const noTests = opts.noTests || false;
3236
-
3237
- const dbFilePath = findDbPath(customDbPath);
3238
- const repoRoot = path.resolve(path.dirname(dbFilePath), '..');
2441
+ try {
2442
+ const noTests = opts.noTests || false;
3239
2443
 
3240
- const fileCache = new Map();
3241
- function getFileLines(file) {
3242
- if (fileCache.has(file)) return fileCache.get(file);
3243
- try {
3244
- const absPath = safePath(repoRoot, file);
3245
- if (!absPath) {
2444
+ const dbFilePath = findDbPath(customDbPath);
2445
+ const repoRoot = path.resolve(path.dirname(dbFilePath), '..');
2446
+
2447
+ const fileCache = new Map();
2448
+ function getFileLines(file) {
2449
+ if (fileCache.has(file)) return fileCache.get(file);
2450
+ try {
2451
+ const absPath = safePath(repoRoot, file);
2452
+ if (!absPath) {
2453
+ fileCache.set(file, null);
2454
+ return null;
2455
+ }
2456
+ const lines = fs.readFileSync(absPath, 'utf-8').split('\n');
2457
+ fileCache.set(file, lines);
2458
+ return lines;
2459
+ } catch {
3246
2460
  fileCache.set(file, null);
3247
2461
  return null;
3248
2462
  }
3249
- const lines = fs.readFileSync(absPath, 'utf-8').split('\n');
3250
- fileCache.set(file, lines);
3251
- return lines;
3252
- } catch {
3253
- fileCache.set(file, null);
3254
- return null;
3255
- }
3256
- }
3257
-
3258
- const unused = opts.unused || false;
3259
- const fileResults = exportsFileImpl(db, file, noTests, getFileLines, unused);
3260
- db.close();
3261
-
3262
- if (fileResults.length === 0) {
3263
- return paginateResult(
3264
- { file, results: [], reexports: [], totalExported: 0, totalInternal: 0, totalUnused: 0 },
3265
- 'results',
3266
- { limit: opts.limit, offset: opts.offset },
3267
- );
3268
- }
3269
-
3270
- // For single-file match return flat; for multi-match return first (like explainData)
3271
- const first = fileResults[0];
3272
- const base = {
3273
- file: first.file,
3274
- results: first.results,
3275
- reexports: first.reexports,
3276
- totalExported: first.totalExported,
3277
- totalInternal: first.totalInternal,
3278
- totalUnused: first.totalUnused,
3279
- };
3280
- return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
3281
- }
3282
-
3283
- export function fileExports(file, customDbPath, opts = {}) {
3284
- const data = exportsData(file, customDbPath, opts);
3285
- if (opts.ndjson) {
3286
- printNdjson(data, 'results');
3287
- return;
3288
- }
3289
- if (opts.json) {
3290
- console.log(JSON.stringify(data, null, 2));
3291
- return;
3292
- }
3293
-
3294
- if (data.results.length === 0) {
3295
- if (opts.unused) {
3296
- console.log(`No unused exports found for "${file}".`);
3297
- } else {
3298
- console.log(`No exported symbols found for "${file}". Run "codegraph build" first.`);
3299
- }
3300
- return;
3301
- }
3302
-
3303
- if (opts.unused) {
3304
- console.log(
3305
- `\n# ${data.file} — ${data.totalUnused} unused export${data.totalUnused !== 1 ? 's' : ''} (of ${data.totalExported} exported)\n`,
3306
- );
3307
- } else {
3308
- const unusedNote = data.totalUnused > 0 ? ` (${data.totalUnused} unused)` : '';
3309
- console.log(
3310
- `\n# ${data.file} — ${data.totalExported} exported${unusedNote}, ${data.totalInternal} internal\n`,
3311
- );
3312
- }
3313
-
3314
- for (const sym of data.results) {
3315
- const icon = kindIcon(sym.kind);
3316
- const sig = sym.signature?.params ? `(${sym.signature.params})` : '';
3317
- const role = sym.role ? ` [${sym.role}]` : '';
3318
- console.log(` ${icon} ${sym.name}${sig}${role} :${sym.line}`);
3319
- if (sym.consumers.length === 0) {
3320
- console.log(' (no consumers)');
3321
- } else {
3322
- for (const c of sym.consumers) {
3323
- console.log(` <- ${c.name} (${c.file}:${c.line})`);
3324
- }
3325
- }
3326
- }
3327
-
3328
- if (data.reexports.length > 0) {
3329
- console.log(`\n Re-exports: ${data.reexports.map((r) => r.file).join(', ')}`);
3330
- }
3331
- console.log();
3332
- }
3333
-
3334
- export function fnImpact(name, customDbPath, opts = {}) {
3335
- const data = fnImpactData(name, customDbPath, opts);
3336
- if (opts.ndjson) {
3337
- printNdjson(data, 'results');
3338
- return;
3339
- }
3340
- if (opts.json) {
3341
- console.log(JSON.stringify(data, null, 2));
3342
- return;
3343
- }
3344
- if (data.results.length === 0) {
3345
- console.log(`No function/method/class matching "${name}"`);
3346
- return;
3347
- }
3348
-
3349
- for (const r of data.results) {
3350
- console.log(`\nFunction impact: ${kindIcon(r.kind)} ${r.name} -- ${r.file}:${r.line}\n`);
3351
- if (Object.keys(r.levels).length === 0) {
3352
- console.log(` No callers found.`);
3353
- } else {
3354
- for (const [level, fns] of Object.entries(r.levels).sort((a, b) => a[0] - b[0])) {
3355
- const l = parseInt(level, 10);
3356
- console.log(` ${'--'.repeat(l)} Level ${level} (${fns.length} functions):`);
3357
- for (const f of fns.slice(0, 20))
3358
- console.log(` ${' '.repeat(l)}^ ${kindIcon(f.kind)} ${f.name} ${f.file}:${f.line}`);
3359
- if (fns.length > 20) console.log(` ... and ${fns.length - 20} more`);
3360
- }
3361
2463
  }
3362
- console.log(`\n Total: ${r.totalDependents} functions transitively depend on ${r.name}\n`);
3363
- }
3364
- }
3365
2464
 
3366
- export function diffImpact(customDbPath, opts = {}) {
3367
- if (opts.format === 'mermaid') {
3368
- console.log(diffImpactMermaid(customDbPath, opts));
3369
- return;
3370
- }
3371
- const data = diffImpactData(customDbPath, opts);
3372
- if (opts.ndjson) {
3373
- printNdjson(data, 'affectedFunctions');
3374
- return;
3375
- }
3376
- if (opts.json || opts.format === 'json') {
3377
- console.log(JSON.stringify(data, null, 2));
3378
- return;
3379
- }
3380
- if (data.error) {
3381
- console.log(data.error);
3382
- return;
3383
- }
3384
- if (data.changedFiles === 0) {
3385
- console.log('No changes detected.');
3386
- return;
3387
- }
3388
- if (data.affectedFunctions.length === 0) {
3389
- console.log(
3390
- ' No function-level changes detected (changes may be in imports, types, or config).',
3391
- );
3392
- return;
3393
- }
2465
+ const unused = opts.unused || false;
2466
+ const fileResults = exportsFileImpl(db, file, noTests, getFileLines, unused);
3394
2467
 
3395
- console.log(`\ndiff-impact: ${data.changedFiles} files changed\n`);
3396
- console.log(` ${data.affectedFunctions.length} functions changed:\n`);
3397
- for (const fn of data.affectedFunctions) {
3398
- console.log(` ${kindIcon(fn.kind)} ${fn.name} -- ${fn.file}:${fn.line}`);
3399
- if (fn.transitiveCallers > 0) console.log(` ^ ${fn.transitiveCallers} transitive callers`);
3400
- }
3401
- if (data.historicallyCoupled && data.historicallyCoupled.length > 0) {
3402
- console.log('\n Historically coupled (not in static graph):\n');
3403
- for (const c of data.historicallyCoupled) {
3404
- const pct = `${(c.jaccard * 100).toFixed(0)}%`;
3405
- console.log(
3406
- ` ${c.file} <- coupled with ${c.coupledWith} (${pct}, ${c.commitCount} commits)`,
2468
+ if (fileResults.length === 0) {
2469
+ return paginateResult(
2470
+ { file, results: [], reexports: [], totalExported: 0, totalInternal: 0, totalUnused: 0 },
2471
+ 'results',
2472
+ { limit: opts.limit, offset: opts.offset },
3407
2473
  );
3408
2474
  }
3409
- }
3410
- if (data.ownership) {
3411
- console.log(`\n Affected owners: ${data.ownership.affectedOwners.join(', ')}`);
3412
- console.log(` Suggested reviewers: ${data.ownership.suggestedReviewers.join(', ')}`);
3413
- }
3414
- if (data.boundaryViolations && data.boundaryViolations.length > 0) {
3415
- console.log(`\n Boundary violations (${data.boundaryViolationCount}):\n`);
3416
- for (const v of data.boundaryViolations) {
3417
- console.log(` [${v.name}] ${v.file} -> ${v.targetFile}`);
3418
- if (v.message) console.log(` ${v.message}`);
3419
- }
3420
- }
3421
- if (data.summary) {
3422
- let summaryLine = `\n Summary: ${data.summary.functionsChanged} functions changed -> ${data.summary.callersAffected} callers affected across ${data.summary.filesAffected} files`;
3423
- if (data.summary.historicallyCoupledCount > 0) {
3424
- summaryLine += `, ${data.summary.historicallyCoupledCount} historically coupled`;
3425
- }
3426
- if (data.summary.ownersAffected > 0) {
3427
- summaryLine += `, ${data.summary.ownersAffected} owners affected`;
3428
- }
3429
- if (data.summary.boundaryViolationCount > 0) {
3430
- summaryLine += `, ${data.summary.boundaryViolationCount} boundary violations`;
3431
- }
3432
- console.log(`${summaryLine}\n`);
2475
+
2476
+ // For single-file match return flat; for multi-match return first (like explainData)
2477
+ const first = fileResults[0];
2478
+ const base = {
2479
+ file: first.file,
2480
+ results: first.results,
2481
+ reexports: first.reexports,
2482
+ totalExported: first.totalExported,
2483
+ totalInternal: first.totalInternal,
2484
+ totalUnused: first.totalUnused,
2485
+ };
2486
+ return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
2487
+ } finally {
2488
+ db.close();
3433
2489
  }
3434
2490
  }