@optave/codegraph 2.1.1-dev.3c12b64 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/queries.js CHANGED
@@ -3,13 +3,70 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { findCycles } from './cycles.js';
5
5
  import { findDbPath, openReadonlyOrFail } from './db.js';
6
+ import { debug } from './logger.js';
6
7
  import { LANGUAGE_REGISTRY } from './parser.js';
7
8
 
9
+ /**
10
+ * Resolve a file path relative to repoRoot, rejecting traversal outside the repo.
11
+ * Returns null if the resolved path escapes repoRoot.
12
+ */
13
+ function safePath(repoRoot, file) {
14
+ const resolved = path.resolve(repoRoot, file);
15
+ if (!resolved.startsWith(repoRoot + path.sep) && resolved !== repoRoot) return null;
16
+ return resolved;
17
+ }
18
+
8
19
  const TEST_PATTERN = /\.(test|spec)\.|__test__|__tests__|\.stories\./;
9
- function isTestFile(filePath) {
20
+ export function isTestFile(filePath) {
10
21
  return TEST_PATTERN.test(filePath);
11
22
  }
12
23
 
24
+ export const FALSE_POSITIVE_NAMES = new Set([
25
+ 'run',
26
+ 'get',
27
+ 'set',
28
+ 'init',
29
+ 'start',
30
+ 'handle',
31
+ 'main',
32
+ 'new',
33
+ 'create',
34
+ 'update',
35
+ 'delete',
36
+ 'process',
37
+ 'execute',
38
+ 'call',
39
+ 'apply',
40
+ 'setup',
41
+ 'render',
42
+ 'build',
43
+ 'load',
44
+ 'save',
45
+ 'find',
46
+ 'make',
47
+ 'open',
48
+ 'close',
49
+ 'reset',
50
+ 'send',
51
+ 'read',
52
+ 'write',
53
+ ]);
54
+ export const FALSE_POSITIVE_CALLER_THRESHOLD = 20;
55
+
56
+ const FUNCTION_KINDS = ['function', 'method', 'class'];
57
+ export const ALL_SYMBOL_KINDS = [
58
+ 'function',
59
+ 'method',
60
+ 'class',
61
+ 'interface',
62
+ 'type',
63
+ 'struct',
64
+ 'enum',
65
+ 'trait',
66
+ 'record',
67
+ 'module',
68
+ ];
69
+
13
70
  /**
14
71
  * Get all ancestor class names for a given class using extends edges.
15
72
  */
@@ -60,6 +117,58 @@ function resolveMethodViaHierarchy(db, methodName) {
60
117
  return results;
61
118
  }
62
119
 
120
+ /**
121
+ * Find nodes matching a name query, ranked by relevance.
122
+ * Scoring: exact=100, prefix=60, word-boundary=40, substring=10, plus fan-in tiebreaker.
123
+ */
124
+ function findMatchingNodes(db, name, opts = {}) {
125
+ const kinds = opts.kind ? [opts.kind] : FUNCTION_KINDS;
126
+ const placeholders = kinds.map(() => '?').join(', ');
127
+ const params = [`%${name}%`, ...kinds];
128
+
129
+ let fileCondition = '';
130
+ if (opts.file) {
131
+ fileCondition = ' AND n.file LIKE ?';
132
+ params.push(`%${opts.file}%`);
133
+ }
134
+
135
+ const rows = db
136
+ .prepare(`
137
+ SELECT n.*, COALESCE(fi.cnt, 0) AS fan_in
138
+ FROM nodes n
139
+ LEFT JOIN (
140
+ SELECT target_id, COUNT(*) AS cnt FROM edges WHERE kind = 'calls' GROUP BY target_id
141
+ ) fi ON fi.target_id = n.id
142
+ WHERE n.name LIKE ? AND n.kind IN (${placeholders})${fileCondition}
143
+ `)
144
+ .all(...params);
145
+
146
+ const nodes = opts.noTests ? rows.filter((n) => !isTestFile(n.file)) : rows;
147
+
148
+ const lowerQuery = name.toLowerCase();
149
+ for (const node of nodes) {
150
+ const lowerName = node.name.toLowerCase();
151
+ const bareName = lowerName.includes('.') ? lowerName.split('.').pop() : lowerName;
152
+
153
+ let matchScore;
154
+ if (lowerName === lowerQuery || bareName === lowerQuery) {
155
+ matchScore = 100;
156
+ } else if (lowerName.startsWith(lowerQuery) || bareName.startsWith(lowerQuery)) {
157
+ matchScore = 60;
158
+ } else if (lowerName.includes(`.${lowerQuery}`) || lowerName.includes(`${lowerQuery}.`)) {
159
+ matchScore = 40;
160
+ } else {
161
+ matchScore = 10;
162
+ }
163
+
164
+ const fanInBonus = Math.min(Math.log2(node.fan_in + 1) * 5, 25);
165
+ node._relevance = matchScore + fanInBonus;
166
+ }
167
+
168
+ nodes.sort((a, b) => b._relevance - a._relevance);
169
+ return nodes;
170
+ }
171
+
63
172
  function kindIcon(kind) {
64
173
  switch (kind) {
65
174
  case 'function':
@@ -81,16 +190,18 @@ function kindIcon(kind) {
81
190
 
82
191
  // ─── Data-returning functions ───────────────────────────────────────────
83
192
 
84
- export function queryNameData(name, customDbPath) {
193
+ export function queryNameData(name, customDbPath, opts = {}) {
85
194
  const db = openReadonlyOrFail(customDbPath);
86
- const nodes = db.prepare(`SELECT * FROM nodes WHERE name LIKE ?`).all(`%${name}%`);
195
+ const noTests = opts.noTests || false;
196
+ let nodes = db.prepare(`SELECT * FROM nodes WHERE name LIKE ?`).all(`%${name}%`);
197
+ if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
87
198
  if (nodes.length === 0) {
88
199
  db.close();
89
200
  return { query: name, results: [] };
90
201
  }
91
202
 
92
203
  const results = nodes.map((node) => {
93
- const callees = db
204
+ let callees = db
94
205
  .prepare(`
95
206
  SELECT n.name, n.kind, n.file, n.line, e.kind as edge_kind
96
207
  FROM edges e JOIN nodes n ON e.target_id = n.id
@@ -98,7 +209,7 @@ export function queryNameData(name, customDbPath) {
98
209
  `)
99
210
  .all(node.id);
100
211
 
101
- const callers = db
212
+ let callers = db
102
213
  .prepare(`
103
214
  SELECT n.name, n.kind, n.file, n.line, e.kind as edge_kind
104
215
  FROM edges e JOIN nodes n ON e.source_id = n.id
@@ -106,6 +217,11 @@ export function queryNameData(name, customDbPath) {
106
217
  `)
107
218
  .all(node.id);
108
219
 
220
+ if (noTests) {
221
+ callees = callees.filter((c) => !isTestFile(c.file));
222
+ callers = callers.filter((c) => !isTestFile(c.file));
223
+ }
224
+
109
225
  return {
110
226
  name: node.name,
111
227
  kind: node.kind,
@@ -132,8 +248,9 @@ export function queryNameData(name, customDbPath) {
132
248
  return { query: name, results };
133
249
  }
134
250
 
135
- export function impactAnalysisData(file, customDbPath) {
251
+ export function impactAnalysisData(file, customDbPath, opts = {}) {
136
252
  const db = openReadonlyOrFail(customDbPath);
253
+ const noTests = opts.noTests || false;
137
254
  const fileNodes = db
138
255
  .prepare(`SELECT * FROM nodes WHERE file LIKE ? AND kind = 'file'`)
139
256
  .all(`%${file}%`);
@@ -162,7 +279,7 @@ export function impactAnalysisData(file, customDbPath) {
162
279
  `)
163
280
  .all(current);
164
281
  for (const dep of dependents) {
165
- if (!visited.has(dep.id)) {
282
+ if (!visited.has(dep.id) && (!noTests || !isTestFile(dep.file))) {
166
283
  visited.add(dep.id);
167
284
  queue.push(dep.id);
168
285
  levels.set(dep.id, level + 1);
@@ -187,8 +304,17 @@ export function impactAnalysisData(file, customDbPath) {
187
304
  };
188
305
  }
189
306
 
190
- export function moduleMapData(customDbPath, limit = 20) {
307
+ export function moduleMapData(customDbPath, limit = 20, opts = {}) {
191
308
  const db = openReadonlyOrFail(customDbPath);
309
+ const noTests = opts.noTests || false;
310
+
311
+ const testFilter = noTests
312
+ ? `AND n.file NOT LIKE '%.test.%'
313
+ AND n.file NOT LIKE '%.spec.%'
314
+ AND n.file NOT LIKE '%__test__%'
315
+ AND n.file NOT LIKE '%__tests__%'
316
+ AND n.file NOT LIKE '%.stories.%'`
317
+ : '';
192
318
 
193
319
  const nodes = db
194
320
  .prepare(`
@@ -197,9 +323,7 @@ export function moduleMapData(customDbPath, limit = 20) {
197
323
  (SELECT COUNT(*) FROM edges WHERE target_id = n.id AND kind != 'contains') as in_edges
198
324
  FROM nodes n
199
325
  WHERE n.kind = 'file'
200
- AND n.file NOT LIKE '%.test.%'
201
- AND n.file NOT LIKE '%.spec.%'
202
- AND n.file NOT LIKE '%__test__%'
326
+ ${testFilter}
203
327
  ORDER BY (SELECT COUNT(*) FROM edges WHERE target_id = n.id AND kind != 'contains') DESC
204
328
  LIMIT ?
205
329
  `)
@@ -220,8 +344,9 @@ export function moduleMapData(customDbPath, limit = 20) {
220
344
  return { limit, topNodes, stats: { totalFiles, totalNodes, totalEdges } };
221
345
  }
222
346
 
223
- export function fileDepsData(file, customDbPath) {
347
+ export function fileDepsData(file, customDbPath, opts = {}) {
224
348
  const db = openReadonlyOrFail(customDbPath);
349
+ const noTests = opts.noTests || false;
225
350
  const fileNodes = db
226
351
  .prepare(`SELECT * FROM nodes WHERE file LIKE ? AND kind = 'file'`)
227
352
  .all(`%${file}%`);
@@ -231,19 +356,21 @@ export function fileDepsData(file, customDbPath) {
231
356
  }
232
357
 
233
358
  const results = fileNodes.map((fn) => {
234
- const importsTo = db
359
+ let importsTo = db
235
360
  .prepare(`
236
361
  SELECT n.file, e.kind as edge_kind FROM edges e JOIN nodes n ON e.target_id = n.id
237
362
  WHERE e.source_id = ? AND e.kind IN ('imports', 'imports-type')
238
363
  `)
239
364
  .all(fn.id);
365
+ if (noTests) importsTo = importsTo.filter((i) => !isTestFile(i.file));
240
366
 
241
- const importedBy = db
367
+ let importedBy = db
242
368
  .prepare(`
243
369
  SELECT n.file, e.kind as edge_kind FROM edges e JOIN nodes n ON e.source_id = n.id
244
370
  WHERE e.target_id = ? AND e.kind IN ('imports', 'imports-type')
245
371
  `)
246
372
  .all(fn.id);
373
+ if (noTests) importedBy = importedBy.filter((i) => !isTestFile(i.file));
247
374
 
248
375
  const defs = db
249
376
  .prepare(`SELECT * FROM nodes WHERE file = ? AND kind != 'file' ORDER BY line`)
@@ -266,12 +393,7 @@ export function fnDepsData(name, customDbPath, opts = {}) {
266
393
  const depth = opts.depth || 3;
267
394
  const noTests = opts.noTests || false;
268
395
 
269
- let nodes = db
270
- .prepare(
271
- `SELECT * FROM nodes WHERE name LIKE ? AND kind IN ('function', 'method', 'class') ORDER BY file, line`,
272
- )
273
- .all(`%${name}%`);
274
- if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
396
+ const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
275
397
  if (nodes.length === 0) {
276
398
  db.close();
277
399
  return { name, results: [] };
@@ -391,10 +513,7 @@ export function fnImpactData(name, customDbPath, opts = {}) {
391
513
  const maxDepth = opts.depth || 5;
392
514
  const noTests = opts.noTests || false;
393
515
 
394
- let nodes = db
395
- .prepare(`SELECT * FROM nodes WHERE name LIKE ? AND kind IN ('function', 'method', 'class')`)
396
- .all(`%${name}%`);
397
- if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
516
+ const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
398
517
  if (nodes.length === 0) {
399
518
  db.close();
400
519
  return { name, results: [] };
@@ -616,11 +735,40 @@ export function listFunctionsData(customDbPath, opts = {}) {
616
735
  return { count: rows.length, functions: rows };
617
736
  }
618
737
 
619
- export function statsData(customDbPath) {
738
+ export function statsData(customDbPath, opts = {}) {
620
739
  const db = openReadonlyOrFail(customDbPath);
740
+ const noTests = opts.noTests || false;
741
+
742
+ // Build set of test file IDs for filtering nodes and edges
743
+ let testFileIds = null;
744
+ if (noTests) {
745
+ const allFileNodes = db.prepare("SELECT id, file FROM nodes WHERE kind = 'file'").all();
746
+ testFileIds = new Set();
747
+ const testFiles = new Set();
748
+ for (const n of allFileNodes) {
749
+ if (isTestFile(n.file)) {
750
+ testFileIds.add(n.id);
751
+ testFiles.add(n.file);
752
+ }
753
+ }
754
+ // Also collect non-file node IDs that belong to test files
755
+ const allNodes = db.prepare('SELECT id, file FROM nodes').all();
756
+ for (const n of allNodes) {
757
+ if (testFiles.has(n.file)) testFileIds.add(n.id);
758
+ }
759
+ }
621
760
 
622
761
  // Node breakdown by kind
623
- const nodeRows = db.prepare('SELECT kind, COUNT(*) as c FROM nodes GROUP BY kind').all();
762
+ let nodeRows;
763
+ if (noTests) {
764
+ const allNodes = db.prepare('SELECT id, kind, file FROM nodes').all();
765
+ const filtered = allNodes.filter((n) => !testFileIds.has(n.id));
766
+ const counts = {};
767
+ for (const n of filtered) counts[n.kind] = (counts[n.kind] || 0) + 1;
768
+ nodeRows = Object.entries(counts).map(([kind, c]) => ({ kind, c }));
769
+ } else {
770
+ nodeRows = db.prepare('SELECT kind, COUNT(*) as c FROM nodes GROUP BY kind').all();
771
+ }
624
772
  const nodesByKind = {};
625
773
  let totalNodes = 0;
626
774
  for (const r of nodeRows) {
@@ -629,7 +777,18 @@ export function statsData(customDbPath) {
629
777
  }
630
778
 
631
779
  // Edge breakdown by kind
632
- const edgeRows = db.prepare('SELECT kind, COUNT(*) as c FROM edges GROUP BY kind').all();
780
+ let edgeRows;
781
+ if (noTests) {
782
+ const allEdges = db.prepare('SELECT source_id, target_id, kind FROM edges').all();
783
+ const filtered = allEdges.filter(
784
+ (e) => !testFileIds.has(e.source_id) && !testFileIds.has(e.target_id),
785
+ );
786
+ const counts = {};
787
+ for (const e of filtered) counts[e.kind] = (counts[e.kind] || 0) + 1;
788
+ edgeRows = Object.entries(counts).map(([kind, c]) => ({ kind, c }));
789
+ } else {
790
+ edgeRows = db.prepare('SELECT kind, COUNT(*) as c FROM edges GROUP BY kind').all();
791
+ }
633
792
  const edgesByKind = {};
634
793
  let totalEdges = 0;
635
794
  for (const r of edgeRows) {
@@ -644,7 +803,8 @@ export function statsData(customDbPath) {
644
803
  extToLang.set(ext, entry.id);
645
804
  }
646
805
  }
647
- const fileNodes = db.prepare("SELECT file FROM nodes WHERE kind = 'file'").all();
806
+ let fileNodes = db.prepare("SELECT file FROM nodes WHERE kind = 'file'").all();
807
+ if (noTests) fileNodes = fileNodes.filter((n) => !isTestFile(n.file));
648
808
  const byLanguage = {};
649
809
  for (const row of fileNodes) {
650
810
  const ext = path.extname(row.file).toLowerCase();
@@ -654,23 +814,30 @@ export function statsData(customDbPath) {
654
814
  const langCount = Object.keys(byLanguage).length;
655
815
 
656
816
  // Cycles
657
- const fileCycles = findCycles(db, { fileLevel: true });
658
- const fnCycles = findCycles(db, { fileLevel: false });
817
+ const fileCycles = findCycles(db, { fileLevel: true, noTests });
818
+ const fnCycles = findCycles(db, { fileLevel: false, noTests });
659
819
 
660
820
  // Top 5 coupling hotspots (fan-in + fan-out, file nodes)
821
+ const testFilter = noTests
822
+ ? `AND n.file NOT LIKE '%.test.%'
823
+ AND n.file NOT LIKE '%.spec.%'
824
+ AND n.file NOT LIKE '%__test__%'
825
+ AND n.file NOT LIKE '%__tests__%'
826
+ AND n.file NOT LIKE '%.stories.%'`
827
+ : '';
661
828
  const hotspotRows = db
662
829
  .prepare(`
663
830
  SELECT n.file,
664
831
  (SELECT COUNT(*) FROM edges WHERE target_id = n.id) as fan_in,
665
832
  (SELECT COUNT(*) FROM edges WHERE source_id = n.id) as fan_out
666
833
  FROM nodes n
667
- WHERE n.kind = 'file'
834
+ WHERE n.kind = 'file' ${testFilter}
668
835
  ORDER BY (SELECT COUNT(*) FROM edges WHERE target_id = n.id)
669
836
  + (SELECT COUNT(*) FROM edges WHERE source_id = n.id) DESC
670
- LIMIT 5
671
837
  `)
672
838
  .all();
673
- const hotspots = hotspotRows.map((r) => ({
839
+ const filteredHotspots = noTests ? hotspotRows.filter((r) => !isTestFile(r.file)) : hotspotRows;
840
+ const hotspots = filteredHotspots.slice(0, 5).map((r) => ({
674
841
  file: r.file,
675
842
  fanIn: r.fan_in,
676
843
  fanOut: r.fan_out,
@@ -695,6 +862,70 @@ export function statsData(customDbPath) {
695
862
  /* embeddings table may not exist */
696
863
  }
697
864
 
865
+ // Graph quality metrics
866
+ const qualityTestFilter = testFilter.replace(/n\.file/g, 'file');
867
+ const totalCallable = db
868
+ .prepare(
869
+ `SELECT COUNT(*) as c FROM nodes WHERE kind IN ('function', 'method') ${qualityTestFilter}`,
870
+ )
871
+ .get().c;
872
+ const callableWithCallers = db
873
+ .prepare(`
874
+ SELECT COUNT(DISTINCT e.target_id) as c FROM edges e
875
+ JOIN nodes n ON e.target_id = n.id
876
+ WHERE e.kind = 'calls' AND n.kind IN ('function', 'method') ${testFilter}
877
+ `)
878
+ .get().c;
879
+ const callerCoverage = totalCallable > 0 ? callableWithCallers / totalCallable : 0;
880
+
881
+ const totalCallEdges = db.prepare("SELECT COUNT(*) as c FROM edges WHERE kind = 'calls'").get().c;
882
+ const highConfCallEdges = db
883
+ .prepare("SELECT COUNT(*) as c FROM edges WHERE kind = 'calls' AND confidence >= 0.7")
884
+ .get().c;
885
+ const callConfidence = totalCallEdges > 0 ? highConfCallEdges / totalCallEdges : 0;
886
+
887
+ // False-positive warnings: generic names with > threshold callers
888
+ const fpRows = db
889
+ .prepare(`
890
+ SELECT n.name, n.file, n.line, COUNT(e.source_id) as caller_count
891
+ FROM nodes n
892
+ LEFT JOIN edges e ON n.id = e.target_id AND e.kind = 'calls'
893
+ WHERE n.kind IN ('function', 'method')
894
+ GROUP BY n.id
895
+ HAVING caller_count > ?
896
+ ORDER BY caller_count DESC
897
+ `)
898
+ .all(FALSE_POSITIVE_CALLER_THRESHOLD);
899
+ const falsePositiveWarnings = fpRows
900
+ .filter((r) =>
901
+ FALSE_POSITIVE_NAMES.has(r.name.includes('.') ? r.name.split('.').pop() : r.name),
902
+ )
903
+ .map((r) => ({ name: r.name, file: r.file, line: r.line, callerCount: r.caller_count }));
904
+
905
+ // Edges from suspicious nodes
906
+ let fpEdgeCount = 0;
907
+ for (const fp of falsePositiveWarnings) fpEdgeCount += fp.callerCount;
908
+ const falsePositiveRatio = totalCallEdges > 0 ? fpEdgeCount / totalCallEdges : 0;
909
+
910
+ const score = Math.round(
911
+ callerCoverage * 40 + callConfidence * 40 + (1 - falsePositiveRatio) * 20,
912
+ );
913
+
914
+ const quality = {
915
+ score,
916
+ callerCoverage: {
917
+ ratio: callerCoverage,
918
+ covered: callableWithCallers,
919
+ total: totalCallable,
920
+ },
921
+ callConfidence: {
922
+ ratio: callConfidence,
923
+ highConf: highConfCallEdges,
924
+ total: totalCallEdges,
925
+ },
926
+ falsePositiveWarnings,
927
+ };
928
+
698
929
  db.close();
699
930
  return {
700
931
  nodes: { total: totalNodes, byKind: nodesByKind },
@@ -703,11 +934,12 @@ export function statsData(customDbPath) {
703
934
  cycles: { fileLevel: fileCycles.length, functionLevel: fnCycles.length },
704
935
  hotspots,
705
936
  embeddings,
937
+ quality,
706
938
  };
707
939
  }
708
940
 
709
941
  export function stats(customDbPath, opts = {}) {
710
- const data = statsData(customDbPath);
942
+ const data = statsData(customDbPath, { noTests: opts.noTests });
711
943
  if (opts.json) {
712
944
  console.log(JSON.stringify(data, null, 2));
713
945
  return;
@@ -779,13 +1011,33 @@ export function stats(customDbPath, opts = {}) {
779
1011
  console.log('\nEmbeddings: not built');
780
1012
  }
781
1013
 
1014
+ // Quality
1015
+ if (data.quality) {
1016
+ const q = data.quality;
1017
+ const cc = q.callerCoverage;
1018
+ const cf = q.callConfidence;
1019
+ console.log(`\nGraph Quality: ${q.score}/100`);
1020
+ console.log(
1021
+ ` Caller coverage: ${(cc.ratio * 100).toFixed(1)}% (${cc.covered}/${cc.total} functions have >=1 caller)`,
1022
+ );
1023
+ console.log(
1024
+ ` Call confidence: ${(cf.ratio * 100).toFixed(1)}% (${cf.highConf}/${cf.total} call edges are high-confidence)`,
1025
+ );
1026
+ if (q.falsePositiveWarnings.length > 0) {
1027
+ console.log(' False-positive warnings:');
1028
+ for (const fp of q.falsePositiveWarnings) {
1029
+ console.log(` ! ${fp.name} (${fp.callerCount} callers) -- ${fp.file}:${fp.line}`);
1030
+ }
1031
+ }
1032
+ }
1033
+
782
1034
  console.log();
783
1035
  }
784
1036
 
785
1037
  // ─── Human-readable output (original formatting) ───────────────────────
786
1038
 
787
1039
  export function queryName(name, customDbPath, opts = {}) {
788
- const data = queryNameData(name, customDbPath);
1040
+ const data = queryNameData(name, customDbPath, { noTests: opts.noTests });
789
1041
  if (opts.json) {
790
1042
  console.log(JSON.stringify(data, null, 2));
791
1043
  return;
@@ -815,7 +1067,7 @@ export function queryName(name, customDbPath, opts = {}) {
815
1067
  }
816
1068
 
817
1069
  export function impactAnalysis(file, customDbPath, opts = {}) {
818
- const data = impactAnalysisData(file, customDbPath);
1070
+ const data = impactAnalysisData(file, customDbPath, { noTests: opts.noTests });
819
1071
  if (opts.json) {
820
1072
  console.log(JSON.stringify(data, null, 2));
821
1073
  return;
@@ -846,7 +1098,7 @@ export function impactAnalysis(file, customDbPath, opts = {}) {
846
1098
  }
847
1099
 
848
1100
  export function moduleMap(customDbPath, limit = 20, opts = {}) {
849
- const data = moduleMapData(customDbPath, limit);
1101
+ const data = moduleMapData(customDbPath, limit, { noTests: opts.noTests });
850
1102
  if (opts.json) {
851
1103
  console.log(JSON.stringify(data, null, 2));
852
1104
  return;
@@ -874,7 +1126,7 @@ export function moduleMap(customDbPath, limit = 20, opts = {}) {
874
1126
  }
875
1127
 
876
1128
  export function fileDeps(file, customDbPath, opts = {}) {
877
- const data = fileDepsData(file, customDbPath);
1129
+ const data = fileDepsData(file, customDbPath, { noTests: opts.noTests });
878
1130
  if (opts.json) {
879
1131
  console.log(JSON.stringify(data, null, 2));
880
1132
  return;
@@ -945,6 +1197,859 @@ export function fnDeps(name, customDbPath, opts = {}) {
945
1197
  }
946
1198
  }
947
1199
 
1200
+ // ─── Context helpers (private) ──────────────────────────────────────────
1201
+
1202
+ function readSourceRange(repoRoot, file, startLine, endLine) {
1203
+ try {
1204
+ const absPath = safePath(repoRoot, file);
1205
+ if (!absPath) return null;
1206
+ const content = fs.readFileSync(absPath, 'utf-8');
1207
+ const lines = content.split('\n');
1208
+ const start = Math.max(0, (startLine || 1) - 1);
1209
+ const end = Math.min(lines.length, endLine || startLine + 50);
1210
+ return lines.slice(start, end).join('\n');
1211
+ } catch (e) {
1212
+ debug(`readSourceRange failed for ${file}: ${e.message}`);
1213
+ return null;
1214
+ }
1215
+ }
1216
+
1217
+ function extractSummary(fileLines, line) {
1218
+ if (!fileLines || !line || line <= 1) return null;
1219
+ const idx = line - 2; // line above the definition (0-indexed)
1220
+ // Scan up to 10 lines above for JSDoc or comment
1221
+ let jsdocEnd = -1;
1222
+ for (let i = idx; i >= Math.max(0, idx - 10); i--) {
1223
+ const trimmed = fileLines[i].trim();
1224
+ if (trimmed.endsWith('*/')) {
1225
+ jsdocEnd = i;
1226
+ break;
1227
+ }
1228
+ if (trimmed.startsWith('//') || trimmed.startsWith('#')) {
1229
+ // Single-line comment immediately above
1230
+ const text = trimmed
1231
+ .replace(/^\/\/\s*/, '')
1232
+ .replace(/^#\s*/, '')
1233
+ .trim();
1234
+ return text.length > 100 ? `${text.slice(0, 100)}...` : text;
1235
+ }
1236
+ if (trimmed !== '' && !trimmed.startsWith('*') && !trimmed.startsWith('/*')) break;
1237
+ }
1238
+ if (jsdocEnd >= 0) {
1239
+ // Find opening /**
1240
+ for (let i = jsdocEnd; i >= Math.max(0, jsdocEnd - 20); i--) {
1241
+ if (fileLines[i].trim().startsWith('/**')) {
1242
+ // Extract first non-tag, non-empty line
1243
+ for (let j = i + 1; j <= jsdocEnd; j++) {
1244
+ const docLine = fileLines[j]
1245
+ .trim()
1246
+ .replace(/^\*\s?/, '')
1247
+ .trim();
1248
+ if (docLine && !docLine.startsWith('@') && docLine !== '/' && docLine !== '*/') {
1249
+ return docLine.length > 100 ? `${docLine.slice(0, 100)}...` : docLine;
1250
+ }
1251
+ }
1252
+ break;
1253
+ }
1254
+ }
1255
+ }
1256
+ return null;
1257
+ }
1258
+
1259
+ function extractSignature(fileLines, line) {
1260
+ if (!fileLines || !line) return null;
1261
+ const idx = line - 1;
1262
+ // Gather up to 5 lines to handle multi-line params
1263
+ const chunk = fileLines.slice(idx, Math.min(fileLines.length, idx + 5)).join('\n');
1264
+
1265
+ // JS/TS: function name(params) or (params) => or async function
1266
+ let m = chunk.match(
1267
+ /(?:export\s+)?(?:async\s+)?function\s*\*?\s*\w*\s*\(([^)]*)\)\s*(?::\s*([^\n{]+))?/,
1268
+ );
1269
+ if (m) {
1270
+ return {
1271
+ params: m[1].trim() || null,
1272
+ returnType: m[2] ? m[2].trim().replace(/\s*\{$/, '') : null,
1273
+ };
1274
+ }
1275
+ // Arrow: const name = (params) => or (params):ReturnType =>
1276
+ m = chunk.match(/=\s*(?:async\s+)?\(([^)]*)\)\s*(?::\s*([^=>\n{]+))?\s*=>/);
1277
+ if (m) {
1278
+ return {
1279
+ params: m[1].trim() || null,
1280
+ returnType: m[2] ? m[2].trim() : null,
1281
+ };
1282
+ }
1283
+ // Python: def name(params) -> return:
1284
+ m = chunk.match(/def\s+\w+\s*\(([^)]*)\)\s*(?:->\s*([^:\n]+))?/);
1285
+ if (m) {
1286
+ return {
1287
+ params: m[1].trim() || null,
1288
+ returnType: m[2] ? m[2].trim() : null,
1289
+ };
1290
+ }
1291
+ // Go: func (recv) name(params) (returns)
1292
+ m = chunk.match(/func\s+(?:\([^)]*\)\s+)?\w+\s*\(([^)]*)\)\s*(?:\(([^)]+)\)|(\w[^\n{]*))?/);
1293
+ if (m) {
1294
+ return {
1295
+ params: m[1].trim() || null,
1296
+ returnType: (m[2] || m[3] || '').trim() || null,
1297
+ };
1298
+ }
1299
+ // Rust: fn name(params) -> ReturnType
1300
+ m = chunk.match(/fn\s+\w+\s*\(([^)]*)\)\s*(?:->\s*([^\n{]+))?/);
1301
+ if (m) {
1302
+ return {
1303
+ params: m[1].trim() || null,
1304
+ returnType: m[2] ? m[2].trim() : null,
1305
+ };
1306
+ }
1307
+ return null;
1308
+ }
1309
+
1310
+ // ─── contextData ────────────────────────────────────────────────────────
1311
+
1312
+ export function contextData(name, customDbPath, opts = {}) {
1313
+ const db = openReadonlyOrFail(customDbPath);
1314
+ const depth = opts.depth || 0;
1315
+ const noSource = opts.noSource || false;
1316
+ const noTests = opts.noTests || false;
1317
+ const includeTests = opts.includeTests || false;
1318
+
1319
+ const dbPath = findDbPath(customDbPath);
1320
+ const repoRoot = path.resolve(path.dirname(dbPath), '..');
1321
+
1322
+ let nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
1323
+ if (nodes.length === 0) {
1324
+ db.close();
1325
+ return { name, results: [] };
1326
+ }
1327
+
1328
+ // Limit to first 5 results
1329
+ nodes = nodes.slice(0, 5);
1330
+
1331
+ // File-lines cache to avoid re-reading the same file
1332
+ const fileCache = new Map();
1333
+ function getFileLines(file) {
1334
+ if (fileCache.has(file)) return fileCache.get(file);
1335
+ try {
1336
+ const absPath = safePath(repoRoot, file);
1337
+ if (!absPath) {
1338
+ fileCache.set(file, null);
1339
+ return null;
1340
+ }
1341
+ const lines = fs.readFileSync(absPath, 'utf-8').split('\n');
1342
+ fileCache.set(file, lines);
1343
+ return lines;
1344
+ } catch (e) {
1345
+ debug(`getFileLines failed for ${file}: ${e.message}`);
1346
+ fileCache.set(file, null);
1347
+ return null;
1348
+ }
1349
+ }
1350
+
1351
+ const results = nodes.map((node) => {
1352
+ const fileLines = getFileLines(node.file);
1353
+
1354
+ // Source
1355
+ const source = noSource ? null : readSourceRange(repoRoot, node.file, node.line, node.end_line);
1356
+
1357
+ // Signature
1358
+ const signature = fileLines ? extractSignature(fileLines, node.line) : null;
1359
+
1360
+ // Callees
1361
+ const calleeRows = db
1362
+ .prepare(
1363
+ `SELECT n.id, n.name, n.kind, n.file, n.line, n.end_line
1364
+ FROM edges e JOIN nodes n ON e.target_id = n.id
1365
+ WHERE e.source_id = ? AND e.kind = 'calls'`,
1366
+ )
1367
+ .all(node.id);
1368
+ const filteredCallees = noTests ? calleeRows.filter((c) => !isTestFile(c.file)) : calleeRows;
1369
+
1370
+ const callees = filteredCallees.map((c) => {
1371
+ const cLines = getFileLines(c.file);
1372
+ const summary = cLines ? extractSummary(cLines, c.line) : null;
1373
+ let calleeSource = null;
1374
+ if (depth >= 1) {
1375
+ calleeSource = readSourceRange(repoRoot, c.file, c.line, c.end_line);
1376
+ }
1377
+ return {
1378
+ name: c.name,
1379
+ kind: c.kind,
1380
+ file: c.file,
1381
+ line: c.line,
1382
+ endLine: c.end_line || null,
1383
+ summary,
1384
+ source: calleeSource,
1385
+ };
1386
+ });
1387
+
1388
+ // Deep callee expansion via BFS (depth > 1, capped at 5)
1389
+ if (depth > 1) {
1390
+ const visited = new Set(filteredCallees.map((c) => c.id));
1391
+ visited.add(node.id);
1392
+ let frontier = filteredCallees.map((c) => c.id);
1393
+ const maxDepth = Math.min(depth, 5);
1394
+ for (let d = 2; d <= maxDepth; d++) {
1395
+ const nextFrontier = [];
1396
+ for (const fid of frontier) {
1397
+ const deeper = db
1398
+ .prepare(
1399
+ `SELECT n.id, n.name, n.kind, n.file, n.line, n.end_line
1400
+ FROM edges e JOIN nodes n ON e.target_id = n.id
1401
+ WHERE e.source_id = ? AND e.kind = 'calls'`,
1402
+ )
1403
+ .all(fid);
1404
+ for (const c of deeper) {
1405
+ if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) {
1406
+ visited.add(c.id);
1407
+ nextFrontier.push(c.id);
1408
+ const cLines = getFileLines(c.file);
1409
+ callees.push({
1410
+ name: c.name,
1411
+ kind: c.kind,
1412
+ file: c.file,
1413
+ line: c.line,
1414
+ endLine: c.end_line || null,
1415
+ summary: cLines ? extractSummary(cLines, c.line) : null,
1416
+ source: readSourceRange(repoRoot, c.file, c.line, c.end_line),
1417
+ });
1418
+ }
1419
+ }
1420
+ }
1421
+ frontier = nextFrontier;
1422
+ if (frontier.length === 0) break;
1423
+ }
1424
+ }
1425
+
1426
+ // Callers
1427
+ let callerRows = db
1428
+ .prepare(
1429
+ `SELECT n.name, n.kind, n.file, n.line
1430
+ FROM edges e JOIN nodes n ON e.source_id = n.id
1431
+ WHERE e.target_id = ? AND e.kind = 'calls'`,
1432
+ )
1433
+ .all(node.id);
1434
+
1435
+ // Method hierarchy resolution
1436
+ if (node.kind === 'method' && node.name.includes('.')) {
1437
+ const methodName = node.name.split('.').pop();
1438
+ const relatedMethods = resolveMethodViaHierarchy(db, methodName);
1439
+ for (const rm of relatedMethods) {
1440
+ if (rm.id === node.id) continue;
1441
+ const extraCallers = db
1442
+ .prepare(
1443
+ `SELECT n.name, n.kind, n.file, n.line
1444
+ FROM edges e JOIN nodes n ON e.source_id = n.id
1445
+ WHERE e.target_id = ? AND e.kind = 'calls'`,
1446
+ )
1447
+ .all(rm.id);
1448
+ callerRows.push(...extraCallers.map((c) => ({ ...c, viaHierarchy: rm.name })));
1449
+ }
1450
+ }
1451
+ if (noTests) callerRows = callerRows.filter((c) => !isTestFile(c.file));
1452
+
1453
+ const callers = callerRows.map((c) => ({
1454
+ name: c.name,
1455
+ kind: c.kind,
1456
+ file: c.file,
1457
+ line: c.line,
1458
+ viaHierarchy: c.viaHierarchy || undefined,
1459
+ }));
1460
+
1461
+ // Related tests: callers that live in test files
1462
+ const testCallerRows = db
1463
+ .prepare(
1464
+ `SELECT n.name, n.kind, n.file, n.line
1465
+ FROM edges e JOIN nodes n ON e.source_id = n.id
1466
+ WHERE e.target_id = ? AND e.kind = 'calls'`,
1467
+ )
1468
+ .all(node.id);
1469
+ const testCallers = testCallerRows.filter((c) => isTestFile(c.file));
1470
+
1471
+ const testsByFile = new Map();
1472
+ for (const tc of testCallers) {
1473
+ if (!testsByFile.has(tc.file)) testsByFile.set(tc.file, []);
1474
+ testsByFile.get(tc.file).push(tc);
1475
+ }
1476
+
1477
+ const relatedTests = [];
1478
+ for (const [file] of testsByFile) {
1479
+ const tLines = getFileLines(file);
1480
+ const testNames = [];
1481
+ if (tLines) {
1482
+ for (const tl of tLines) {
1483
+ const tm = tl.match(/(?:it|test|describe)\s*\(\s*['"`]([^'"`]+)['"`]/);
1484
+ if (tm) testNames.push(tm[1]);
1485
+ }
1486
+ }
1487
+ const testSource = includeTests && tLines ? tLines.join('\n') : undefined;
1488
+ relatedTests.push({
1489
+ file,
1490
+ testCount: testNames.length,
1491
+ testNames,
1492
+ source: testSource,
1493
+ });
1494
+ }
1495
+
1496
+ return {
1497
+ name: node.name,
1498
+ kind: node.kind,
1499
+ file: node.file,
1500
+ line: node.line,
1501
+ endLine: node.end_line || null,
1502
+ source,
1503
+ signature,
1504
+ callees,
1505
+ callers,
1506
+ relatedTests,
1507
+ };
1508
+ });
1509
+
1510
+ db.close();
1511
+ return { name, results };
1512
+ }
1513
+
1514
+ export function context(name, customDbPath, opts = {}) {
1515
+ const data = contextData(name, customDbPath, opts);
1516
+ if (opts.json) {
1517
+ console.log(JSON.stringify(data, null, 2));
1518
+ return;
1519
+ }
1520
+ if (data.results.length === 0) {
1521
+ console.log(`No function/method/class matching "${name}"`);
1522
+ return;
1523
+ }
1524
+
1525
+ for (const r of data.results) {
1526
+ const lineRange = r.endLine ? `${r.line}-${r.endLine}` : `${r.line}`;
1527
+ console.log(`\n# ${r.name} (${r.kind}) — ${r.file}:${lineRange}\n`);
1528
+
1529
+ // Signature
1530
+ if (r.signature) {
1531
+ console.log('## Type/Shape Info');
1532
+ if (r.signature.params != null) console.log(` Parameters: (${r.signature.params})`);
1533
+ if (r.signature.returnType) console.log(` Returns: ${r.signature.returnType}`);
1534
+ console.log();
1535
+ }
1536
+
1537
+ // Source
1538
+ if (r.source) {
1539
+ console.log('## Source');
1540
+ for (const line of r.source.split('\n')) {
1541
+ console.log(` ${line}`);
1542
+ }
1543
+ console.log();
1544
+ }
1545
+
1546
+ // Callees
1547
+ if (r.callees.length > 0) {
1548
+ console.log(`## Direct Dependencies (${r.callees.length})`);
1549
+ for (const c of r.callees) {
1550
+ const summary = c.summary ? ` — ${c.summary}` : '';
1551
+ console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}${summary}`);
1552
+ if (c.source) {
1553
+ for (const line of c.source.split('\n').slice(0, 10)) {
1554
+ console.log(` | ${line}`);
1555
+ }
1556
+ }
1557
+ }
1558
+ console.log();
1559
+ }
1560
+
1561
+ // Callers
1562
+ if (r.callers.length > 0) {
1563
+ console.log(`## Callers (${r.callers.length})`);
1564
+ for (const c of r.callers) {
1565
+ const via = c.viaHierarchy ? ` (via ${c.viaHierarchy})` : '';
1566
+ console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}${via}`);
1567
+ }
1568
+ console.log();
1569
+ }
1570
+
1571
+ // Related tests
1572
+ if (r.relatedTests.length > 0) {
1573
+ console.log('## Related Tests');
1574
+ for (const t of r.relatedTests) {
1575
+ console.log(` ${t.file} — ${t.testCount} tests`);
1576
+ for (const tn of t.testNames) {
1577
+ console.log(` - ${tn}`);
1578
+ }
1579
+ if (t.source) {
1580
+ console.log(' Source:');
1581
+ for (const line of t.source.split('\n').slice(0, 20)) {
1582
+ console.log(` | ${line}`);
1583
+ }
1584
+ }
1585
+ }
1586
+ console.log();
1587
+ }
1588
+
1589
+ if (r.callees.length === 0 && r.callers.length === 0 && r.relatedTests.length === 0) {
1590
+ console.log(
1591
+ ' (no call edges or tests found — may be invoked dynamically or via re-exports)',
1592
+ );
1593
+ console.log();
1594
+ }
1595
+ }
1596
+ }
1597
+
1598
+ // ─── explainData ────────────────────────────────────────────────────────
1599
+
1600
+ function isFileLikeTarget(target) {
1601
+ if (target.includes('/') || target.includes('\\')) return true;
1602
+ const ext = path.extname(target).toLowerCase();
1603
+ if (!ext) return false;
1604
+ for (const entry of LANGUAGE_REGISTRY) {
1605
+ if (entry.extensions.includes(ext)) return true;
1606
+ }
1607
+ return false;
1608
+ }
1609
+
1610
+ function explainFileImpl(db, target, getFileLines) {
1611
+ const fileNodes = db
1612
+ .prepare(`SELECT * FROM nodes WHERE file LIKE ? AND kind = 'file'`)
1613
+ .all(`%${target}%`);
1614
+ if (fileNodes.length === 0) return [];
1615
+
1616
+ return fileNodes.map((fn) => {
1617
+ const symbols = db
1618
+ .prepare(`SELECT * FROM nodes WHERE file = ? AND kind != 'file' ORDER BY line`)
1619
+ .all(fn.file);
1620
+
1621
+ // IDs of symbols that have incoming calls from other files (public)
1622
+ const publicIds = new Set(
1623
+ db
1624
+ .prepare(
1625
+ `SELECT DISTINCT e.target_id FROM edges e
1626
+ JOIN nodes caller ON e.source_id = caller.id
1627
+ JOIN nodes target ON e.target_id = target.id
1628
+ WHERE target.file = ? AND caller.file != ? AND e.kind = 'calls'`,
1629
+ )
1630
+ .all(fn.file, fn.file)
1631
+ .map((r) => r.target_id),
1632
+ );
1633
+
1634
+ const fileLines = getFileLines(fn.file);
1635
+ const mapSymbol = (s) => ({
1636
+ name: s.name,
1637
+ kind: s.kind,
1638
+ line: s.line,
1639
+ summary: fileLines ? extractSummary(fileLines, s.line) : null,
1640
+ signature: fileLines ? extractSignature(fileLines, s.line) : null,
1641
+ });
1642
+
1643
+ const publicApi = symbols.filter((s) => publicIds.has(s.id)).map(mapSymbol);
1644
+ const internal = symbols.filter((s) => !publicIds.has(s.id)).map(mapSymbol);
1645
+
1646
+ // Imports / importedBy
1647
+ const imports = db
1648
+ .prepare(
1649
+ `SELECT n.file FROM edges e JOIN nodes n ON e.target_id = n.id
1650
+ WHERE e.source_id = ? AND e.kind IN ('imports', 'imports-type')`,
1651
+ )
1652
+ .all(fn.id)
1653
+ .map((r) => ({ file: r.file }));
1654
+
1655
+ const importedBy = db
1656
+ .prepare(
1657
+ `SELECT n.file FROM edges e JOIN nodes n ON e.source_id = n.id
1658
+ WHERE e.target_id = ? AND e.kind IN ('imports', 'imports-type')`,
1659
+ )
1660
+ .all(fn.id)
1661
+ .map((r) => ({ file: r.file }));
1662
+
1663
+ // Intra-file data flow
1664
+ const intraEdges = db
1665
+ .prepare(
1666
+ `SELECT caller.name as caller_name, callee.name as callee_name
1667
+ FROM edges e
1668
+ JOIN nodes caller ON e.source_id = caller.id
1669
+ JOIN nodes callee ON e.target_id = callee.id
1670
+ WHERE caller.file = ? AND callee.file = ? AND e.kind = 'calls'
1671
+ ORDER BY caller.line`,
1672
+ )
1673
+ .all(fn.file, fn.file);
1674
+
1675
+ const dataFlowMap = new Map();
1676
+ for (const edge of intraEdges) {
1677
+ if (!dataFlowMap.has(edge.caller_name)) dataFlowMap.set(edge.caller_name, []);
1678
+ dataFlowMap.get(edge.caller_name).push(edge.callee_name);
1679
+ }
1680
+ const dataFlow = [...dataFlowMap.entries()].map(([caller, callees]) => ({
1681
+ caller,
1682
+ callees,
1683
+ }));
1684
+
1685
+ // Line count: prefer node_metrics (actual), fall back to MAX(end_line)
1686
+ const metric = db
1687
+ .prepare(`SELECT nm.line_count FROM node_metrics nm WHERE nm.node_id = ?`)
1688
+ .get(fn.id);
1689
+ let lineCount = metric?.line_count || null;
1690
+ if (!lineCount) {
1691
+ const maxLine = db
1692
+ .prepare(`SELECT MAX(end_line) as max_end FROM nodes WHERE file = ?`)
1693
+ .get(fn.file);
1694
+ lineCount = maxLine?.max_end || null;
1695
+ }
1696
+
1697
+ return {
1698
+ file: fn.file,
1699
+ lineCount,
1700
+ symbolCount: symbols.length,
1701
+ publicApi,
1702
+ internal,
1703
+ imports,
1704
+ importedBy,
1705
+ dataFlow,
1706
+ };
1707
+ });
1708
+ }
1709
+
1710
+ function explainFunctionImpl(db, target, noTests, getFileLines) {
1711
+ let nodes = db
1712
+ .prepare(
1713
+ `SELECT * FROM nodes WHERE name LIKE ? AND kind IN ('function','method','class','interface','type','struct','enum','trait','record','module') ORDER BY file, line`,
1714
+ )
1715
+ .all(`%${target}%`);
1716
+ if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
1717
+ if (nodes.length === 0) return [];
1718
+
1719
+ return nodes.slice(0, 10).map((node) => {
1720
+ const fileLines = getFileLines(node.file);
1721
+ const lineCount = node.end_line ? node.end_line - node.line + 1 : null;
1722
+ const summary = fileLines ? extractSummary(fileLines, node.line) : null;
1723
+ const signature = fileLines ? extractSignature(fileLines, node.line) : null;
1724
+
1725
+ const callees = db
1726
+ .prepare(
1727
+ `SELECT n.name, n.kind, n.file, n.line
1728
+ FROM edges e JOIN nodes n ON e.target_id = n.id
1729
+ WHERE e.source_id = ? AND e.kind = 'calls'`,
1730
+ )
1731
+ .all(node.id)
1732
+ .map((c) => ({ name: c.name, kind: c.kind, file: c.file, line: c.line }));
1733
+
1734
+ let callers = db
1735
+ .prepare(
1736
+ `SELECT n.name, n.kind, n.file, n.line
1737
+ FROM edges e JOIN nodes n ON e.source_id = n.id
1738
+ WHERE e.target_id = ? AND e.kind = 'calls'`,
1739
+ )
1740
+ .all(node.id)
1741
+ .map((c) => ({ name: c.name, kind: c.kind, file: c.file, line: c.line }));
1742
+ if (noTests) callers = callers.filter((c) => !isTestFile(c.file));
1743
+
1744
+ const testCallerRows = db
1745
+ .prepare(
1746
+ `SELECT DISTINCT n.file FROM edges e JOIN nodes n ON e.source_id = n.id
1747
+ WHERE e.target_id = ? AND e.kind = 'calls'`,
1748
+ )
1749
+ .all(node.id);
1750
+ const relatedTests = testCallerRows
1751
+ .filter((r) => isTestFile(r.file))
1752
+ .map((r) => ({ file: r.file }));
1753
+
1754
+ return {
1755
+ name: node.name,
1756
+ kind: node.kind,
1757
+ file: node.file,
1758
+ line: node.line,
1759
+ endLine: node.end_line || null,
1760
+ lineCount,
1761
+ summary,
1762
+ signature,
1763
+ callees,
1764
+ callers,
1765
+ relatedTests,
1766
+ };
1767
+ });
1768
+ }
1769
+
1770
+ export function explainData(target, customDbPath, opts = {}) {
1771
+ const db = openReadonlyOrFail(customDbPath);
1772
+ const noTests = opts.noTests || false;
1773
+ const kind = isFileLikeTarget(target) ? 'file' : 'function';
1774
+
1775
+ const dbPath = findDbPath(customDbPath);
1776
+ const repoRoot = path.resolve(path.dirname(dbPath), '..');
1777
+
1778
+ const fileCache = new Map();
1779
+ function getFileLines(file) {
1780
+ if (fileCache.has(file)) return fileCache.get(file);
1781
+ try {
1782
+ const absPath = safePath(repoRoot, file);
1783
+ if (!absPath) {
1784
+ fileCache.set(file, null);
1785
+ return null;
1786
+ }
1787
+ const lines = fs.readFileSync(absPath, 'utf-8').split('\n');
1788
+ fileCache.set(file, lines);
1789
+ return lines;
1790
+ } catch (e) {
1791
+ debug(`getFileLines failed for ${file}: ${e.message}`);
1792
+ fileCache.set(file, null);
1793
+ return null;
1794
+ }
1795
+ }
1796
+
1797
+ const results =
1798
+ kind === 'file'
1799
+ ? explainFileImpl(db, target, getFileLines)
1800
+ : explainFunctionImpl(db, target, noTests, getFileLines);
1801
+
1802
+ db.close();
1803
+ return { target, kind, results };
1804
+ }
1805
+
1806
+ export function explain(target, customDbPath, opts = {}) {
1807
+ const data = explainData(target, customDbPath, opts);
1808
+ if (opts.json) {
1809
+ console.log(JSON.stringify(data, null, 2));
1810
+ return;
1811
+ }
1812
+ if (data.results.length === 0) {
1813
+ console.log(`No ${data.kind === 'file' ? 'file' : 'function/symbol'} matching "${target}"`);
1814
+ return;
1815
+ }
1816
+
1817
+ if (data.kind === 'file') {
1818
+ for (const r of data.results) {
1819
+ const publicCount = r.publicApi.length;
1820
+ const internalCount = r.internal.length;
1821
+ const lineInfo = r.lineCount ? `${r.lineCount} lines, ` : '';
1822
+ console.log(`\n# ${r.file}`);
1823
+ console.log(
1824
+ ` ${lineInfo}${r.symbolCount} symbols (${publicCount} exported, ${internalCount} internal)`,
1825
+ );
1826
+
1827
+ if (r.imports.length > 0) {
1828
+ console.log(` Imports: ${r.imports.map((i) => i.file).join(', ')}`);
1829
+ }
1830
+ if (r.importedBy.length > 0) {
1831
+ console.log(` Imported by: ${r.importedBy.map((i) => i.file).join(', ')}`);
1832
+ }
1833
+
1834
+ if (r.publicApi.length > 0) {
1835
+ console.log(`\n## Exported`);
1836
+ for (const s of r.publicApi) {
1837
+ const sig = s.signature?.params != null ? `(${s.signature.params})` : '';
1838
+ const summary = s.summary ? ` -- ${s.summary}` : '';
1839
+ console.log(` ${kindIcon(s.kind)} ${s.name}${sig} :${s.line}${summary}`);
1840
+ }
1841
+ }
1842
+
1843
+ if (r.internal.length > 0) {
1844
+ console.log(`\n## Internal`);
1845
+ for (const s of r.internal) {
1846
+ const sig = s.signature?.params != null ? `(${s.signature.params})` : '';
1847
+ const summary = s.summary ? ` -- ${s.summary}` : '';
1848
+ console.log(` ${kindIcon(s.kind)} ${s.name}${sig} :${s.line}${summary}`);
1849
+ }
1850
+ }
1851
+
1852
+ if (r.dataFlow.length > 0) {
1853
+ console.log(`\n## Data Flow`);
1854
+ for (const df of r.dataFlow) {
1855
+ console.log(` ${df.caller} -> ${df.callees.join(', ')}`);
1856
+ }
1857
+ }
1858
+ console.log();
1859
+ }
1860
+ } else {
1861
+ for (const r of data.results) {
1862
+ const lineRange = r.endLine ? `${r.line}-${r.endLine}` : `${r.line}`;
1863
+ const lineInfo = r.lineCount ? `${r.lineCount} lines` : '';
1864
+ const summaryPart = r.summary ? ` | ${r.summary}` : '';
1865
+ console.log(`\n# ${r.name} (${r.kind}) ${r.file}:${lineRange}`);
1866
+ if (lineInfo || r.summary) {
1867
+ console.log(` ${lineInfo}${summaryPart}`);
1868
+ }
1869
+ if (r.signature) {
1870
+ if (r.signature.params != null) console.log(` Parameters: (${r.signature.params})`);
1871
+ if (r.signature.returnType) console.log(` Returns: ${r.signature.returnType}`);
1872
+ }
1873
+
1874
+ if (r.callees.length > 0) {
1875
+ console.log(`\n## Calls (${r.callees.length})`);
1876
+ for (const c of r.callees) {
1877
+ console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`);
1878
+ }
1879
+ }
1880
+
1881
+ if (r.callers.length > 0) {
1882
+ console.log(`\n## Called by (${r.callers.length})`);
1883
+ for (const c of r.callers) {
1884
+ console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`);
1885
+ }
1886
+ }
1887
+
1888
+ if (r.relatedTests.length > 0) {
1889
+ const label = r.relatedTests.length === 1 ? 'file' : 'files';
1890
+ console.log(`\n## Tests (${r.relatedTests.length} ${label})`);
1891
+ for (const t of r.relatedTests) {
1892
+ console.log(` ${t.file}`);
1893
+ }
1894
+ }
1895
+
1896
+ if (r.callees.length === 0 && r.callers.length === 0) {
1897
+ console.log(` (no call edges found -- may be invoked dynamically or via re-exports)`);
1898
+ }
1899
+ console.log();
1900
+ }
1901
+ }
1902
+ }
1903
+
1904
+ // ─── whereData ──────────────────────────────────────────────────────────
1905
+
1906
+ function whereSymbolImpl(db, target, noTests) {
1907
+ const placeholders = ALL_SYMBOL_KINDS.map(() => '?').join(', ');
1908
+ let nodes = db
1909
+ .prepare(
1910
+ `SELECT * FROM nodes WHERE name LIKE ? AND kind IN (${placeholders}) ORDER BY file, line`,
1911
+ )
1912
+ .all(`%${target}%`, ...ALL_SYMBOL_KINDS);
1913
+ if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
1914
+
1915
+ return nodes.map((node) => {
1916
+ const crossFileCallers = db
1917
+ .prepare(
1918
+ `SELECT COUNT(*) as cnt FROM edges e JOIN nodes n ON e.source_id = n.id
1919
+ WHERE e.target_id = ? AND e.kind = 'calls' AND n.file != ?`,
1920
+ )
1921
+ .get(node.id, node.file);
1922
+ const exported = crossFileCallers.cnt > 0;
1923
+
1924
+ let uses = db
1925
+ .prepare(
1926
+ `SELECT n.name, n.file, n.line FROM edges e JOIN nodes n ON e.source_id = n.id
1927
+ WHERE e.target_id = ? AND e.kind = 'calls'`,
1928
+ )
1929
+ .all(node.id);
1930
+ if (noTests) uses = uses.filter((u) => !isTestFile(u.file));
1931
+
1932
+ return {
1933
+ name: node.name,
1934
+ kind: node.kind,
1935
+ file: node.file,
1936
+ line: node.line,
1937
+ exported,
1938
+ uses: uses.map((u) => ({ name: u.name, file: u.file, line: u.line })),
1939
+ };
1940
+ });
1941
+ }
1942
+
1943
+ function whereFileImpl(db, target) {
1944
+ const fileNodes = db
1945
+ .prepare(`SELECT * FROM nodes WHERE file LIKE ? AND kind = 'file'`)
1946
+ .all(`%${target}%`);
1947
+ if (fileNodes.length === 0) return [];
1948
+
1949
+ return fileNodes.map((fn) => {
1950
+ const symbols = db
1951
+ .prepare(`SELECT * FROM nodes WHERE file = ? AND kind != 'file' ORDER BY line`)
1952
+ .all(fn.file);
1953
+
1954
+ const imports = db
1955
+ .prepare(
1956
+ `SELECT n.file FROM edges e JOIN nodes n ON e.target_id = n.id
1957
+ WHERE e.source_id = ? AND e.kind IN ('imports', 'imports-type')`,
1958
+ )
1959
+ .all(fn.id)
1960
+ .map((r) => r.file);
1961
+
1962
+ const importedBy = db
1963
+ .prepare(
1964
+ `SELECT n.file FROM edges e JOIN nodes n ON e.source_id = n.id
1965
+ WHERE e.target_id = ? AND e.kind IN ('imports', 'imports-type')`,
1966
+ )
1967
+ .all(fn.id)
1968
+ .map((r) => r.file);
1969
+
1970
+ const exportedIds = new Set(
1971
+ db
1972
+ .prepare(
1973
+ `SELECT DISTINCT e.target_id FROM edges e
1974
+ JOIN nodes caller ON e.source_id = caller.id
1975
+ JOIN nodes target ON e.target_id = target.id
1976
+ WHERE target.file = ? AND caller.file != ? AND e.kind = 'calls'`,
1977
+ )
1978
+ .all(fn.file, fn.file)
1979
+ .map((r) => r.target_id),
1980
+ );
1981
+
1982
+ const exported = symbols.filter((s) => exportedIds.has(s.id)).map((s) => s.name);
1983
+
1984
+ return {
1985
+ file: fn.file,
1986
+ symbols: symbols.map((s) => ({ name: s.name, kind: s.kind, line: s.line })),
1987
+ imports,
1988
+ importedBy,
1989
+ exported,
1990
+ };
1991
+ });
1992
+ }
1993
+
1994
+ export function whereData(target, customDbPath, opts = {}) {
1995
+ const db = openReadonlyOrFail(customDbPath);
1996
+ const noTests = opts.noTests || false;
1997
+ const fileMode = opts.file || false;
1998
+
1999
+ const results = fileMode ? whereFileImpl(db, target) : whereSymbolImpl(db, target, noTests);
2000
+
2001
+ db.close();
2002
+ return { target, mode: fileMode ? 'file' : 'symbol', results };
2003
+ }
2004
+
2005
+ export function where(target, customDbPath, opts = {}) {
2006
+ const data = whereData(target, customDbPath, opts);
2007
+ if (opts.json) {
2008
+ console.log(JSON.stringify(data, null, 2));
2009
+ return;
2010
+ }
2011
+
2012
+ if (data.results.length === 0) {
2013
+ console.log(
2014
+ data.mode === 'file'
2015
+ ? `No file matching "${target}" in graph`
2016
+ : `No symbol matching "${target}" in graph`,
2017
+ );
2018
+ return;
2019
+ }
2020
+
2021
+ if (data.mode === 'symbol') {
2022
+ for (const r of data.results) {
2023
+ const tag = r.exported ? ' (exported)' : '';
2024
+ console.log(`\n${kindIcon(r.kind)} ${r.name} ${r.file}:${r.line}${tag}`);
2025
+ if (r.uses.length > 0) {
2026
+ const useStrs = r.uses.map((u) => `${u.file}:${u.line}`);
2027
+ console.log(` Used in: ${useStrs.join(', ')}`);
2028
+ } else {
2029
+ console.log(' No uses found');
2030
+ }
2031
+ }
2032
+ } else {
2033
+ for (const r of data.results) {
2034
+ console.log(`\n# ${r.file}`);
2035
+ if (r.symbols.length > 0) {
2036
+ const symStrs = r.symbols.map((s) => `${s.name}:${s.line}`);
2037
+ console.log(` Symbols: ${symStrs.join(', ')}`);
2038
+ }
2039
+ if (r.imports.length > 0) {
2040
+ console.log(` Imports: ${r.imports.join(', ')}`);
2041
+ }
2042
+ if (r.importedBy.length > 0) {
2043
+ console.log(` Imported by: ${r.importedBy.join(', ')}`);
2044
+ }
2045
+ if (r.exported.length > 0) {
2046
+ console.log(` Exported: ${r.exported.join(', ')}`);
2047
+ }
2048
+ }
2049
+ }
2050
+ console.log();
2051
+ }
2052
+
948
2053
  export function fnImpact(name, customDbPath, opts = {}) {
949
2054
  const data = fnImpactData(name, customDbPath, opts);
950
2055
  if (opts.json) {