@optave/codegraph 3.1.0 → 3.1.2

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 (83) 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/engine.js +365 -0
  5. package/src/ast-analysis/metrics.js +118 -0
  6. package/src/ast-analysis/rules/csharp.js +201 -0
  7. package/src/ast-analysis/rules/go.js +182 -0
  8. package/src/ast-analysis/rules/index.js +82 -0
  9. package/src/ast-analysis/rules/java.js +175 -0
  10. package/src/ast-analysis/rules/javascript.js +246 -0
  11. package/src/ast-analysis/rules/php.js +219 -0
  12. package/src/ast-analysis/rules/python.js +196 -0
  13. package/src/ast-analysis/rules/ruby.js +204 -0
  14. package/src/ast-analysis/rules/rust.js +173 -0
  15. package/src/ast-analysis/shared.js +223 -0
  16. package/src/ast-analysis/visitor-utils.js +176 -0
  17. package/src/ast-analysis/visitor.js +162 -0
  18. package/src/ast-analysis/visitors/ast-store-visitor.js +150 -0
  19. package/src/ast-analysis/visitors/cfg-visitor.js +792 -0
  20. package/src/ast-analysis/visitors/complexity-visitor.js +243 -0
  21. package/src/ast-analysis/visitors/dataflow-visitor.js +358 -0
  22. package/src/ast.js +26 -166
  23. package/src/audit.js +2 -88
  24. package/src/batch.js +0 -25
  25. package/src/boundaries.js +1 -1
  26. package/src/branch-compare.js +82 -172
  27. package/src/builder.js +48 -184
  28. package/src/cfg.js +148 -1174
  29. package/src/check.js +1 -84
  30. package/src/cli.js +118 -197
  31. package/src/cochange.js +1 -39
  32. package/src/commands/audit.js +88 -0
  33. package/src/commands/batch.js +26 -0
  34. package/src/commands/branch-compare.js +97 -0
  35. package/src/commands/cfg.js +55 -0
  36. package/src/commands/check.js +82 -0
  37. package/src/commands/cochange.js +37 -0
  38. package/src/commands/communities.js +69 -0
  39. package/src/commands/complexity.js +77 -0
  40. package/src/commands/dataflow.js +110 -0
  41. package/src/commands/flow.js +70 -0
  42. package/src/commands/manifesto.js +77 -0
  43. package/src/commands/owners.js +52 -0
  44. package/src/commands/query.js +21 -0
  45. package/src/commands/sequence.js +33 -0
  46. package/src/commands/structure.js +64 -0
  47. package/src/commands/triage.js +49 -0
  48. package/src/communities.js +22 -96
  49. package/src/complexity.js +234 -1591
  50. package/src/cycles.js +1 -1
  51. package/src/dataflow.js +274 -1352
  52. package/src/db/connection.js +88 -0
  53. package/src/db/migrations.js +312 -0
  54. package/src/db/query-builder.js +280 -0
  55. package/src/db/repository/build-stmts.js +104 -0
  56. package/src/db/repository/cfg.js +83 -0
  57. package/src/db/repository/cochange.js +41 -0
  58. package/src/db/repository/complexity.js +15 -0
  59. package/src/db/repository/dataflow.js +12 -0
  60. package/src/db/repository/edges.js +259 -0
  61. package/src/db/repository/embeddings.js +40 -0
  62. package/src/db/repository/graph-read.js +39 -0
  63. package/src/db/repository/index.js +42 -0
  64. package/src/db/repository/nodes.js +236 -0
  65. package/src/db.js +58 -399
  66. package/src/embedder.js +158 -174
  67. package/src/export.js +1 -1
  68. package/src/extractors/javascript.js +130 -5
  69. package/src/flow.js +153 -222
  70. package/src/index.js +53 -16
  71. package/src/infrastructure/result-formatter.js +21 -0
  72. package/src/infrastructure/test-filter.js +7 -0
  73. package/src/kinds.js +50 -0
  74. package/src/manifesto.js +1 -82
  75. package/src/mcp.js +37 -20
  76. package/src/owners.js +127 -182
  77. package/src/queries-cli.js +866 -0
  78. package/src/queries.js +1271 -2416
  79. package/src/sequence.js +179 -223
  80. package/src/structure.js +211 -269
  81. package/src/triage.js +117 -212
  82. package/src/viewer.js +1 -1
  83. package/src/watcher.js +7 -4
package/src/queries.js CHANGED
@@ -5,12 +5,41 @@ 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
+ countCrossFileCallers,
10
+ findAllIncomingEdges,
11
+ findAllOutgoingEdges,
12
+ findCallees,
13
+ findCallers,
14
+ findCrossFileCallTargets,
15
+ findDbPath,
16
+ findDistinctCallers,
17
+ findFileNodes,
18
+ findImportDependents,
19
+ findImportSources,
20
+ findImportTargets,
21
+ findIntraFileCallEdges,
22
+ findNodeById,
23
+ findNodeChildren,
24
+ findNodesByFile,
25
+ findNodesWithFanIn,
26
+ getClassHierarchy,
27
+ getComplexityForNode,
28
+ iterateFunctionNodes,
29
+ listFunctionNodes,
30
+ openReadonlyOrFail,
31
+ testFilterSQL,
32
+ } from './db.js';
33
+ import { isTestFile } from './infrastructure/test-filter.js';
34
+ import { ALL_SYMBOL_KINDS } from './kinds.js';
9
35
  import { debug } from './logger.js';
10
36
  import { ownersForFiles } from './owners.js';
11
- import { paginateResult, printNdjson } from './paginate.js';
37
+ import { paginateResult } from './paginate.js';
12
38
  import { LANGUAGE_REGISTRY } from './parser.js';
13
39
 
40
+ // Re-export from dedicated module for backward compat
41
+ export { isTestFile, TEST_PATTERN } from './infrastructure/test-filter.js';
42
+
14
43
  /**
15
44
  * Resolve a file path relative to repoRoot, rejecting traversal outside the repo.
16
45
  * Returns null if the resolved path escapes repoRoot.
@@ -21,11 +50,6 @@ function safePath(repoRoot, file) {
21
50
  return resolved;
22
51
  }
23
52
 
24
- const TEST_PATTERN = /\.(test|spec)\.|__test__|__tests__|\.stories\./;
25
- export function isTestFile(filePath) {
26
- return TEST_PATTERN.test(filePath);
27
- }
28
-
29
53
  export const FALSE_POSITIVE_NAMES = new Set([
30
54
  'run',
31
55
  'get',
@@ -60,78 +84,17 @@ export const FALSE_POSITIVE_CALLER_THRESHOLD = 20;
60
84
 
61
85
  const FUNCTION_KINDS = ['function', 'method', 'class'];
62
86
 
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'];
111
-
112
- /**
113
- * Get all ancestor class names for a given class using extends edges.
114
- */
115
- function getClassHierarchy(db, classNodeId) {
116
- const ancestors = new Set();
117
- const queue = [classNodeId];
118
- while (queue.length > 0) {
119
- const current = queue.shift();
120
- const parents = db
121
- .prepare(`
122
- SELECT n.id, n.name FROM edges e JOIN nodes n ON e.target_id = n.id
123
- WHERE e.source_id = ? AND e.kind = 'extends'
124
- `)
125
- .all(current);
126
- for (const p of parents) {
127
- if (!ancestors.has(p.id)) {
128
- ancestors.add(p.id);
129
- queue.push(p.id);
130
- }
131
- }
132
- }
133
- return ancestors;
134
- }
87
+ // Re-export kind/edge constants from kinds.js (canonical source)
88
+ export {
89
+ ALL_SYMBOL_KINDS,
90
+ CORE_EDGE_KINDS,
91
+ CORE_SYMBOL_KINDS,
92
+ EVERY_EDGE_KIND,
93
+ EVERY_SYMBOL_KIND,
94
+ EXTENDED_SYMBOL_KINDS,
95
+ STRUCTURAL_EDGE_KINDS,
96
+ VALID_ROLES,
97
+ } from './kinds.js';
135
98
 
136
99
  function resolveMethodViaHierarchy(db, methodName) {
137
100
  const methods = db
@@ -164,26 +127,9 @@ function resolveMethodViaHierarchy(db, methodName) {
164
127
  * Scoring: exact=100, prefix=60, word-boundary=40, substring=10, plus fan-in tiebreaker.
165
128
  */
166
129
  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
- }
130
+ const kinds = opts.kind ? [opts.kind] : opts.kinds?.length ? opts.kinds : FUNCTION_KINDS;
176
131
 
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);
132
+ const rows = findNodesWithFanIn(db, `%${name}%`, { kinds, file: opts.file });
187
133
 
188
134
  const nodes = opts.noTests ? rows.filter((n) => !isTestFile(n.file)) : rows;
189
135
 
@@ -240,859 +186,744 @@ export function kindIcon(kind) {
240
186
 
241
187
  export function queryNameData(name, customDbPath, opts = {}) {
242
188
  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
- }
189
+ try {
190
+ const noTests = opts.noTests || false;
191
+ let nodes = db.prepare(`SELECT * FROM nodes WHERE name LIKE ?`).all(`%${name}%`);
192
+ if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
193
+ if (nodes.length === 0) {
194
+ return { query: name, results: [] };
195
+ }
250
196
 
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);
197
+ const hc = new Map();
198
+ const results = nodes.map((node) => {
199
+ let callees = findAllOutgoingEdges(db, node.id);
260
200
 
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);
201
+ let callers = findAllIncomingEdges(db, node.id);
268
202
 
269
- if (noTests) {
270
- callees = callees.filter((c) => !isTestFile(c.file));
271
- callers = callers.filter((c) => !isTestFile(c.file));
272
- }
203
+ if (noTests) {
204
+ callees = callees.filter((c) => !isTestFile(c.file));
205
+ callers = callers.filter((c) => !isTestFile(c.file));
206
+ }
273
207
 
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
- });
208
+ return {
209
+ ...normalizeSymbol(node, db, hc),
210
+ callees: callees.map((c) => ({
211
+ name: c.name,
212
+ kind: c.kind,
213
+ file: c.file,
214
+ line: c.line,
215
+ edgeKind: c.edge_kind,
216
+ })),
217
+ callers: callers.map((c) => ({
218
+ name: c.name,
219
+ kind: c.kind,
220
+ file: c.file,
221
+ line: c.line,
222
+ edgeKind: c.edge_kind,
223
+ })),
224
+ };
225
+ });
292
226
 
293
- db.close();
294
- const base = { query: name, results };
295
- return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
227
+ const base = { query: name, results };
228
+ return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
229
+ } finally {
230
+ db.close();
231
+ }
296
232
  }
297
233
 
298
234
  export function impactAnalysisData(file, customDbPath, opts = {}) {
299
235
  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) {
236
+ try {
237
+ const noTests = opts.noTests || false;
238
+ const fileNodes = findFileNodes(db, `%${file}%`);
239
+ if (fileNodes.length === 0) {
240
+ return { file, sources: [], levels: {}, totalDependents: 0 };
241
+ }
242
+
243
+ const visited = new Set();
244
+ const queue = [];
245
+ const levels = new Map();
246
+
247
+ for (const fn of fileNodes) {
248
+ visited.add(fn.id);
249
+ queue.push(fn.id);
250
+ levels.set(fn.id, 0);
251
+ }
252
+
253
+ while (queue.length > 0) {
254
+ const current = queue.shift();
255
+ const level = levels.get(current);
256
+ const dependents = findImportDependents(db, current);
257
+ for (const dep of dependents) {
258
+ if (!visited.has(dep.id) && (!noTests || !isTestFile(dep.file))) {
259
+ visited.add(dep.id);
260
+ queue.push(dep.id);
261
+ levels.set(dep.id, level + 1);
262
+ }
263
+ }
264
+ }
265
+
266
+ const byLevel = {};
267
+ for (const [id, level] of levels) {
268
+ if (level === 0) continue;
269
+ if (!byLevel[level]) byLevel[level] = [];
270
+ const node = findNodeById(db, id);
271
+ if (node) byLevel[level].push({ file: node.file });
272
+ }
273
+
274
+ return {
275
+ file,
276
+ sources: fileNodes.map((f) => f.file),
277
+ levels: byLevel,
278
+ totalDependents: visited.size - fileNodes.length,
279
+ };
280
+ } finally {
305
281
  db.close();
306
- return { file, sources: [], levels: {}, totalDependents: 0 };
307
282
  }
283
+ }
308
284
 
309
- const visited = new Set();
310
- const queue = [];
311
- const levels = new Map();
285
+ export function moduleMapData(customDbPath, limit = 20, opts = {}) {
286
+ const db = openReadonlyOrFail(customDbPath);
287
+ try {
288
+ const noTests = opts.noTests || false;
312
289
 
313
- for (const fn of fileNodes) {
314
- visited.add(fn.id);
315
- queue.push(fn.id);
316
- levels.set(fn.id, 0);
317
- }
290
+ const testFilter = testFilterSQL('n.file', noTests);
318
291
 
319
- while (queue.length > 0) {
320
- const current = queue.shift();
321
- const level = levels.get(current);
322
- const dependents = db
292
+ const nodes = db
323
293
  .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')
294
+ SELECT n.*,
295
+ (SELECT COUNT(*) FROM edges WHERE source_id = n.id AND kind NOT IN ('contains', 'parameter_of', 'receiver')) as out_edges,
296
+ (SELECT COUNT(*) FROM edges WHERE target_id = n.id AND kind NOT IN ('contains', 'parameter_of', 'receiver')) as in_edges
297
+ FROM nodes n
298
+ WHERE n.kind = 'file'
299
+ ${testFilter}
300
+ ORDER BY (SELECT COUNT(*) FROM edges WHERE target_id = n.id AND kind NOT IN ('contains', 'parameter_of', 'receiver')) DESC
301
+ LIMIT ?
326
302
  `)
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);
333
- }
334
- }
335
- }
336
-
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
- }
303
+ .all(limit);
304
+
305
+ const topNodes = nodes.map((n) => ({
306
+ file: n.file,
307
+ dir: path.dirname(n.file) || '.',
308
+ inEdges: n.in_edges,
309
+ outEdges: n.out_edges,
310
+ coupling: n.in_edges + n.out_edges,
311
+ }));
344
312
 
345
- db.close();
346
- return {
347
- file,
348
- sources: fileNodes.map((f) => f.file),
349
- levels: byLevel,
350
- totalDependents: visited.size - fileNodes.length,
351
- };
352
- }
313
+ const totalNodes = db.prepare('SELECT COUNT(*) as c FROM nodes').get().c;
314
+ const totalEdges = db.prepare('SELECT COUNT(*) as c FROM edges').get().c;
315
+ const totalFiles = db.prepare("SELECT COUNT(*) as c FROM nodes WHERE kind = 'file'").get().c;
353
316
 
354
- export function moduleMapData(customDbPath, limit = 20, opts = {}) {
355
- 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 } };
317
+ return { limit, topNodes, stats: { totalFiles, totalNodes, totalEdges } };
318
+ } finally {
319
+ db.close();
320
+ }
393
321
  }
394
322
 
395
323
  export function fileDepsData(file, customDbPath, opts = {}) {
396
324
  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
- }
325
+ try {
326
+ const noTests = opts.noTests || false;
327
+ const fileNodes = findFileNodes(db, `%${file}%`);
328
+ if (fileNodes.length === 0) {
329
+ return { file, results: [] };
330
+ }
405
331
 
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));
332
+ const results = fileNodes.map((fn) => {
333
+ let importsTo = findImportTargets(db, fn.id);
334
+ if (noTests) importsTo = importsTo.filter((i) => !isTestFile(i.file));
414
335
 
415
- let importedBy = db
416
- .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')
419
- `)
420
- .all(fn.id);
421
- if (noTests) importedBy = importedBy.filter((i) => !isTestFile(i.file));
336
+ let importedBy = findImportSources(db, fn.id);
337
+ if (noTests) importedBy = importedBy.filter((i) => !isTestFile(i.file));
422
338
 
423
- const defs = db
424
- .prepare(`SELECT * FROM nodes WHERE file = ? AND kind != 'file' ORDER BY line`)
425
- .all(fn.file);
339
+ const defs = findNodesByFile(db, fn.file);
426
340
 
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
- });
341
+ return {
342
+ file: fn.file,
343
+ imports: importsTo.map((i) => ({ file: i.file, typeOnly: i.edge_kind === 'imports-type' })),
344
+ importedBy: importedBy.map((i) => ({ file: i.file })),
345
+ definitions: defs.map((d) => ({ name: d.name, kind: d.kind, line: d.line })),
346
+ };
347
+ });
434
348
 
435
- db.close();
436
- const base = { file, results };
437
- return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
349
+ const base = { file, results };
350
+ return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
351
+ } finally {
352
+ db.close();
353
+ }
438
354
  }
439
355
 
440
356
  export function fnDepsData(name, customDbPath, opts = {}) {
441
357
  const db = openReadonlyOrFail(customDbPath);
442
- const depth = opts.depth || 3;
443
- const noTests = opts.noTests || false;
444
- const hc = new Map();
358
+ try {
359
+ const depth = opts.depth || 3;
360
+ const noTests = opts.noTests || false;
361
+ const hc = new Map();
445
362
 
446
- const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
447
- if (nodes.length === 0) {
448
- db.close();
449
- return { name, results: [] };
450
- }
363
+ const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
364
+ if (nodes.length === 0) {
365
+ return { name, results: [] };
366
+ }
451
367
 
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;
368
+ const results = nodes.map((node) => {
369
+ const callees = findCallees(db, node.id);
370
+ const filteredCallees = noTests ? callees.filter((c) => !isTestFile(c.file)) : callees;
461
371
 
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);
469
-
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
- }
484
- }
485
- if (noTests) callers = callers.filter((c) => !isTestFile(c.file));
372
+ let callers = findCallers(db, node.id);
486
373
 
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
506
- .prepare(`
507
- SELECT n.name, n.kind, n.file, n.line
508
- FROM edges e JOIN nodes n ON e.source_id = n.id
509
- WHERE e.target_id = ? AND e.kind = 'calls'
510
- `)
511
- .all(f.id);
512
- for (const u of upstream) {
513
- if (noTests && isTestFile(u.file)) continue;
514
- const uid = db
374
+ if (node.kind === 'method' && node.name.includes('.')) {
375
+ const methodName = node.name.split('.').pop();
376
+ const relatedMethods = resolveMethodViaHierarchy(db, methodName);
377
+ for (const rm of relatedMethods) {
378
+ if (rm.id === node.id) continue;
379
+ const extraCallers = findCallers(db, rm.id);
380
+ callers.push(...extraCallers.map((c) => ({ ...c, viaHierarchy: rm.name })));
381
+ }
382
+ }
383
+ if (noTests) callers = callers.filter((c) => !isTestFile(c.file));
384
+
385
+ // Transitive callers
386
+ const transitiveCallers = {};
387
+ if (depth > 1) {
388
+ const visited = new Set([node.id]);
389
+ let frontier = callers
390
+ .map((c) => {
391
+ const row = db
515
392
  .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 });
393
+ .get(c.name, c.kind, c.file, c.line);
394
+ return row ? { ...c, id: row.id } : null;
395
+ })
396
+ .filter(Boolean);
397
+
398
+ for (let d = 2; d <= depth; d++) {
399
+ const nextFrontier = [];
400
+ for (const f of frontier) {
401
+ if (visited.has(f.id)) continue;
402
+ visited.add(f.id);
403
+ const upstream = db
404
+ .prepare(`
405
+ SELECT n.name, n.kind, n.file, n.line
406
+ FROM edges e JOIN nodes n ON e.source_id = n.id
407
+ WHERE e.target_id = ? AND e.kind = 'calls'
408
+ `)
409
+ .all(f.id);
410
+ for (const u of upstream) {
411
+ if (noTests && isTestFile(u.file)) continue;
412
+ const uid = db
413
+ .prepare(
414
+ 'SELECT id FROM nodes WHERE name = ? AND kind = ? AND file = ? AND line = ?',
415
+ )
416
+ .get(u.name, u.kind, u.file, u.line)?.id;
417
+ if (uid && !visited.has(uid)) {
418
+ nextFrontier.push({ ...u, id: uid });
419
+ }
519
420
  }
520
421
  }
422
+ if (nextFrontier.length > 0) {
423
+ transitiveCallers[d] = nextFrontier.map((n) => ({
424
+ name: n.name,
425
+ kind: n.kind,
426
+ file: n.file,
427
+ line: n.line,
428
+ }));
429
+ }
430
+ frontier = nextFrontier;
431
+ if (frontier.length === 0) break;
521
432
  }
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
433
  }
533
- }
534
434
 
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
- });
435
+ return {
436
+ ...normalizeSymbol(node, db, hc),
437
+ callees: filteredCallees.map((c) => ({
438
+ name: c.name,
439
+ kind: c.kind,
440
+ file: c.file,
441
+ line: c.line,
442
+ })),
443
+ callers: callers.map((c) => ({
444
+ name: c.name,
445
+ kind: c.kind,
446
+ file: c.file,
447
+ line: c.line,
448
+ viaHierarchy: c.viaHierarchy || undefined,
449
+ })),
450
+ transitiveCallers,
451
+ };
452
+ });
553
453
 
554
- db.close();
555
- const base = { name, results };
556
- return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
454
+ const base = { name, results };
455
+ return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
456
+ } finally {
457
+ db.close();
458
+ }
557
459
  }
558
460
 
559
461
  export function fnImpactData(name, customDbPath, opts = {}) {
560
462
  const db = openReadonlyOrFail(customDbPath);
561
- const maxDepth = opts.depth || 5;
562
- const noTests = opts.noTests || false;
563
- const hc = new Map();
463
+ try {
464
+ const maxDepth = opts.depth || 5;
465
+ const noTests = opts.noTests || false;
466
+ const hc = new Map();
564
467
 
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
- }
468
+ const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
469
+ if (nodes.length === 0) {
470
+ return { name, results: [] };
471
+ }
472
+
473
+ const results = nodes.map((node) => {
474
+ const visited = new Set([node.id]);
475
+ const levels = {};
476
+ let frontier = [node.id];
570
477
 
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 });
478
+ for (let d = 1; d <= maxDepth; d++) {
479
+ const nextFrontier = [];
480
+ for (const fid of frontier) {
481
+ const callers = findDistinctCallers(db, fid);
482
+ for (const c of callers) {
483
+ if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) {
484
+ visited.add(c.id);
485
+ nextFrontier.push(c.id);
486
+ if (!levels[d]) levels[d] = [];
487
+ levels[d].push({ name: c.name, kind: c.kind, file: c.file, line: c.line });
488
+ }
592
489
  }
593
490
  }
491
+ frontier = nextFrontier;
492
+ if (frontier.length === 0) break;
594
493
  }
595
- frontier = nextFrontier;
596
- if (frontier.length === 0) break;
597
- }
598
494
 
599
- return {
600
- ...normalizeSymbol(node, db, hc),
601
- levels,
602
- totalDependents: visited.size - 1,
603
- };
604
- });
495
+ return {
496
+ ...normalizeSymbol(node, db, hc),
497
+ levels,
498
+ totalDependents: visited.size - 1,
499
+ };
500
+ });
605
501
 
606
- db.close();
607
- const base = { name, results };
608
- return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
502
+ const base = { name, results };
503
+ return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
504
+ } finally {
505
+ db.close();
506
+ }
609
507
  }
610
508
 
611
509
  export function pathData(from, to, customDbPath, opts = {}) {
612
510
  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
- }
511
+ try {
512
+ const noTests = opts.noTests || false;
513
+ const maxDepth = opts.maxDepth || 10;
514
+ const edgeKinds = opts.edgeKinds || ['calls'];
515
+ const reverse = opts.reverse || false;
634
516
 
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
- }
517
+ const fromNodes = findMatchingNodes(db, from, {
518
+ noTests,
519
+ file: opts.fromFile,
520
+ kind: opts.kind,
521
+ });
522
+ if (fromNodes.length === 0) {
523
+ return {
524
+ from,
525
+ to,
526
+ found: false,
527
+ error: `No symbol matching "${from}"`,
528
+ fromCandidates: [],
529
+ toCandidates: [],
530
+ };
531
+ }
532
+
533
+ const toNodes = findMatchingNodes(db, to, {
534
+ noTests,
535
+ file: opts.toFile,
536
+ kind: opts.kind,
537
+ });
538
+ if (toNodes.length === 0) {
539
+ return {
540
+ from,
541
+ to,
542
+ found: false,
543
+ error: `No symbol matching "${to}"`,
544
+ fromCandidates: fromNodes
545
+ .slice(0, 5)
546
+ .map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line })),
547
+ toCandidates: [],
548
+ };
549
+ }
653
550
 
654
- const sourceNode = fromNodes[0];
655
- const targetNode = toNodes[0];
551
+ const sourceNode = fromNodes[0];
552
+ const targetNode = toNodes[0];
656
553
 
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 }));
554
+ const fromCandidates = fromNodes
555
+ .slice(0, 5)
556
+ .map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line }));
557
+ const toCandidates = toNodes
558
+ .slice(0, 5)
559
+ .map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line }));
663
560
 
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
- }
561
+ // Self-path
562
+ if (sourceNode.id === targetNode.id) {
563
+ return {
564
+ from,
565
+ to,
566
+ fromCandidates,
567
+ toCandidates,
568
+ found: true,
569
+ hops: 0,
570
+ path: [
571
+ {
572
+ name: sourceNode.name,
573
+ kind: sourceNode.kind,
574
+ file: sourceNode.file,
575
+ line: sourceNode.line,
576
+ edgeKind: null,
577
+ },
578
+ ],
579
+ alternateCount: 0,
580
+ edgeKinds,
581
+ reverse,
582
+ maxDepth,
583
+ };
584
+ }
689
585
 
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;
586
+ // Build edge kind filter
587
+ const kindPlaceholders = edgeKinds.map(() => '?').join(', ');
588
+
589
+ // BFS — direction depends on `reverse` flag
590
+ // Forward: source_id → target_id (A calls... calls B)
591
+ // Reverse: target_id → source_id (B is called by... called by A)
592
+ const neighborQuery = reverse
593
+ ? `SELECT n.id, n.name, n.kind, n.file, n.line, e.kind AS edge_kind
594
+ FROM edges e JOIN nodes n ON e.source_id = n.id
595
+ WHERE e.target_id = ? AND e.kind IN (${kindPlaceholders})`
596
+ : `SELECT n.id, n.name, n.kind, n.file, n.line, e.kind AS edge_kind
597
+ FROM edges e JOIN nodes n ON e.target_id = n.id
598
+ WHERE e.source_id = ? AND e.kind IN (${kindPlaceholders})`;
599
+ const neighborStmt = db.prepare(neighborQuery);
600
+
601
+ const visited = new Set([sourceNode.id]);
602
+ // parent map: nodeId → { parentId, edgeKind }
603
+ const parent = new Map();
604
+ let queue = [sourceNode.id];
605
+ let found = false;
606
+ let alternateCount = 0;
607
+ let foundDepth = -1;
608
+
609
+ for (let depth = 1; depth <= maxDepth; depth++) {
610
+ const nextQueue = [];
611
+ for (const currentId of queue) {
612
+ const neighbors = neighborStmt.all(currentId, ...edgeKinds);
613
+ for (const n of neighbors) {
614
+ if (noTests && isTestFile(n.file)) continue;
615
+ if (n.id === targetNode.id) {
616
+ if (!found) {
617
+ found = true;
618
+ foundDepth = depth;
619
+ parent.set(n.id, { parentId: currentId, edgeKind: n.edge_kind });
620
+ }
621
+ alternateCount++;
622
+ continue;
623
+ }
624
+ if (!visited.has(n.id)) {
625
+ visited.add(n.id);
723
626
  parent.set(n.id, { parentId: currentId, edgeKind: n.edge_kind });
627
+ nextQueue.push(n.id);
724
628
  }
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
629
  }
733
630
  }
631
+ if (found) break;
632
+ queue = nextQueue;
633
+ if (queue.length === 0) break;
734
634
  }
735
- if (found) break;
736
- queue = nextQueue;
737
- if (queue.length === 0) break;
738
- }
739
635
 
740
- if (!found) {
741
- db.close();
636
+ if (!found) {
637
+ return {
638
+ from,
639
+ to,
640
+ fromCandidates,
641
+ toCandidates,
642
+ found: false,
643
+ hops: null,
644
+ path: [],
645
+ alternateCount: 0,
646
+ edgeKinds,
647
+ reverse,
648
+ maxDepth,
649
+ };
650
+ }
651
+
652
+ // alternateCount includes the one we kept; subtract 1 for "alternates"
653
+ alternateCount = Math.max(0, alternateCount - 1);
654
+
655
+ // Reconstruct path from target back to source
656
+ const pathIds = [targetNode.id];
657
+ let cur = targetNode.id;
658
+ while (cur !== sourceNode.id) {
659
+ const p = parent.get(cur);
660
+ pathIds.push(p.parentId);
661
+ cur = p.parentId;
662
+ }
663
+ pathIds.reverse();
664
+
665
+ // Build path with node info
666
+ const nodeCache = new Map();
667
+ const getNode = (id) => {
668
+ if (nodeCache.has(id)) return nodeCache.get(id);
669
+ const row = db.prepare('SELECT name, kind, file, line FROM nodes WHERE id = ?').get(id);
670
+ nodeCache.set(id, row);
671
+ return row;
672
+ };
673
+
674
+ const resultPath = pathIds.map((id, idx) => {
675
+ const node = getNode(id);
676
+ const edgeKind = idx === 0 ? null : parent.get(id).edgeKind;
677
+ return { name: node.name, kind: node.kind, file: node.file, line: node.line, edgeKind };
678
+ });
679
+
742
680
  return {
743
681
  from,
744
682
  to,
745
683
  fromCandidates,
746
684
  toCandidates,
747
- found: false,
748
- hops: null,
749
- path: [],
750
- alternateCount: 0,
685
+ found: true,
686
+ hops: foundDepth,
687
+ path: resultPath,
688
+ alternateCount,
751
689
  edgeKinds,
752
690
  reverse,
753
691
  maxDepth,
754
692
  };
693
+ } finally {
694
+ db.close();
755
695
  }
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
696
  }
800
697
 
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
- }
698
+ /**
699
+ * Fix #2: Shell injection vulnerability.
700
+ * Uses execFileSync instead of execSync to prevent shell interpretation of user input.
701
+ */
702
+ export function diffImpactData(customDbPath, opts = {}) {
703
+ const db = openReadonlyOrFail(customDbPath);
704
+ try {
705
+ const noTests = opts.noTests || false;
706
+ const maxDepth = opts.depth || 3;
807
707
 
808
- if (data.error) {
809
- console.log(data.error);
810
- return;
811
- }
708
+ const dbPath = findDbPath(customDbPath);
709
+ const repoRoot = path.resolve(path.dirname(dbPath), '..');
812
710
 
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
- );
711
+ // Verify we're in a git repository before running git diff
712
+ let checkDir = repoRoot;
713
+ let isGitRepo = false;
714
+ while (checkDir) {
715
+ if (fs.existsSync(path.join(checkDir, '.git'))) {
716
+ isGitRepo = true;
717
+ break;
718
+ }
719
+ const parent = path.dirname(checkDir);
720
+ if (parent === checkDir) break;
721
+ checkDir = parent;
820
722
  }
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
- );
723
+ if (!isGitRepo) {
724
+ return { error: `Not a git repository: ${repoRoot}` };
825
725
  }
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
726
 
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
- );
727
+ let diffOutput;
728
+ try {
729
+ const args = opts.staged
730
+ ? ['diff', '--cached', '--unified=0', '--no-color']
731
+ : ['diff', opts.ref || 'HEAD', '--unified=0', '--no-color'];
732
+ diffOutput = execFileSync('git', args, {
733
+ cwd: repoRoot,
734
+ encoding: 'utf-8',
735
+ maxBuffer: 10 * 1024 * 1024,
736
+ stdio: ['pipe', 'pipe', 'pipe'],
737
+ });
738
+ } catch (e) {
739
+ return { error: `Failed to run git diff: ${e.message}` };
849
740
  }
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
- }
859
741
 
860
- /**
861
- * Fix #2: Shell injection vulnerability.
862
- * Uses execFileSync instead of execSync to prevent shell interpretation of user input.
863
- */
864
- export function diffImpactData(customDbPath, opts = {}) {
865
- 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;
742
+ if (!diffOutput.trim()) {
743
+ return {
744
+ changedFiles: 0,
745
+ newFiles: [],
746
+ affectedFunctions: [],
747
+ affectedFiles: [],
748
+ summary: null,
749
+ };
879
750
  }
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
- 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
- }
904
-
905
- if (!diffOutput.trim()) {
906
- db.close();
907
- return {
908
- changedFiles: 0,
909
- newFiles: [],
910
- affectedFunctions: [],
911
- affectedFiles: [],
912
- summary: null,
913
- };
914
- }
915
751
 
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 });
752
+ const changedRanges = new Map();
753
+ const newFiles = new Set();
754
+ let currentFile = null;
755
+ let prevIsDevNull = false;
756
+ for (const line of diffOutput.split('\n')) {
757
+ if (line.startsWith('--- /dev/null')) {
758
+ prevIsDevNull = true;
759
+ continue;
760
+ }
761
+ if (line.startsWith('--- ')) {
762
+ prevIsDevNull = false;
763
+ continue;
764
+ }
765
+ const fileMatch = line.match(/^\+\+\+ b\/(.+)/);
766
+ if (fileMatch) {
767
+ currentFile = fileMatch[1];
768
+ if (!changedRanges.has(currentFile)) changedRanges.set(currentFile, []);
769
+ if (prevIsDevNull) newFiles.add(currentFile);
770
+ prevIsDevNull = false;
771
+ continue;
772
+ }
773
+ const hunkMatch = line.match(/^@@ .+ \+(\d+)(?:,(\d+))? @@/);
774
+ if (hunkMatch && currentFile) {
775
+ const start = parseInt(hunkMatch[1], 10);
776
+ const count = parseInt(hunkMatch[2] || '1', 10);
777
+ changedRanges.get(currentFile).push({ start, end: start + count - 1 });
778
+ }
942
779
  }
943
- }
944
780
 
945
- if (changedRanges.size === 0) {
946
- db.close();
947
- return {
948
- changedFiles: 0,
949
- newFiles: [],
950
- affectedFunctions: [],
951
- affectedFiles: [],
952
- summary: null,
953
- };
954
- }
781
+ if (changedRanges.size === 0) {
782
+ return {
783
+ changedFiles: 0,
784
+ newFiles: [],
785
+ affectedFunctions: [],
786
+ affectedFiles: [],
787
+ summary: null,
788
+ };
789
+ }
955
790
 
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;
791
+ const affectedFunctions = [];
792
+ for (const [file, ranges] of changedRanges) {
793
+ if (noTests && isTestFile(file)) continue;
794
+ const defs = db
795
+ .prepare(
796
+ `SELECT * FROM nodes WHERE file = ? AND kind IN ('function', 'method', 'class') ORDER BY line`,
797
+ )
798
+ .all(file);
799
+ for (let i = 0; i < defs.length; i++) {
800
+ const def = defs[i];
801
+ const endLine = def.end_line || (defs[i + 1] ? defs[i + 1].line - 1 : 999999);
802
+ for (const range of ranges) {
803
+ if (range.start <= endLine && range.end >= def.line) {
804
+ affectedFunctions.push(def);
805
+ break;
806
+ }
971
807
  }
972
808
  }
973
809
  }
974
- }
975
810
 
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++;
811
+ const allAffected = new Set();
812
+ const functionResults = affectedFunctions.map((fn) => {
813
+ const visited = new Set([fn.id]);
814
+ let frontier = [fn.id];
815
+ let totalCallers = 0;
816
+ const levels = {};
817
+ const edges = [];
818
+ const idToKey = new Map();
819
+ idToKey.set(fn.id, `${fn.file}::${fn.name}:${fn.line}`);
820
+ for (let d = 1; d <= maxDepth; d++) {
821
+ const nextFrontier = [];
822
+ for (const fid of frontier) {
823
+ const callers = findDistinctCallers(db, fid);
824
+ for (const c of callers) {
825
+ if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) {
826
+ visited.add(c.id);
827
+ nextFrontier.push(c.id);
828
+ allAffected.add(`${c.file}:${c.name}`);
829
+ const callerKey = `${c.file}::${c.name}:${c.line}`;
830
+ idToKey.set(c.id, callerKey);
831
+ if (!levels[d]) levels[d] = [];
832
+ levels[d].push({ name: c.name, kind: c.kind, file: c.file, line: c.line });
833
+ edges.push({ from: idToKey.get(fid), to: callerKey });
834
+ totalCallers++;
835
+ }
1006
836
  }
1007
837
  }
838
+ frontier = nextFrontier;
839
+ if (frontier.length === 0) break;
1008
840
  }
1009
- frontier = nextFrontier;
1010
- if (frontier.length === 0) break;
1011
- }
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
-
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,
841
+ return {
842
+ name: fn.name,
843
+ kind: fn.kind,
844
+ file: fn.file,
845
+ line: fn.line,
846
+ transitiveCallers: totalCallers,
847
+ levels,
848
+ edges,
849
+ };
1035
850
  });
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
851
 
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
- }
852
+ const affectedFiles = new Set();
853
+ for (const key of allAffected) affectedFiles.add(key.split(':')[0]);
1057
854
 
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()],
855
+ // Look up historically coupled files from co-change data
856
+ let historicallyCoupled = [];
857
+ try {
858
+ db.prepare('SELECT 1 FROM co_changes LIMIT 1').get();
859
+ const changedFilesList = [...changedRanges.keys()];
860
+ const coResults = coChangeForFiles(changedFilesList, db, {
861
+ minJaccard: 0.3,
862
+ limit: 20,
1067
863
  noTests,
1068
864
  });
1069
- boundaryViolations = result.violations;
1070
- boundaryViolationCount = result.violationCount;
865
+ // Exclude files already found via static analysis
866
+ historicallyCoupled = coResults.filter((r) => !affectedFiles.has(r.file));
867
+ } catch {
868
+ /* co_changes table doesn't exist — skip silently */
869
+ }
870
+
871
+ // Look up CODEOWNERS for changed + affected files
872
+ let ownership = null;
873
+ try {
874
+ const allFilePaths = [...new Set([...changedRanges.keys(), ...affectedFiles])];
875
+ const ownerResult = ownersForFiles(allFilePaths, repoRoot);
876
+ if (ownerResult.affectedOwners.length > 0) {
877
+ ownership = {
878
+ owners: Object.fromEntries(ownerResult.owners),
879
+ affectedOwners: ownerResult.affectedOwners,
880
+ suggestedReviewers: ownerResult.suggestedReviewers,
881
+ };
882
+ }
883
+ } catch {
884
+ /* CODEOWNERS missing or unreadable — skip silently */
885
+ }
886
+
887
+ // Check boundary violations scoped to changed files
888
+ let boundaryViolations = [];
889
+ let boundaryViolationCount = 0;
890
+ try {
891
+ const config = loadConfig(repoRoot);
892
+ const boundaryConfig = config.manifesto?.boundaries;
893
+ if (boundaryConfig) {
894
+ const result = evaluateBoundaries(db, boundaryConfig, {
895
+ scopeFiles: [...changedRanges.keys()],
896
+ noTests,
897
+ });
898
+ boundaryViolations = result.violations;
899
+ boundaryViolationCount = result.violationCount;
900
+ }
901
+ } catch {
902
+ /* boundary check failed — skip silently */
1071
903
  }
1072
- } catch {
1073
- /* boundary check failed — skip silently */
1074
- }
1075
904
 
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,
905
+ const base = {
906
+ changedFiles: changedRanges.size,
907
+ newFiles: [...newFiles],
908
+ affectedFunctions: functionResults,
909
+ affectedFiles: [...affectedFiles],
910
+ historicallyCoupled,
911
+ ownership,
912
+ boundaryViolations,
1092
913
  boundaryViolationCount,
1093
- },
1094
- };
1095
- return paginateResult(base, 'affectedFunctions', { limit: opts.limit, offset: opts.offset });
914
+ summary: {
915
+ functionsChanged: affectedFunctions.length,
916
+ callersAffected: allAffected.size,
917
+ filesAffected: affectedFiles.size,
918
+ historicallyCoupledCount: historicallyCoupled.length,
919
+ ownersAffected: ownership ? ownership.affectedOwners.length : 0,
920
+ boundaryViolationCount,
921
+ },
922
+ };
923
+ return paginateResult(base, 'affectedFunctions', { limit: opts.limit, offset: opts.offset });
924
+ } finally {
925
+ db.close();
926
+ }
1096
927
  }
1097
928
 
1098
929
  export function diffImpactMermaid(customDbPath, opts = {}) {
@@ -1211,35 +1042,20 @@ export function diffImpactMermaid(customDbPath, opts = {}) {
1211
1042
 
1212
1043
  export function listFunctionsData(customDbPath, opts = {}) {
1213
1044
  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
- }
1045
+ try {
1046
+ const noTests = opts.noTests || false;
1229
1047
 
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);
1048
+ let rows = listFunctionNodes(db, { file: opts.file, pattern: opts.pattern });
1235
1049
 
1236
- if (noTests) rows = rows.filter((r) => !isTestFile(r.file));
1050
+ if (noTests) rows = rows.filter((r) => !isTestFile(r.file));
1237
1051
 
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 });
1052
+ const hc = new Map();
1053
+ const functions = rows.map((r) => normalizeSymbol(r, db, hc));
1054
+ const base = { count: functions.length, functions };
1055
+ return paginateResult(base, 'functions', { limit: opts.limit, offset: opts.offset });
1056
+ } finally {
1057
+ db.close();
1058
+ }
1243
1059
  }
1244
1060
 
1245
1061
  /**
@@ -1255,25 +1071,8 @@ export function* iterListFunctions(customDbPath, opts = {}) {
1255
1071
  const db = openReadonlyOrFail(customDbPath);
1256
1072
  try {
1257
1073
  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
- }
1272
1074
 
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)) {
1075
+ for (const row of iterateFunctionNodes(db, { file: opts.file, pattern: opts.pattern })) {
1277
1076
  if (noTests && isTestFile(row.file)) continue;
1278
1077
  yield {
1279
1078
  name: row.name,
@@ -1383,569 +1182,247 @@ export function* iterWhere(target, customDbPath, opts = {}) {
1383
1182
 
1384
1183
  export function statsData(customDbPath, opts = {}) {
1385
1184
  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);
1185
+ try {
1186
+ const noTests = opts.noTests || false;
1187
+
1188
+ // Build set of test file IDs for filtering nodes and edges
1189
+ let testFileIds = null;
1190
+ if (noTests) {
1191
+ const allFileNodes = db.prepare("SELECT id, file FROM nodes WHERE kind = 'file'").all();
1192
+ testFileIds = new Set();
1193
+ const testFiles = new Set();
1194
+ for (const n of allFileNodes) {
1195
+ if (isTestFile(n.file)) {
1196
+ testFileIds.add(n.id);
1197
+ testFiles.add(n.file);
1198
+ }
1199
+ }
1200
+ // Also collect non-file node IDs that belong to test files
1201
+ const allNodes = db.prepare('SELECT id, file FROM nodes').all();
1202
+ for (const n of allNodes) {
1203
+ if (testFiles.has(n.file)) testFileIds.add(n.id);
1398
1204
  }
1399
1205
  }
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
1206
 
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);
1207
+ // Node breakdown by kind
1208
+ let nodeRows;
1209
+ if (noTests) {
1210
+ const allNodes = db.prepare('SELECT id, kind, file FROM nodes').all();
1211
+ const filtered = allNodes.filter((n) => !testFileIds.has(n.id));
1212
+ const counts = {};
1213
+ for (const n of filtered) counts[n.kind] = (counts[n.kind] || 0) + 1;
1214
+ nodeRows = Object.entries(counts).map(([kind, c]) => ({ kind, c }));
1215
+ } else {
1216
+ nodeRows = db.prepare('SELECT kind, COUNT(*) as c FROM nodes GROUP BY kind').all();
1450
1217
  }
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
- };
1218
+ const nodesByKind = {};
1219
+ let totalNodes = 0;
1220
+ for (const r of nodeRows) {
1221
+ nodesByKind[r.kind] = r.c;
1222
+ totalNodes += r.c;
1506
1223
  }
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
1224
 
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
- };
1612
- }
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
-
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)}`,
1225
+ // Edge breakdown by kind
1226
+ let edgeRows;
1227
+ if (noTests) {
1228
+ const allEdges = db.prepare('SELECT source_id, target_id, kind FROM edges').all();
1229
+ const filtered = allEdges.filter(
1230
+ (e) => !testFileIds.has(e.source_id) && !testFileIds.has(e.target_id),
1699
1231
  );
1232
+ const counts = {};
1233
+ for (const e of filtered) counts[e.kind] = (counts[e.kind] || 0) + 1;
1234
+ edgeRows = Object.entries(counts).map(([kind, c]) => ({ kind, c }));
1235
+ } else {
1236
+ edgeRows = db.prepare('SELECT kind, COUNT(*) as c FROM edges GROUP BY kind').all();
1237
+ }
1238
+ const edgesByKind = {};
1239
+ let totalEdges = 0;
1240
+ for (const r of edgeRows) {
1241
+ edgesByKind[r.kind] = r.c;
1242
+ totalEdges += r.c;
1700
1243
  }
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
1244
 
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}`);
1245
+ // File/language distribution — map extensions via LANGUAGE_REGISTRY
1246
+ const extToLang = new Map();
1247
+ for (const entry of LANGUAGE_REGISTRY) {
1248
+ for (const ext of entry.extensions) {
1249
+ extToLang.set(ext, entry.id);
1729
1250
  }
1730
1251
  }
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}`);
1252
+ let fileNodes = db.prepare("SELECT file FROM nodes WHERE kind = 'file'").all();
1253
+ if (noTests) fileNodes = fileNodes.filter((n) => !isTestFile(n.file));
1254
+ const byLanguage = {};
1255
+ for (const row of fileNodes) {
1256
+ const ext = path.extname(row.file).toLowerCase();
1257
+ const lang = extToLang.get(ext) || 'other';
1258
+ byLanguage[lang] = (byLanguage[lang] || 0) + 1;
1746
1259
  }
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
- }
1260
+ const langCount = Object.keys(byLanguage).length;
1757
1261
 
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
- }
1789
-
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
- }
1262
+ // Cycles
1263
+ const fileCycles = findCycles(db, { fileLevel: true, noTests });
1264
+ const fnCycles = findCycles(db, { fileLevel: false, noTests });
1808
1265
 
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
- }
1266
+ // Top 5 coupling hotspots (fan-in + fan-out, file nodes)
1267
+ const testFilter = testFilterSQL('n.file', noTests);
1268
+ const hotspotRows = db
1269
+ .prepare(`
1270
+ SELECT n.file,
1271
+ (SELECT COUNT(*) FROM edges WHERE target_id = n.id) as fan_in,
1272
+ (SELECT COUNT(*) FROM edges WHERE source_id = n.id) as fan_out
1273
+ FROM nodes n
1274
+ WHERE n.kind = 'file' ${testFilter}
1275
+ ORDER BY (SELECT COUNT(*) FROM edges WHERE target_id = n.id)
1276
+ + (SELECT COUNT(*) FROM edges WHERE source_id = n.id) DESC
1277
+ `)
1278
+ .all();
1279
+ const filteredHotspots = noTests ? hotspotRows.filter((r) => !isTestFile(r.file)) : hotspotRows;
1280
+ const hotspots = filteredHotspots.slice(0, 5).map((r) => ({
1281
+ file: r.file,
1282
+ fanIn: r.fan_in,
1283
+ fanOut: r.fan_out,
1284
+ }));
1823
1285
 
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`);
1286
+ // Embeddings metadata
1287
+ let embeddings = null;
1288
+ try {
1289
+ const count = db.prepare('SELECT COUNT(*) as c FROM embeddings').get();
1290
+ if (count && count.c > 0) {
1291
+ const meta = {};
1292
+ const metaRows = db.prepare('SELECT key, value FROM embedding_meta').all();
1293
+ for (const r of metaRows) meta[r.key] = r.value;
1294
+ embeddings = {
1295
+ count: count.c,
1296
+ model: meta.model || null,
1297
+ dim: meta.dim ? parseInt(meta.dim, 10) : null,
1298
+ builtAt: meta.built_at || null,
1299
+ };
1300
+ }
1301
+ } catch {
1302
+ /* embeddings table may not exist */
1839
1303
  }
1840
- }
1841
- console.log(`\n Total: ${data.totalDependents} files transitively depend on "${file}"\n`);
1842
- }
1843
1304
 
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
- }
1305
+ // Graph quality metrics
1306
+ const qualityTestFilter = testFilter.replace(/n\.file/g, 'file');
1307
+ const totalCallable = db
1308
+ .prepare(
1309
+ `SELECT COUNT(*) as c FROM nodes WHERE kind IN ('function', 'method') ${qualityTestFilter}`,
1310
+ )
1311
+ .get().c;
1312
+ const callableWithCallers = db
1313
+ .prepare(`
1314
+ SELECT COUNT(DISTINCT e.target_id) as c FROM edges e
1315
+ JOIN nodes n ON e.target_id = n.id
1316
+ WHERE e.kind = 'calls' AND n.kind IN ('function', 'method') ${testFilter}
1317
+ `)
1318
+ .get().c;
1319
+ const callerCoverage = totalCallable > 0 ? callableWithCallers / totalCallable : 0;
1320
+
1321
+ const totalCallEdges = db
1322
+ .prepare("SELECT COUNT(*) as c FROM edges WHERE kind = 'calls'")
1323
+ .get().c;
1324
+ const highConfCallEdges = db
1325
+ .prepare("SELECT COUNT(*) as c FROM edges WHERE kind = 'calls' AND confidence >= 0.7")
1326
+ .get().c;
1327
+ const callConfidence = totalCallEdges > 0 ? highConfCallEdges / totalCallEdges : 0;
1328
+
1329
+ // False-positive warnings: generic names with > threshold callers
1330
+ const fpRows = db
1331
+ .prepare(`
1332
+ SELECT n.name, n.file, n.line, COUNT(e.source_id) as caller_count
1333
+ FROM nodes n
1334
+ LEFT JOIN edges e ON n.id = e.target_id AND e.kind = 'calls'
1335
+ WHERE n.kind IN ('function', 'method')
1336
+ GROUP BY n.id
1337
+ HAVING caller_count > ?
1338
+ ORDER BY caller_count DESC
1339
+ `)
1340
+ .all(FALSE_POSITIVE_CALLER_THRESHOLD);
1341
+ const falsePositiveWarnings = fpRows
1342
+ .filter((r) =>
1343
+ FALSE_POSITIVE_NAMES.has(r.name.includes('.') ? r.name.split('.').pop() : r.name),
1344
+ )
1345
+ .map((r) => ({ name: r.name, file: r.file, line: r.line, callerCount: r.caller_count }));
1850
1346
 
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
- }
1347
+ // Edges from suspicious nodes
1348
+ let fpEdgeCount = 0;
1349
+ for (const fp of falsePositiveWarnings) fpEdgeCount += fp.callerCount;
1350
+ const falsePositiveRatio = totalCallEdges > 0 ? fpEdgeCount / totalCallEdges : 0;
1871
1351
 
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
- }
1352
+ const score = Math.round(
1353
+ callerCoverage * 40 + callConfidence * 40 + (1 - falsePositiveRatio) * 20,
1354
+ );
1886
1355
 
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
- }
1356
+ const quality = {
1357
+ score,
1358
+ callerCoverage: {
1359
+ ratio: callerCoverage,
1360
+ covered: callableWithCallers,
1361
+ total: totalCallable,
1362
+ },
1363
+ callConfidence: {
1364
+ ratio: callConfidence,
1365
+ highConf: highConfCallEdges,
1366
+ total: totalCallEdges,
1367
+ },
1368
+ falsePositiveWarnings,
1369
+ };
1905
1370
 
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
- }
1371
+ // Role distribution
1372
+ let roleRows;
1373
+ if (noTests) {
1374
+ const allRoleNodes = db.prepare('SELECT role, file FROM nodes WHERE role IS NOT NULL').all();
1375
+ const filtered = allRoleNodes.filter((n) => !isTestFile(n.file));
1376
+ const counts = {};
1377
+ for (const n of filtered) counts[n.role] = (counts[n.role] || 0) + 1;
1378
+ roleRows = Object.entries(counts).map(([role, c]) => ({ role, c }));
1379
+ } else {
1380
+ roleRows = db
1381
+ .prepare('SELECT role, COUNT(*) as c FROM nodes WHERE role IS NOT NULL GROUP BY role')
1382
+ .all();
1383
+ }
1384
+ const roles = {};
1385
+ for (const r of roleRows) roles[r.role] = r.c;
1920
1386
 
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}`);
1387
+ // Complexity summary
1388
+ let complexity = null;
1389
+ try {
1390
+ const cRows = db
1391
+ .prepare(
1392
+ `SELECT fc.cognitive, fc.cyclomatic, fc.max_nesting, fc.maintainability_index
1393
+ FROM function_complexity fc JOIN nodes n ON fc.node_id = n.id
1394
+ WHERE n.kind IN ('function','method') ${testFilter}`,
1395
+ )
1396
+ .all();
1397
+ if (cRows.length > 0) {
1398
+ const miValues = cRows.map((r) => r.maintainability_index || 0);
1399
+ complexity = {
1400
+ analyzed: cRows.length,
1401
+ avgCognitive: +(cRows.reduce((s, r) => s + r.cognitive, 0) / cRows.length).toFixed(1),
1402
+ avgCyclomatic: +(cRows.reduce((s, r) => s + r.cyclomatic, 0) / cRows.length).toFixed(1),
1403
+ maxCognitive: Math.max(...cRows.map((r) => r.cognitive)),
1404
+ maxCyclomatic: Math.max(...cRows.map((r) => r.cyclomatic)),
1405
+ avgMI: +(miValues.reduce((s, v) => s + v, 0) / miValues.length).toFixed(1),
1406
+ minMI: +Math.min(...miValues).toFixed(1),
1407
+ };
1933
1408
  }
1409
+ } catch {
1410
+ /* table may not exist in older DBs */
1934
1411
  }
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();
1412
+
1413
+ return {
1414
+ nodes: { total: totalNodes, byKind: nodesByKind },
1415
+ edges: { total: totalEdges, byKind: edgesByKind },
1416
+ files: { total: fileNodes.length, languages: langCount, byLanguage },
1417
+ cycles: { fileLevel: fileCycles.length, functionLevel: fnCycles.length },
1418
+ hotspots,
1419
+ embeddings,
1420
+ quality,
1421
+ roles,
1422
+ complexity,
1423
+ };
1424
+ } finally {
1425
+ db.close();
1949
1426
  }
1950
1427
  }
1951
1428
 
@@ -2063,347 +1540,210 @@ function extractSignature(fileLines, line) {
2063
1540
 
2064
1541
  export function contextData(name, customDbPath, opts = {}) {
2065
1542
  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;
1543
+ try {
1544
+ const depth = opts.depth || 0;
1545
+ const noSource = opts.noSource || false;
1546
+ const noTests = opts.noTests || false;
1547
+ const includeTests = opts.includeTests || false;
2070
1548
 
2071
- const dbPath = findDbPath(customDbPath);
2072
- const repoRoot = path.resolve(path.dirname(dbPath), '..');
1549
+ const dbPath = findDbPath(customDbPath);
1550
+ const repoRoot = path.resolve(path.dirname(dbPath), '..');
2073
1551
 
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
- }
1552
+ const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
1553
+ if (nodes.length === 0) {
1554
+ return { name, results: [] };
1555
+ }
2079
1556
 
2080
- // No hardcoded slice — pagination handles bounding via limit/offset
1557
+ // No hardcoded slice — pagination handles bounding via limit/offset
2081
1558
 
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) {
1559
+ // File-lines cache to avoid re-reading the same file
1560
+ const fileCache = new Map();
1561
+ function getFileLines(file) {
1562
+ if (fileCache.has(file)) return fileCache.get(file);
1563
+ try {
1564
+ const absPath = safePath(repoRoot, file);
1565
+ if (!absPath) {
1566
+ fileCache.set(file, null);
1567
+ return null;
1568
+ }
1569
+ const lines = fs.readFileSync(absPath, 'utf-8').split('\n');
1570
+ fileCache.set(file, lines);
1571
+ return lines;
1572
+ } catch (e) {
1573
+ debug(`getFileLines failed for ${file}: ${e.message}`);
2089
1574
  fileCache.set(file, null);
2090
1575
  return null;
2091
1576
  }
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;
2099
1577
  }
2100
- }
2101
1578
 
2102
- const results = nodes.map((node) => {
2103
- const fileLines = getFileLines(node.file);
1579
+ const results = nodes.map((node) => {
1580
+ const fileLines = getFileLines(node.file);
2104
1581
 
2105
- // Source
2106
- const source = noSource ? null : readSourceRange(repoRoot, node.file, node.line, node.end_line);
1582
+ // Source
1583
+ const source = noSource
1584
+ ? null
1585
+ : readSourceRange(repoRoot, node.file, node.line, node.end_line);
2107
1586
 
2108
- // Signature
2109
- const signature = fileLines ? extractSignature(fileLines, node.line) : null;
1587
+ // Signature
1588
+ const signature = fileLines ? extractSignature(fileLines, node.line) : null;
2110
1589
 
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
- });
1590
+ // Callees
1591
+ const calleeRows = findCallees(db, node.id);
1592
+ const filteredCallees = noTests ? calleeRows.filter((c) => !isTestFile(c.file)) : calleeRows;
2138
1593
 
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
- });
1594
+ const callees = filteredCallees.map((c) => {
1595
+ const cLines = getFileLines(c.file);
1596
+ const summary = cLines ? extractSummary(cLines, c.line) : null;
1597
+ let calleeSource = null;
1598
+ if (depth >= 1) {
1599
+ calleeSource = readSourceRange(repoRoot, c.file, c.line, c.end_line);
1600
+ }
1601
+ return {
1602
+ name: c.name,
1603
+ kind: c.kind,
1604
+ file: c.file,
1605
+ line: c.line,
1606
+ endLine: c.end_line || null,
1607
+ summary,
1608
+ source: calleeSource,
1609
+ };
1610
+ });
1611
+
1612
+ // Deep callee expansion via BFS (depth > 1, capped at 5)
1613
+ if (depth > 1) {
1614
+ const visited = new Set(filteredCallees.map((c) => c.id));
1615
+ visited.add(node.id);
1616
+ let frontier = filteredCallees.map((c) => c.id);
1617
+ const maxDepth = Math.min(depth, 5);
1618
+ for (let d = 2; d <= maxDepth; d++) {
1619
+ const nextFrontier = [];
1620
+ for (const fid of frontier) {
1621
+ const deeper = findCallees(db, fid);
1622
+ for (const c of deeper) {
1623
+ if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) {
1624
+ visited.add(c.id);
1625
+ nextFrontier.push(c.id);
1626
+ const cLines = getFileLines(c.file);
1627
+ callees.push({
1628
+ name: c.name,
1629
+ kind: c.kind,
1630
+ file: c.file,
1631
+ line: c.line,
1632
+ endLine: c.end_line || null,
1633
+ summary: cLines ? extractSummary(cLines, c.line) : null,
1634
+ source: readSourceRange(repoRoot, c.file, c.line, c.end_line),
1635
+ });
1636
+ }
2169
1637
  }
2170
1638
  }
1639
+ frontier = nextFrontier;
1640
+ if (frontier.length === 0) break;
2171
1641
  }
2172
- frontier = nextFrontier;
2173
- if (frontier.length === 0) break;
2174
- }
2175
- }
2176
-
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
1642
  }
2201
- }
2202
- if (noTests) callerRows = callerRows.filter((c) => !isTestFile(c.file));
2203
-
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
- }));
2211
1643
 
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]);
1644
+ // Callers
1645
+ let callerRows = findCallers(db, node.id);
1646
+
1647
+ // Method hierarchy resolution
1648
+ if (node.kind === 'method' && node.name.includes('.')) {
1649
+ const methodName = node.name.split('.').pop();
1650
+ const relatedMethods = resolveMethodViaHierarchy(db, methodName);
1651
+ for (const rm of relatedMethods) {
1652
+ if (rm.id === node.id) continue;
1653
+ const extraCallers = findCallers(db, rm.id);
1654
+ callerRows.push(...extraCallers.map((c) => ({ ...c, viaHierarchy: rm.name })));
2236
1655
  }
2237
1656
  }
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
+ if (noTests) callerRows = callerRows.filter((c) => !isTestFile(c.file));
2246
1658
 
2247
- // Complexity metrics
2248
- let complexityMetrics = null;
2249
- try {
2250
- const cRow = db
2251
- .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
- }
2315
-
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`);
1659
+ const callers = callerRows.map((c) => ({
1660
+ name: c.name,
1661
+ kind: c.kind,
1662
+ file: c.file,
1663
+ line: c.line,
1664
+ viaHierarchy: c.viaHierarchy || undefined,
1665
+ }));
2320
1666
 
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
- }
1667
+ // Related tests: callers that live in test files
1668
+ const testCallerRows = findCallers(db, node.id);
1669
+ const testCallers = testCallerRows.filter((c) => isTestFile(c.file));
2328
1670
 
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}`);
1671
+ const testsByFile = new Map();
1672
+ for (const tc of testCallers) {
1673
+ if (!testsByFile.has(tc.file)) testsByFile.set(tc.file, []);
1674
+ testsByFile.get(tc.file).push(tc);
2334
1675
  }
2335
- console.log();
2336
- }
2337
-
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
- }
2348
1676
 
2349
- // Source
2350
- if (r.source) {
2351
- console.log('## Source');
2352
- for (const line of r.source.split('\n')) {
2353
- console.log(` ${line}`);
2354
- }
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}`);
1677
+ const relatedTests = [];
1678
+ for (const [file] of testsByFile) {
1679
+ const tLines = getFileLines(file);
1680
+ const testNames = [];
1681
+ if (tLines) {
1682
+ for (const tl of tLines) {
1683
+ const tm = tl.match(/(?:it|test|describe)\s*\(\s*['"`]([^'"`]+)['"`]/);
1684
+ if (tm) testNames.push(tm[1]);
2367
1685
  }
2368
1686
  }
1687
+ const testSource = includeTests && tLines ? tLines.join('\n') : undefined;
1688
+ relatedTests.push({
1689
+ file,
1690
+ testCount: testNames.length,
1691
+ testNames,
1692
+ source: testSource,
1693
+ });
2369
1694
  }
2370
- console.log();
2371
- }
2372
1695
 
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}`);
1696
+ // Complexity metrics
1697
+ let complexityMetrics = null;
1698
+ try {
1699
+ const cRow = getComplexityForNode(db, node.id);
1700
+ if (cRow) {
1701
+ complexityMetrics = {
1702
+ cognitive: cRow.cognitive,
1703
+ cyclomatic: cRow.cyclomatic,
1704
+ maxNesting: cRow.max_nesting,
1705
+ maintainabilityIndex: cRow.maintainability_index || 0,
1706
+ halsteadVolume: cRow.halstead_volume || 0,
1707
+ };
1708
+ }
1709
+ } catch {
1710
+ /* table may not exist */
2379
1711
  }
2380
- console.log();
2381
- }
2382
1712
 
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
- }
1713
+ // Children (parameters, properties, constants)
1714
+ let nodeChildren = [];
1715
+ try {
1716
+ nodeChildren = findNodeChildren(db, node.id).map((c) => ({
1717
+ name: c.name,
1718
+ kind: c.kind,
1719
+ line: c.line,
1720
+ endLine: c.end_line || null,
1721
+ }));
1722
+ } catch {
1723
+ /* parent_id column may not exist */
2397
1724
  }
2398
- console.log();
2399
- }
2400
1725
 
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
- }
1726
+ return {
1727
+ name: node.name,
1728
+ kind: node.kind,
1729
+ file: node.file,
1730
+ line: node.line,
1731
+ role: node.role || null,
1732
+ endLine: node.end_line || null,
1733
+ source,
1734
+ signature,
1735
+ complexity: complexityMetrics,
1736
+ children: nodeChildren.length > 0 ? nodeChildren : undefined,
1737
+ callees,
1738
+ callers,
1739
+ relatedTests,
1740
+ };
1741
+ });
1742
+
1743
+ const base = { name, results };
1744
+ return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
1745
+ } finally {
1746
+ db.close();
2407
1747
  }
2408
1748
  }
2409
1749
 
@@ -2411,62 +1751,40 @@ export function context(name, customDbPath, opts = {}) {
2411
1751
 
2412
1752
  export function childrenData(name, customDbPath, opts = {}) {
2413
1753
  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
- }
1754
+ try {
1755
+ const noTests = opts.noTests || false;
2421
1756
 
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 = [];
1757
+ const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
1758
+ if (nodes.length === 0) {
1759
+ return { name, results: [] };
2430
1760
  }
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
1761
 
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}`);
1762
+ const results = nodes.map((node) => {
1763
+ let children;
1764
+ try {
1765
+ children = findNodeChildren(db, node.id);
1766
+ } catch {
1767
+ children = [];
2468
1768
  }
2469
- }
1769
+ if (noTests) children = children.filter((c) => !isTestFile(c.file || node.file));
1770
+ return {
1771
+ name: node.name,
1772
+ kind: node.kind,
1773
+ file: node.file,
1774
+ line: node.line,
1775
+ children: children.map((c) => ({
1776
+ name: c.name,
1777
+ kind: c.kind,
1778
+ line: c.line,
1779
+ endLine: c.end_line || null,
1780
+ })),
1781
+ };
1782
+ });
1783
+
1784
+ const base = { name, results };
1785
+ return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
1786
+ } finally {
1787
+ db.close();
2470
1788
  }
2471
1789
  }
2472
1790
 
@@ -2483,28 +1801,14 @@ function isFileLikeTarget(target) {
2483
1801
  }
2484
1802
 
2485
1803
  function explainFileImpl(db, target, getFileLines) {
2486
- const fileNodes = db
2487
- .prepare(`SELECT * FROM nodes WHERE file LIKE ? AND kind = 'file'`)
2488
- .all(`%${target}%`);
1804
+ const fileNodes = findFileNodes(db, `%${target}%`);
2489
1805
  if (fileNodes.length === 0) return [];
2490
1806
 
2491
1807
  return fileNodes.map((fn) => {
2492
- const symbols = db
2493
- .prepare(`SELECT * FROM nodes WHERE file = ? AND kind != 'file' ORDER BY line`)
2494
- .all(fn.file);
1808
+ const symbols = findNodesByFile(db, fn.file);
2495
1809
 
2496
1810
  // IDs of symbols that have incoming calls from other files (public)
2497
- const publicIds = new Set(
2498
- db
2499
- .prepare(
2500
- `SELECT DISTINCT e.target_id FROM edges e
2501
- JOIN nodes caller ON e.source_id = caller.id
2502
- JOIN nodes target ON e.target_id = target.id
2503
- WHERE target.file = ? AND caller.file != ? AND e.kind = 'calls'`,
2504
- )
2505
- .all(fn.file, fn.file)
2506
- .map((r) => r.target_id),
2507
- );
1811
+ const publicIds = findCrossFileCallTargets(db, fn.file);
2508
1812
 
2509
1813
  const fileLines = getFileLines(fn.file);
2510
1814
  const mapSymbol = (s) => ({
@@ -2520,33 +1824,12 @@ function explainFileImpl(db, target, getFileLines) {
2520
1824
  const internal = symbols.filter((s) => !publicIds.has(s.id)).map(mapSymbol);
2521
1825
 
2522
1826
  // Imports / importedBy
2523
- const imports = db
2524
- .prepare(
2525
- `SELECT n.file FROM edges e JOIN nodes n ON e.target_id = n.id
2526
- WHERE e.source_id = ? AND e.kind IN ('imports', 'imports-type')`,
2527
- )
2528
- .all(fn.id)
2529
- .map((r) => ({ file: r.file }));
1827
+ const imports = findImportTargets(db, fn.id).map((r) => ({ file: r.file }));
2530
1828
 
2531
- const importedBy = db
2532
- .prepare(
2533
- `SELECT n.file FROM edges e JOIN nodes n ON e.source_id = n.id
2534
- WHERE e.target_id = ? AND e.kind IN ('imports', 'imports-type')`,
2535
- )
2536
- .all(fn.id)
2537
- .map((r) => ({ file: r.file }));
1829
+ const importedBy = findImportSources(db, fn.id).map((r) => ({ file: r.file }));
2538
1830
 
2539
1831
  // Intra-file data flow
2540
- const intraEdges = db
2541
- .prepare(
2542
- `SELECT caller.name as caller_name, callee.name as callee_name
2543
- FROM edges e
2544
- JOIN nodes caller ON e.source_id = caller.id
2545
- JOIN nodes callee ON e.target_id = callee.id
2546
- WHERE caller.file = ? AND callee.file = ? AND e.kind = 'calls'
2547
- ORDER BY caller.line`,
2548
- )
2549
- .all(fn.file, fn.file);
1832
+ const intraEdges = findIntraFileCallEdges(db, fn.file);
2550
1833
 
2551
1834
  const dataFlowMap = new Map();
2552
1835
  for (const edge of intraEdges) {
@@ -2599,43 +1882,31 @@ function explainFunctionImpl(db, target, noTests, getFileLines) {
2599
1882
  const summary = fileLines ? extractSummary(fileLines, node.line) : null;
2600
1883
  const signature = fileLines ? extractSignature(fileLines, node.line) : null;
2601
1884
 
2602
- const callees = db
2603
- .prepare(
2604
- `SELECT n.name, n.kind, n.file, n.line
2605
- FROM edges e JOIN nodes n ON e.target_id = n.id
2606
- WHERE e.source_id = ? AND e.kind = 'calls'`,
2607
- )
2608
- .all(node.id)
2609
- .map((c) => ({ name: c.name, kind: c.kind, file: c.file, line: c.line }));
1885
+ const callees = findCallees(db, node.id).map((c) => ({
1886
+ name: c.name,
1887
+ kind: c.kind,
1888
+ file: c.file,
1889
+ line: c.line,
1890
+ }));
2610
1891
 
2611
- let callers = db
2612
- .prepare(
2613
- `SELECT n.name, n.kind, n.file, n.line
2614
- FROM edges e JOIN nodes n ON e.source_id = n.id
2615
- WHERE e.target_id = ? AND e.kind = 'calls'`,
2616
- )
2617
- .all(node.id)
2618
- .map((c) => ({ name: c.name, kind: c.kind, file: c.file, line: c.line }));
1892
+ let callers = findCallers(db, node.id).map((c) => ({
1893
+ name: c.name,
1894
+ kind: c.kind,
1895
+ file: c.file,
1896
+ line: c.line,
1897
+ }));
2619
1898
  if (noTests) callers = callers.filter((c) => !isTestFile(c.file));
2620
1899
 
2621
- const testCallerRows = db
2622
- .prepare(
2623
- `SELECT DISTINCT n.file FROM edges e JOIN nodes n ON e.source_id = n.id
2624
- WHERE e.target_id = ? AND e.kind = 'calls'`,
2625
- )
2626
- .all(node.id);
1900
+ const testCallerRows = findCallers(db, node.id);
1901
+ const seenFiles = new Set();
2627
1902
  const relatedTests = testCallerRows
2628
- .filter((r) => isTestFile(r.file))
1903
+ .filter((r) => isTestFile(r.file) && !seenFiles.has(r.file) && seenFiles.add(r.file))
2629
1904
  .map((r) => ({ file: r.file }));
2630
1905
 
2631
1906
  // Complexity metrics
2632
1907
  let complexityMetrics = null;
2633
1908
  try {
2634
- const cRow = db
2635
- .prepare(
2636
- 'SELECT cognitive, cyclomatic, max_nesting, maintainability_index, halstead_volume FROM function_complexity WHERE node_id = ?',
2637
- )
2638
- .get(node.id);
1909
+ const cRow = getComplexityForNode(db, node.id);
2639
1910
  if (cRow) {
2640
1911
  complexityMetrics = {
2641
1912
  cognitive: cRow.cognitive,
@@ -2664,200 +1935,73 @@ function explainFunctionImpl(db, target, noTests, getFileLines) {
2664
1935
 
2665
1936
  export function explainData(target, customDbPath, opts = {}) {
2666
1937
  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) {
1938
+ try {
1939
+ const noTests = opts.noTests || false;
1940
+ const depth = opts.depth || 0;
1941
+ const kind = isFileLikeTarget(target) ? 'file' : 'function';
1942
+
1943
+ const dbPath = findDbPath(customDbPath);
1944
+ const repoRoot = path.resolve(path.dirname(dbPath), '..');
1945
+
1946
+ const fileCache = new Map();
1947
+ function getFileLines(file) {
1948
+ if (fileCache.has(file)) return fileCache.get(file);
1949
+ try {
1950
+ const absPath = safePath(repoRoot, file);
1951
+ if (!absPath) {
1952
+ fileCache.set(file, null);
1953
+ return null;
1954
+ }
1955
+ const lines = fs.readFileSync(absPath, 'utf-8').split('\n');
1956
+ fileCache.set(file, lines);
1957
+ return lines;
1958
+ } catch (e) {
1959
+ debug(`getFileLines failed for ${file}: ${e.message}`);
2680
1960
  fileCache.set(file, null);
2681
1961
  return null;
2682
1962
  }
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
1963
  }
2691
- }
2692
1964
 
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);
1965
+ const results =
1966
+ kind === 'file'
1967
+ ? explainFileImpl(db, target, getFileLines)
1968
+ : explainFunctionImpl(db, target, noTests, getFileLines);
1969
+
1970
+ // Recursive dependency explanation for function targets
1971
+ if (kind === 'function' && depth > 0 && results.length > 0) {
1972
+ const visited = new Set(results.map((r) => `${r.name}:${r.file}:${r.line}`));
1973
+
1974
+ function explainCallees(parentResults, currentDepth) {
1975
+ if (currentDepth <= 0) return;
1976
+ for (const r of parentResults) {
1977
+ const newCallees = [];
1978
+ for (const callee of r.callees) {
1979
+ const key = `${callee.name}:${callee.file}:${callee.line}`;
1980
+ if (visited.has(key)) continue;
1981
+ visited.add(key);
1982
+ const calleeResults = explainFunctionImpl(db, callee.name, noTests, getFileLines);
1983
+ const exact = calleeResults.find(
1984
+ (cr) => cr.file === callee.file && cr.line === callee.line,
1985
+ );
1986
+ if (exact) {
1987
+ exact._depth = (r._depth || 0) + 1;
1988
+ newCallees.push(exact);
1989
+ }
1990
+ }
1991
+ if (newCallees.length > 0) {
1992
+ r.depDetails = newCallees;
1993
+ explainCallees(newCallees, currentDepth - 1);
2717
1994
  }
2718
1995
  }
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
1996
  }
2847
1997
 
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();
1998
+ explainCallees(results, depth);
2856
1999
  }
2857
2000
 
2858
- for (const r of data.results) {
2859
- printFunctionExplain(r);
2860
- }
2001
+ const base = { target, kind, results };
2002
+ return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
2003
+ } finally {
2004
+ db.close();
2861
2005
  }
2862
2006
  }
2863
2007
 
@@ -2909,20 +2053,10 @@ function whereSymbolImpl(db, target, noTests) {
2909
2053
 
2910
2054
  const hc = new Map();
2911
2055
  return nodes.map((node) => {
2912
- const crossFileCallers = db
2913
- .prepare(
2914
- `SELECT COUNT(*) as cnt FROM edges e JOIN nodes n ON e.source_id = n.id
2915
- WHERE e.target_id = ? AND e.kind = 'calls' AND n.file != ?`,
2916
- )
2917
- .get(node.id, node.file);
2918
- const exported = crossFileCallers.cnt > 0;
2056
+ const crossCount = countCrossFileCallers(db, node.id, node.file);
2057
+ const exported = crossCount > 0;
2919
2058
 
2920
- let uses = db
2921
- .prepare(
2922
- `SELECT n.name, n.file, n.line FROM edges e JOIN nodes n ON e.source_id = n.id
2923
- WHERE e.target_id = ? AND e.kind = 'calls'`,
2924
- )
2925
- .all(node.id);
2059
+ let uses = findCallers(db, node.id);
2926
2060
  if (noTests) uses = uses.filter((u) => !isTestFile(u.file));
2927
2061
 
2928
2062
  return {
@@ -2934,43 +2068,17 @@ function whereSymbolImpl(db, target, noTests) {
2934
2068
  }
2935
2069
 
2936
2070
  function whereFileImpl(db, target) {
2937
- const fileNodes = db
2938
- .prepare(`SELECT * FROM nodes WHERE file LIKE ? AND kind = 'file'`)
2939
- .all(`%${target}%`);
2071
+ const fileNodes = findFileNodes(db, `%${target}%`);
2940
2072
  if (fileNodes.length === 0) return [];
2941
2073
 
2942
2074
  return fileNodes.map((fn) => {
2943
- const symbols = db
2944
- .prepare(`SELECT * FROM nodes WHERE file = ? AND kind != 'file' ORDER BY line`)
2945
- .all(fn.file);
2075
+ const symbols = findNodesByFile(db, fn.file);
2946
2076
 
2947
- const imports = db
2948
- .prepare(
2949
- `SELECT n.file FROM edges e JOIN nodes n ON e.target_id = n.id
2950
- WHERE e.source_id = ? AND e.kind IN ('imports', 'imports-type')`,
2951
- )
2952
- .all(fn.id)
2953
- .map((r) => r.file);
2077
+ const imports = findImportTargets(db, fn.id).map((r) => r.file);
2954
2078
 
2955
- const importedBy = db
2956
- .prepare(
2957
- `SELECT n.file FROM edges e JOIN nodes n ON e.source_id = n.id
2958
- WHERE e.target_id = ? AND e.kind IN ('imports', 'imports-type')`,
2959
- )
2960
- .all(fn.id)
2961
- .map((r) => r.file);
2079
+ const importedBy = findImportSources(db, fn.id).map((r) => r.file);
2962
2080
 
2963
- const exportedIds = new Set(
2964
- db
2965
- .prepare(
2966
- `SELECT DISTINCT e.target_id FROM edges e
2967
- JOIN nodes caller ON e.source_id = caller.id
2968
- JOIN nodes target ON e.target_id = target.id
2969
- WHERE target.file = ? AND caller.file != ? AND e.kind = 'calls'`,
2970
- )
2971
- .all(fn.file, fn.file)
2972
- .map((r) => r.target_id),
2973
- );
2081
+ const exportedIds = findCrossFileCallTargets(db, fn.file);
2974
2082
 
2975
2083
  const exported = symbols.filter((s) => exportedIds.has(s.id)).map((s) => s.name);
2976
2084
 
@@ -2987,157 +2095,66 @@ function whereFileImpl(db, target) {
2987
2095
 
2988
2096
  export function whereData(target, customDbPath, opts = {}) {
2989
2097
  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
- }
2098
+ try {
2099
+ const noTests = opts.noTests || false;
2100
+ const fileMode = opts.file || false;
3010
2101
 
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
- }
2102
+ const results = fileMode ? whereFileImpl(db, target) : whereSymbolImpl(db, target, noTests);
3019
2103
 
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
- }
2104
+ const base = { target, mode: fileMode ? 'file' : 'symbol', results };
2105
+ return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
2106
+ } finally {
2107
+ db.close();
3049
2108
  }
3050
- console.log();
3051
2109
  }
3052
2110
 
3053
2111
  // ─── rolesData ──────────────────────────────────────────────────────────
3054
2112
 
3055
2113
  export function rolesData(customDbPath, opts = {}) {
3056
2114
  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
- }
2115
+ try {
2116
+ const noTests = opts.noTests || false;
2117
+ const filterRole = opts.role || null;
2118
+ const filterFile = opts.file || null;
3103
2119
 
3104
- if (data.count === 0) {
3105
- console.log('No classified symbols found. Run "codegraph build" first.');
3106
- return;
3107
- }
2120
+ const conditions = ['role IS NOT NULL'];
2121
+ const params = [];
3108
2122
 
3109
- const total = data.count;
3110
- console.log(`\nNode roles (${total} symbols):\n`);
2123
+ if (filterRole) {
2124
+ conditions.push('role = ?');
2125
+ params.push(filterRole);
2126
+ }
2127
+ if (filterFile) {
2128
+ conditions.push('file LIKE ?');
2129
+ params.push(`%${filterFile}%`);
2130
+ }
3111
2131
 
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`);
2132
+ let rows = db
2133
+ .prepare(
2134
+ `SELECT name, kind, file, line, end_line, role FROM nodes WHERE ${conditions.join(' AND ')} ORDER BY role, file, line`,
2135
+ )
2136
+ .all(...params);
3116
2137
 
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
- }
2138
+ if (noTests) rows = rows.filter((r) => !isTestFile(r.file));
3122
2139
 
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}`);
2140
+ const summary = {};
2141
+ for (const r of rows) {
2142
+ summary[r.role] = (summary[r.role] || 0) + 1;
3127
2143
  }
3128
- if (symbols.length > 30) {
3129
- console.log(` ... and ${symbols.length - 30} more`);
3130
- }
3131
- console.log();
2144
+
2145
+ const hc = new Map();
2146
+ const symbols = rows.map((r) => normalizeSymbol(r, db, hc));
2147
+ const base = { count: symbols.length, summary, symbols };
2148
+ return paginateResult(base, 'symbols', { limit: opts.limit, offset: opts.offset });
2149
+ } finally {
2150
+ db.close();
3132
2151
  }
3133
2152
  }
3134
2153
 
3135
2154
  // ─── exportsData ─────────────────────────────────────────────────────
3136
2155
 
3137
2156
  function exportsFileImpl(db, target, noTests, getFileLines, unused) {
3138
- const fileNodes = db
3139
- .prepare(`SELECT * FROM nodes WHERE file LIKE ? AND kind = 'file'`)
3140
- .all(`%${target}%`);
2157
+ const fileNodes = findFileNodes(db, `%${target}%`);
3141
2158
  if (fileNodes.length === 0) return [];
3142
2159
 
3143
2160
  // Detect whether exported column exists
@@ -3150,9 +2167,7 @@ function exportsFileImpl(db, target, noTests, getFileLines, unused) {
3150
2167
  }
3151
2168
 
3152
2169
  return fileNodes.map((fn) => {
3153
- const symbols = db
3154
- .prepare(`SELECT * FROM nodes WHERE file = ? AND kind != 'file' ORDER BY line`)
3155
- .all(fn.file);
2170
+ const symbols = findNodesByFile(db, fn.file);
3156
2171
 
3157
2172
  let exported;
3158
2173
  if (hasExportedCol) {
@@ -3164,17 +2179,7 @@ function exportsFileImpl(db, target, noTests, getFileLines, unused) {
3164
2179
  .all(fn.file);
3165
2180
  } else {
3166
2181
  // Fallback: symbols that have incoming calls from other files
3167
- const exportedIds = new Set(
3168
- db
3169
- .prepare(
3170
- `SELECT DISTINCT e.target_id FROM edges e
3171
- JOIN nodes caller ON e.source_id = caller.id
3172
- JOIN nodes target ON e.target_id = target.id
3173
- WHERE target.file = ? AND caller.file != ? AND e.kind = 'calls'`,
3174
- )
3175
- .all(fn.file, fn.file)
3176
- .map((r) => r.target_id),
3177
- );
2182
+ const exportedIds = findCrossFileCallTargets(db, fn.file);
3178
2183
  exported = symbols.filter((s) => exportedIds.has(s.id));
3179
2184
  }
3180
2185
  const internalCount = symbols.length - exported.length;
@@ -3232,203 +2237,53 @@ function exportsFileImpl(db, target, noTests, getFileLines, unused) {
3232
2237
 
3233
2238
  export function exportsData(file, customDbPath, opts = {}) {
3234
2239
  const db = openReadonlyOrFail(customDbPath);
3235
- const noTests = opts.noTests || false;
3236
-
3237
- const dbFilePath = findDbPath(customDbPath);
3238
- const repoRoot = path.resolve(path.dirname(dbFilePath), '..');
2240
+ try {
2241
+ const noTests = opts.noTests || false;
3239
2242
 
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) {
2243
+ const dbFilePath = findDbPath(customDbPath);
2244
+ const repoRoot = path.resolve(path.dirname(dbFilePath), '..');
2245
+
2246
+ const fileCache = new Map();
2247
+ function getFileLines(file) {
2248
+ if (fileCache.has(file)) return fileCache.get(file);
2249
+ try {
2250
+ const absPath = safePath(repoRoot, file);
2251
+ if (!absPath) {
2252
+ fileCache.set(file, null);
2253
+ return null;
2254
+ }
2255
+ const lines = fs.readFileSync(absPath, 'utf-8').split('\n');
2256
+ fileCache.set(file, lines);
2257
+ return lines;
2258
+ } catch {
3246
2259
  fileCache.set(file, null);
3247
2260
  return null;
3248
2261
  }
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
2262
  }
3362
- console.log(`\n Total: ${r.totalDependents} functions transitively depend on ${r.name}\n`);
3363
- }
3364
- }
3365
2263
 
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
- }
2264
+ const unused = opts.unused || false;
2265
+ const fileResults = exportsFileImpl(db, file, noTests, getFileLines, unused);
3394
2266
 
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)`,
2267
+ if (fileResults.length === 0) {
2268
+ return paginateResult(
2269
+ { file, results: [], reexports: [], totalExported: 0, totalInternal: 0, totalUnused: 0 },
2270
+ 'results',
2271
+ { limit: opts.limit, offset: opts.offset },
3407
2272
  );
3408
2273
  }
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`);
2274
+
2275
+ // For single-file match return flat; for multi-match return first (like explainData)
2276
+ const first = fileResults[0];
2277
+ const base = {
2278
+ file: first.file,
2279
+ results: first.results,
2280
+ reexports: first.reexports,
2281
+ totalExported: first.totalExported,
2282
+ totalInternal: first.totalInternal,
2283
+ totalUnused: first.totalUnused,
2284
+ };
2285
+ return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
2286
+ } finally {
2287
+ db.close();
3433
2288
  }
3434
2289
  }