@optave/codegraph 2.1.0 → 2.1.1-dev.0e15f12

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
20
  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':
@@ -132,8 +241,9 @@ export function queryNameData(name, customDbPath) {
132
241
  return { query: name, results };
133
242
  }
134
243
 
135
- export function impactAnalysisData(file, customDbPath) {
244
+ export function impactAnalysisData(file, customDbPath, opts = {}) {
136
245
  const db = openReadonlyOrFail(customDbPath);
246
+ const noTests = opts.noTests || false;
137
247
  const fileNodes = db
138
248
  .prepare(`SELECT * FROM nodes WHERE file LIKE ? AND kind = 'file'`)
139
249
  .all(`%${file}%`);
@@ -162,7 +272,7 @@ export function impactAnalysisData(file, customDbPath) {
162
272
  `)
163
273
  .all(current);
164
274
  for (const dep of dependents) {
165
- if (!visited.has(dep.id)) {
275
+ if (!visited.has(dep.id) && (!noTests || !isTestFile(dep.file))) {
166
276
  visited.add(dep.id);
167
277
  queue.push(dep.id);
168
278
  levels.set(dep.id, level + 1);
@@ -187,8 +297,17 @@ export function impactAnalysisData(file, customDbPath) {
187
297
  };
188
298
  }
189
299
 
190
- export function moduleMapData(customDbPath, limit = 20) {
300
+ export function moduleMapData(customDbPath, limit = 20, opts = {}) {
191
301
  const db = openReadonlyOrFail(customDbPath);
302
+ const noTests = opts.noTests || false;
303
+
304
+ const testFilter = noTests
305
+ ? `AND n.file NOT LIKE '%.test.%'
306
+ AND n.file NOT LIKE '%.spec.%'
307
+ AND n.file NOT LIKE '%__test__%'
308
+ AND n.file NOT LIKE '%__tests__%'
309
+ AND n.file NOT LIKE '%.stories.%'`
310
+ : '';
192
311
 
193
312
  const nodes = db
194
313
  .prepare(`
@@ -197,9 +316,7 @@ export function moduleMapData(customDbPath, limit = 20) {
197
316
  (SELECT COUNT(*) FROM edges WHERE target_id = n.id AND kind != 'contains') as in_edges
198
317
  FROM nodes n
199
318
  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__%'
319
+ ${testFilter}
203
320
  ORDER BY (SELECT COUNT(*) FROM edges WHERE target_id = n.id AND kind != 'contains') DESC
204
321
  LIMIT ?
205
322
  `)
@@ -220,8 +337,9 @@ export function moduleMapData(customDbPath, limit = 20) {
220
337
  return { limit, topNodes, stats: { totalFiles, totalNodes, totalEdges } };
221
338
  }
222
339
 
223
- export function fileDepsData(file, customDbPath) {
340
+ export function fileDepsData(file, customDbPath, opts = {}) {
224
341
  const db = openReadonlyOrFail(customDbPath);
342
+ const noTests = opts.noTests || false;
225
343
  const fileNodes = db
226
344
  .prepare(`SELECT * FROM nodes WHERE file LIKE ? AND kind = 'file'`)
227
345
  .all(`%${file}%`);
@@ -231,19 +349,21 @@ export function fileDepsData(file, customDbPath) {
231
349
  }
232
350
 
233
351
  const results = fileNodes.map((fn) => {
234
- const importsTo = db
352
+ let importsTo = db
235
353
  .prepare(`
236
354
  SELECT n.file, e.kind as edge_kind FROM edges e JOIN nodes n ON e.target_id = n.id
237
355
  WHERE e.source_id = ? AND e.kind IN ('imports', 'imports-type')
238
356
  `)
239
357
  .all(fn.id);
358
+ if (noTests) importsTo = importsTo.filter((i) => !isTestFile(i.file));
240
359
 
241
- const importedBy = db
360
+ let importedBy = db
242
361
  .prepare(`
243
362
  SELECT n.file, e.kind as edge_kind FROM edges e JOIN nodes n ON e.source_id = n.id
244
363
  WHERE e.target_id = ? AND e.kind IN ('imports', 'imports-type')
245
364
  `)
246
365
  .all(fn.id);
366
+ if (noTests) importedBy = importedBy.filter((i) => !isTestFile(i.file));
247
367
 
248
368
  const defs = db
249
369
  .prepare(`SELECT * FROM nodes WHERE file = ? AND kind != 'file' ORDER BY line`)
@@ -266,12 +386,7 @@ export function fnDepsData(name, customDbPath, opts = {}) {
266
386
  const depth = opts.depth || 3;
267
387
  const noTests = opts.noTests || false;
268
388
 
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));
389
+ const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
275
390
  if (nodes.length === 0) {
276
391
  db.close();
277
392
  return { name, results: [] };
@@ -391,10 +506,7 @@ export function fnImpactData(name, customDbPath, opts = {}) {
391
506
  const maxDepth = opts.depth || 5;
392
507
  const noTests = opts.noTests || false;
393
508
 
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));
509
+ const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
398
510
  if (nodes.length === 0) {
399
511
  db.close();
400
512
  return { name, results: [] };
@@ -695,6 +807,67 @@ export function statsData(customDbPath) {
695
807
  /* embeddings table may not exist */
696
808
  }
697
809
 
810
+ // Graph quality metrics
811
+ const totalCallable = db
812
+ .prepare("SELECT COUNT(*) as c FROM nodes WHERE kind IN ('function', 'method')")
813
+ .get().c;
814
+ const callableWithCallers = db
815
+ .prepare(`
816
+ SELECT COUNT(DISTINCT e.target_id) as c FROM edges e
817
+ JOIN nodes n ON e.target_id = n.id
818
+ WHERE e.kind = 'calls' AND n.kind IN ('function', 'method')
819
+ `)
820
+ .get().c;
821
+ const callerCoverage = totalCallable > 0 ? callableWithCallers / totalCallable : 0;
822
+
823
+ const totalCallEdges = db.prepare("SELECT COUNT(*) as c FROM edges WHERE kind = 'calls'").get().c;
824
+ const highConfCallEdges = db
825
+ .prepare("SELECT COUNT(*) as c FROM edges WHERE kind = 'calls' AND confidence >= 0.7")
826
+ .get().c;
827
+ const callConfidence = totalCallEdges > 0 ? highConfCallEdges / totalCallEdges : 0;
828
+
829
+ // False-positive warnings: generic names with > threshold callers
830
+ const fpRows = db
831
+ .prepare(`
832
+ SELECT n.name, n.file, n.line, COUNT(e.source_id) as caller_count
833
+ FROM nodes n
834
+ LEFT JOIN edges e ON n.id = e.target_id AND e.kind = 'calls'
835
+ WHERE n.kind IN ('function', 'method')
836
+ GROUP BY n.id
837
+ HAVING caller_count > ?
838
+ ORDER BY caller_count DESC
839
+ `)
840
+ .all(FALSE_POSITIVE_CALLER_THRESHOLD);
841
+ const falsePositiveWarnings = fpRows
842
+ .filter((r) =>
843
+ FALSE_POSITIVE_NAMES.has(r.name.includes('.') ? r.name.split('.').pop() : r.name),
844
+ )
845
+ .map((r) => ({ name: r.name, file: r.file, line: r.line, callerCount: r.caller_count }));
846
+
847
+ // Edges from suspicious nodes
848
+ let fpEdgeCount = 0;
849
+ for (const fp of falsePositiveWarnings) fpEdgeCount += fp.callerCount;
850
+ const falsePositiveRatio = totalCallEdges > 0 ? fpEdgeCount / totalCallEdges : 0;
851
+
852
+ const score = Math.round(
853
+ callerCoverage * 40 + callConfidence * 40 + (1 - falsePositiveRatio) * 20,
854
+ );
855
+
856
+ const quality = {
857
+ score,
858
+ callerCoverage: {
859
+ ratio: callerCoverage,
860
+ covered: callableWithCallers,
861
+ total: totalCallable,
862
+ },
863
+ callConfidence: {
864
+ ratio: callConfidence,
865
+ highConf: highConfCallEdges,
866
+ total: totalCallEdges,
867
+ },
868
+ falsePositiveWarnings,
869
+ };
870
+
698
871
  db.close();
699
872
  return {
700
873
  nodes: { total: totalNodes, byKind: nodesByKind },
@@ -703,6 +876,7 @@ export function statsData(customDbPath) {
703
876
  cycles: { fileLevel: fileCycles.length, functionLevel: fnCycles.length },
704
877
  hotspots,
705
878
  embeddings,
879
+ quality,
706
880
  };
707
881
  }
708
882
 
@@ -779,6 +953,26 @@ export function stats(customDbPath, opts = {}) {
779
953
  console.log('\nEmbeddings: not built');
780
954
  }
781
955
 
956
+ // Quality
957
+ if (data.quality) {
958
+ const q = data.quality;
959
+ const cc = q.callerCoverage;
960
+ const cf = q.callConfidence;
961
+ console.log(`\nGraph Quality: ${q.score}/100`);
962
+ console.log(
963
+ ` Caller coverage: ${(cc.ratio * 100).toFixed(1)}% (${cc.covered}/${cc.total} functions have >=1 caller)`,
964
+ );
965
+ console.log(
966
+ ` Call confidence: ${(cf.ratio * 100).toFixed(1)}% (${cf.highConf}/${cf.total} call edges are high-confidence)`,
967
+ );
968
+ if (q.falsePositiveWarnings.length > 0) {
969
+ console.log(' False-positive warnings:');
970
+ for (const fp of q.falsePositiveWarnings) {
971
+ console.log(` ! ${fp.name} (${fp.callerCount} callers) -- ${fp.file}:${fp.line}`);
972
+ }
973
+ }
974
+ }
975
+
782
976
  console.log();
783
977
  }
784
978
 
@@ -815,7 +1009,7 @@ export function queryName(name, customDbPath, opts = {}) {
815
1009
  }
816
1010
 
817
1011
  export function impactAnalysis(file, customDbPath, opts = {}) {
818
- const data = impactAnalysisData(file, customDbPath);
1012
+ const data = impactAnalysisData(file, customDbPath, { noTests: opts.noTests });
819
1013
  if (opts.json) {
820
1014
  console.log(JSON.stringify(data, null, 2));
821
1015
  return;
@@ -846,7 +1040,7 @@ export function impactAnalysis(file, customDbPath, opts = {}) {
846
1040
  }
847
1041
 
848
1042
  export function moduleMap(customDbPath, limit = 20, opts = {}) {
849
- const data = moduleMapData(customDbPath, limit);
1043
+ const data = moduleMapData(customDbPath, limit, { noTests: opts.noTests });
850
1044
  if (opts.json) {
851
1045
  console.log(JSON.stringify(data, null, 2));
852
1046
  return;
@@ -874,7 +1068,7 @@ export function moduleMap(customDbPath, limit = 20, opts = {}) {
874
1068
  }
875
1069
 
876
1070
  export function fileDeps(file, customDbPath, opts = {}) {
877
- const data = fileDepsData(file, customDbPath);
1071
+ const data = fileDepsData(file, customDbPath, { noTests: opts.noTests });
878
1072
  if (opts.json) {
879
1073
  console.log(JSON.stringify(data, null, 2));
880
1074
  return;
@@ -945,6 +1139,859 @@ export function fnDeps(name, customDbPath, opts = {}) {
945
1139
  }
946
1140
  }
947
1141
 
1142
+ // ─── Context helpers (private) ──────────────────────────────────────────
1143
+
1144
+ function readSourceRange(repoRoot, file, startLine, endLine) {
1145
+ try {
1146
+ const absPath = safePath(repoRoot, file);
1147
+ if (!absPath) return null;
1148
+ const content = fs.readFileSync(absPath, 'utf-8');
1149
+ const lines = content.split('\n');
1150
+ const start = Math.max(0, (startLine || 1) - 1);
1151
+ const end = Math.min(lines.length, endLine || startLine + 50);
1152
+ return lines.slice(start, end).join('\n');
1153
+ } catch (e) {
1154
+ debug(`readSourceRange failed for ${file}: ${e.message}`);
1155
+ return null;
1156
+ }
1157
+ }
1158
+
1159
+ function extractSummary(fileLines, line) {
1160
+ if (!fileLines || !line || line <= 1) return null;
1161
+ const idx = line - 2; // line above the definition (0-indexed)
1162
+ // Scan up to 10 lines above for JSDoc or comment
1163
+ let jsdocEnd = -1;
1164
+ for (let i = idx; i >= Math.max(0, idx - 10); i--) {
1165
+ const trimmed = fileLines[i].trim();
1166
+ if (trimmed.endsWith('*/')) {
1167
+ jsdocEnd = i;
1168
+ break;
1169
+ }
1170
+ if (trimmed.startsWith('//') || trimmed.startsWith('#')) {
1171
+ // Single-line comment immediately above
1172
+ const text = trimmed
1173
+ .replace(/^\/\/\s*/, '')
1174
+ .replace(/^#\s*/, '')
1175
+ .trim();
1176
+ return text.length > 100 ? `${text.slice(0, 100)}...` : text;
1177
+ }
1178
+ if (trimmed !== '' && !trimmed.startsWith('*') && !trimmed.startsWith('/*')) break;
1179
+ }
1180
+ if (jsdocEnd >= 0) {
1181
+ // Find opening /**
1182
+ for (let i = jsdocEnd; i >= Math.max(0, jsdocEnd - 20); i--) {
1183
+ if (fileLines[i].trim().startsWith('/**')) {
1184
+ // Extract first non-tag, non-empty line
1185
+ for (let j = i + 1; j <= jsdocEnd; j++) {
1186
+ const docLine = fileLines[j]
1187
+ .trim()
1188
+ .replace(/^\*\s?/, '')
1189
+ .trim();
1190
+ if (docLine && !docLine.startsWith('@') && docLine !== '/' && docLine !== '*/') {
1191
+ return docLine.length > 100 ? `${docLine.slice(0, 100)}...` : docLine;
1192
+ }
1193
+ }
1194
+ break;
1195
+ }
1196
+ }
1197
+ }
1198
+ return null;
1199
+ }
1200
+
1201
+ function extractSignature(fileLines, line) {
1202
+ if (!fileLines || !line) return null;
1203
+ const idx = line - 1;
1204
+ // Gather up to 5 lines to handle multi-line params
1205
+ const chunk = fileLines.slice(idx, Math.min(fileLines.length, idx + 5)).join('\n');
1206
+
1207
+ // JS/TS: function name(params) or (params) => or async function
1208
+ let m = chunk.match(
1209
+ /(?:export\s+)?(?:async\s+)?function\s*\*?\s*\w*\s*\(([^)]*)\)\s*(?::\s*([^\n{]+))?/,
1210
+ );
1211
+ if (m) {
1212
+ return {
1213
+ params: m[1].trim() || null,
1214
+ returnType: m[2] ? m[2].trim().replace(/\s*\{$/, '') : null,
1215
+ };
1216
+ }
1217
+ // Arrow: const name = (params) => or (params):ReturnType =>
1218
+ m = chunk.match(/=\s*(?:async\s+)?\(([^)]*)\)\s*(?::\s*([^=>\n{]+))?\s*=>/);
1219
+ if (m) {
1220
+ return {
1221
+ params: m[1].trim() || null,
1222
+ returnType: m[2] ? m[2].trim() : null,
1223
+ };
1224
+ }
1225
+ // Python: def name(params) -> return:
1226
+ m = chunk.match(/def\s+\w+\s*\(([^)]*)\)\s*(?:->\s*([^:\n]+))?/);
1227
+ if (m) {
1228
+ return {
1229
+ params: m[1].trim() || null,
1230
+ returnType: m[2] ? m[2].trim() : null,
1231
+ };
1232
+ }
1233
+ // Go: func (recv) name(params) (returns)
1234
+ m = chunk.match(/func\s+(?:\([^)]*\)\s+)?\w+\s*\(([^)]*)\)\s*(?:\(([^)]+)\)|(\w[^\n{]*))?/);
1235
+ if (m) {
1236
+ return {
1237
+ params: m[1].trim() || null,
1238
+ returnType: (m[2] || m[3] || '').trim() || null,
1239
+ };
1240
+ }
1241
+ // Rust: fn name(params) -> ReturnType
1242
+ m = chunk.match(/fn\s+\w+\s*\(([^)]*)\)\s*(?:->\s*([^\n{]+))?/);
1243
+ if (m) {
1244
+ return {
1245
+ params: m[1].trim() || null,
1246
+ returnType: m[2] ? m[2].trim() : null,
1247
+ };
1248
+ }
1249
+ return null;
1250
+ }
1251
+
1252
+ // ─── contextData ────────────────────────────────────────────────────────
1253
+
1254
+ export function contextData(name, customDbPath, opts = {}) {
1255
+ const db = openReadonlyOrFail(customDbPath);
1256
+ const depth = opts.depth || 0;
1257
+ const noSource = opts.noSource || false;
1258
+ const noTests = opts.noTests || false;
1259
+ const includeTests = opts.includeTests || false;
1260
+
1261
+ const dbPath = findDbPath(customDbPath);
1262
+ const repoRoot = path.resolve(path.dirname(dbPath), '..');
1263
+
1264
+ let nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
1265
+ if (nodes.length === 0) {
1266
+ db.close();
1267
+ return { name, results: [] };
1268
+ }
1269
+
1270
+ // Limit to first 5 results
1271
+ nodes = nodes.slice(0, 5);
1272
+
1273
+ // File-lines cache to avoid re-reading the same file
1274
+ const fileCache = new Map();
1275
+ function getFileLines(file) {
1276
+ if (fileCache.has(file)) return fileCache.get(file);
1277
+ try {
1278
+ const absPath = safePath(repoRoot, file);
1279
+ if (!absPath) {
1280
+ fileCache.set(file, null);
1281
+ return null;
1282
+ }
1283
+ const lines = fs.readFileSync(absPath, 'utf-8').split('\n');
1284
+ fileCache.set(file, lines);
1285
+ return lines;
1286
+ } catch (e) {
1287
+ debug(`getFileLines failed for ${file}: ${e.message}`);
1288
+ fileCache.set(file, null);
1289
+ return null;
1290
+ }
1291
+ }
1292
+
1293
+ const results = nodes.map((node) => {
1294
+ const fileLines = getFileLines(node.file);
1295
+
1296
+ // Source
1297
+ const source = noSource ? null : readSourceRange(repoRoot, node.file, node.line, node.end_line);
1298
+
1299
+ // Signature
1300
+ const signature = fileLines ? extractSignature(fileLines, node.line) : null;
1301
+
1302
+ // Callees
1303
+ const calleeRows = db
1304
+ .prepare(
1305
+ `SELECT n.id, n.name, n.kind, n.file, n.line, n.end_line
1306
+ FROM edges e JOIN nodes n ON e.target_id = n.id
1307
+ WHERE e.source_id = ? AND e.kind = 'calls'`,
1308
+ )
1309
+ .all(node.id);
1310
+ const filteredCallees = noTests ? calleeRows.filter((c) => !isTestFile(c.file)) : calleeRows;
1311
+
1312
+ const callees = filteredCallees.map((c) => {
1313
+ const cLines = getFileLines(c.file);
1314
+ const summary = cLines ? extractSummary(cLines, c.line) : null;
1315
+ let calleeSource = null;
1316
+ if (depth >= 1) {
1317
+ calleeSource = readSourceRange(repoRoot, c.file, c.line, c.end_line);
1318
+ }
1319
+ return {
1320
+ name: c.name,
1321
+ kind: c.kind,
1322
+ file: c.file,
1323
+ line: c.line,
1324
+ endLine: c.end_line || null,
1325
+ summary,
1326
+ source: calleeSource,
1327
+ };
1328
+ });
1329
+
1330
+ // Deep callee expansion via BFS (depth > 1, capped at 5)
1331
+ if (depth > 1) {
1332
+ const visited = new Set(filteredCallees.map((c) => c.id));
1333
+ visited.add(node.id);
1334
+ let frontier = filteredCallees.map((c) => c.id);
1335
+ const maxDepth = Math.min(depth, 5);
1336
+ for (let d = 2; d <= maxDepth; d++) {
1337
+ const nextFrontier = [];
1338
+ for (const fid of frontier) {
1339
+ const deeper = db
1340
+ .prepare(
1341
+ `SELECT n.id, n.name, n.kind, n.file, n.line, n.end_line
1342
+ FROM edges e JOIN nodes n ON e.target_id = n.id
1343
+ WHERE e.source_id = ? AND e.kind = 'calls'`,
1344
+ )
1345
+ .all(fid);
1346
+ for (const c of deeper) {
1347
+ if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) {
1348
+ visited.add(c.id);
1349
+ nextFrontier.push(c.id);
1350
+ const cLines = getFileLines(c.file);
1351
+ callees.push({
1352
+ name: c.name,
1353
+ kind: c.kind,
1354
+ file: c.file,
1355
+ line: c.line,
1356
+ endLine: c.end_line || null,
1357
+ summary: cLines ? extractSummary(cLines, c.line) : null,
1358
+ source: readSourceRange(repoRoot, c.file, c.line, c.end_line),
1359
+ });
1360
+ }
1361
+ }
1362
+ }
1363
+ frontier = nextFrontier;
1364
+ if (frontier.length === 0) break;
1365
+ }
1366
+ }
1367
+
1368
+ // Callers
1369
+ let callerRows = db
1370
+ .prepare(
1371
+ `SELECT n.name, n.kind, n.file, n.line
1372
+ FROM edges e JOIN nodes n ON e.source_id = n.id
1373
+ WHERE e.target_id = ? AND e.kind = 'calls'`,
1374
+ )
1375
+ .all(node.id);
1376
+
1377
+ // Method hierarchy resolution
1378
+ if (node.kind === 'method' && node.name.includes('.')) {
1379
+ const methodName = node.name.split('.').pop();
1380
+ const relatedMethods = resolveMethodViaHierarchy(db, methodName);
1381
+ for (const rm of relatedMethods) {
1382
+ if (rm.id === node.id) continue;
1383
+ const extraCallers = db
1384
+ .prepare(
1385
+ `SELECT n.name, n.kind, n.file, n.line
1386
+ FROM edges e JOIN nodes n ON e.source_id = n.id
1387
+ WHERE e.target_id = ? AND e.kind = 'calls'`,
1388
+ )
1389
+ .all(rm.id);
1390
+ callerRows.push(...extraCallers.map((c) => ({ ...c, viaHierarchy: rm.name })));
1391
+ }
1392
+ }
1393
+ if (noTests) callerRows = callerRows.filter((c) => !isTestFile(c.file));
1394
+
1395
+ const callers = callerRows.map((c) => ({
1396
+ name: c.name,
1397
+ kind: c.kind,
1398
+ file: c.file,
1399
+ line: c.line,
1400
+ viaHierarchy: c.viaHierarchy || undefined,
1401
+ }));
1402
+
1403
+ // Related tests: callers that live in test files
1404
+ const testCallerRows = db
1405
+ .prepare(
1406
+ `SELECT n.name, n.kind, n.file, n.line
1407
+ FROM edges e JOIN nodes n ON e.source_id = n.id
1408
+ WHERE e.target_id = ? AND e.kind = 'calls'`,
1409
+ )
1410
+ .all(node.id);
1411
+ const testCallers = testCallerRows.filter((c) => isTestFile(c.file));
1412
+
1413
+ const testsByFile = new Map();
1414
+ for (const tc of testCallers) {
1415
+ if (!testsByFile.has(tc.file)) testsByFile.set(tc.file, []);
1416
+ testsByFile.get(tc.file).push(tc);
1417
+ }
1418
+
1419
+ const relatedTests = [];
1420
+ for (const [file] of testsByFile) {
1421
+ const tLines = getFileLines(file);
1422
+ const testNames = [];
1423
+ if (tLines) {
1424
+ for (const tl of tLines) {
1425
+ const tm = tl.match(/(?:it|test|describe)\s*\(\s*['"`]([^'"`]+)['"`]/);
1426
+ if (tm) testNames.push(tm[1]);
1427
+ }
1428
+ }
1429
+ const testSource = includeTests && tLines ? tLines.join('\n') : undefined;
1430
+ relatedTests.push({
1431
+ file,
1432
+ testCount: testNames.length,
1433
+ testNames,
1434
+ source: testSource,
1435
+ });
1436
+ }
1437
+
1438
+ return {
1439
+ name: node.name,
1440
+ kind: node.kind,
1441
+ file: node.file,
1442
+ line: node.line,
1443
+ endLine: node.end_line || null,
1444
+ source,
1445
+ signature,
1446
+ callees,
1447
+ callers,
1448
+ relatedTests,
1449
+ };
1450
+ });
1451
+
1452
+ db.close();
1453
+ return { name, results };
1454
+ }
1455
+
1456
+ export function context(name, customDbPath, opts = {}) {
1457
+ const data = contextData(name, customDbPath, opts);
1458
+ if (opts.json) {
1459
+ console.log(JSON.stringify(data, null, 2));
1460
+ return;
1461
+ }
1462
+ if (data.results.length === 0) {
1463
+ console.log(`No function/method/class matching "${name}"`);
1464
+ return;
1465
+ }
1466
+
1467
+ for (const r of data.results) {
1468
+ const lineRange = r.endLine ? `${r.line}-${r.endLine}` : `${r.line}`;
1469
+ console.log(`\n# ${r.name} (${r.kind}) — ${r.file}:${lineRange}\n`);
1470
+
1471
+ // Signature
1472
+ if (r.signature) {
1473
+ console.log('## Type/Shape Info');
1474
+ if (r.signature.params != null) console.log(` Parameters: (${r.signature.params})`);
1475
+ if (r.signature.returnType) console.log(` Returns: ${r.signature.returnType}`);
1476
+ console.log();
1477
+ }
1478
+
1479
+ // Source
1480
+ if (r.source) {
1481
+ console.log('## Source');
1482
+ for (const line of r.source.split('\n')) {
1483
+ console.log(` ${line}`);
1484
+ }
1485
+ console.log();
1486
+ }
1487
+
1488
+ // Callees
1489
+ if (r.callees.length > 0) {
1490
+ console.log(`## Direct Dependencies (${r.callees.length})`);
1491
+ for (const c of r.callees) {
1492
+ const summary = c.summary ? ` — ${c.summary}` : '';
1493
+ console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}${summary}`);
1494
+ if (c.source) {
1495
+ for (const line of c.source.split('\n').slice(0, 10)) {
1496
+ console.log(` | ${line}`);
1497
+ }
1498
+ }
1499
+ }
1500
+ console.log();
1501
+ }
1502
+
1503
+ // Callers
1504
+ if (r.callers.length > 0) {
1505
+ console.log(`## Callers (${r.callers.length})`);
1506
+ for (const c of r.callers) {
1507
+ const via = c.viaHierarchy ? ` (via ${c.viaHierarchy})` : '';
1508
+ console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}${via}`);
1509
+ }
1510
+ console.log();
1511
+ }
1512
+
1513
+ // Related tests
1514
+ if (r.relatedTests.length > 0) {
1515
+ console.log('## Related Tests');
1516
+ for (const t of r.relatedTests) {
1517
+ console.log(` ${t.file} — ${t.testCount} tests`);
1518
+ for (const tn of t.testNames) {
1519
+ console.log(` - ${tn}`);
1520
+ }
1521
+ if (t.source) {
1522
+ console.log(' Source:');
1523
+ for (const line of t.source.split('\n').slice(0, 20)) {
1524
+ console.log(` | ${line}`);
1525
+ }
1526
+ }
1527
+ }
1528
+ console.log();
1529
+ }
1530
+
1531
+ if (r.callees.length === 0 && r.callers.length === 0 && r.relatedTests.length === 0) {
1532
+ console.log(
1533
+ ' (no call edges or tests found — may be invoked dynamically or via re-exports)',
1534
+ );
1535
+ console.log();
1536
+ }
1537
+ }
1538
+ }
1539
+
1540
+ // ─── explainData ────────────────────────────────────────────────────────
1541
+
1542
+ function isFileLikeTarget(target) {
1543
+ if (target.includes('/') || target.includes('\\')) return true;
1544
+ const ext = path.extname(target).toLowerCase();
1545
+ if (!ext) return false;
1546
+ for (const entry of LANGUAGE_REGISTRY) {
1547
+ if (entry.extensions.includes(ext)) return true;
1548
+ }
1549
+ return false;
1550
+ }
1551
+
1552
+ function explainFileImpl(db, target, getFileLines) {
1553
+ const fileNodes = db
1554
+ .prepare(`SELECT * FROM nodes WHERE file LIKE ? AND kind = 'file'`)
1555
+ .all(`%${target}%`);
1556
+ if (fileNodes.length === 0) return [];
1557
+
1558
+ return fileNodes.map((fn) => {
1559
+ const symbols = db
1560
+ .prepare(`SELECT * FROM nodes WHERE file = ? AND kind != 'file' ORDER BY line`)
1561
+ .all(fn.file);
1562
+
1563
+ // IDs of symbols that have incoming calls from other files (public)
1564
+ const publicIds = new Set(
1565
+ db
1566
+ .prepare(
1567
+ `SELECT DISTINCT e.target_id FROM edges e
1568
+ JOIN nodes caller ON e.source_id = caller.id
1569
+ JOIN nodes target ON e.target_id = target.id
1570
+ WHERE target.file = ? AND caller.file != ? AND e.kind = 'calls'`,
1571
+ )
1572
+ .all(fn.file, fn.file)
1573
+ .map((r) => r.target_id),
1574
+ );
1575
+
1576
+ const fileLines = getFileLines(fn.file);
1577
+ const mapSymbol = (s) => ({
1578
+ name: s.name,
1579
+ kind: s.kind,
1580
+ line: s.line,
1581
+ summary: fileLines ? extractSummary(fileLines, s.line) : null,
1582
+ signature: fileLines ? extractSignature(fileLines, s.line) : null,
1583
+ });
1584
+
1585
+ const publicApi = symbols.filter((s) => publicIds.has(s.id)).map(mapSymbol);
1586
+ const internal = symbols.filter((s) => !publicIds.has(s.id)).map(mapSymbol);
1587
+
1588
+ // Imports / importedBy
1589
+ const imports = db
1590
+ .prepare(
1591
+ `SELECT n.file FROM edges e JOIN nodes n ON e.target_id = n.id
1592
+ WHERE e.source_id = ? AND e.kind IN ('imports', 'imports-type')`,
1593
+ )
1594
+ .all(fn.id)
1595
+ .map((r) => ({ file: r.file }));
1596
+
1597
+ const importedBy = db
1598
+ .prepare(
1599
+ `SELECT n.file FROM edges e JOIN nodes n ON e.source_id = n.id
1600
+ WHERE e.target_id = ? AND e.kind IN ('imports', 'imports-type')`,
1601
+ )
1602
+ .all(fn.id)
1603
+ .map((r) => ({ file: r.file }));
1604
+
1605
+ // Intra-file data flow
1606
+ const intraEdges = db
1607
+ .prepare(
1608
+ `SELECT caller.name as caller_name, callee.name as callee_name
1609
+ FROM edges e
1610
+ JOIN nodes caller ON e.source_id = caller.id
1611
+ JOIN nodes callee ON e.target_id = callee.id
1612
+ WHERE caller.file = ? AND callee.file = ? AND e.kind = 'calls'
1613
+ ORDER BY caller.line`,
1614
+ )
1615
+ .all(fn.file, fn.file);
1616
+
1617
+ const dataFlowMap = new Map();
1618
+ for (const edge of intraEdges) {
1619
+ if (!dataFlowMap.has(edge.caller_name)) dataFlowMap.set(edge.caller_name, []);
1620
+ dataFlowMap.get(edge.caller_name).push(edge.callee_name);
1621
+ }
1622
+ const dataFlow = [...dataFlowMap.entries()].map(([caller, callees]) => ({
1623
+ caller,
1624
+ callees,
1625
+ }));
1626
+
1627
+ // Line count: prefer node_metrics (actual), fall back to MAX(end_line)
1628
+ const metric = db
1629
+ .prepare(`SELECT nm.line_count FROM node_metrics nm WHERE nm.node_id = ?`)
1630
+ .get(fn.id);
1631
+ let lineCount = metric?.line_count || null;
1632
+ if (!lineCount) {
1633
+ const maxLine = db
1634
+ .prepare(`SELECT MAX(end_line) as max_end FROM nodes WHERE file = ?`)
1635
+ .get(fn.file);
1636
+ lineCount = maxLine?.max_end || null;
1637
+ }
1638
+
1639
+ return {
1640
+ file: fn.file,
1641
+ lineCount,
1642
+ symbolCount: symbols.length,
1643
+ publicApi,
1644
+ internal,
1645
+ imports,
1646
+ importedBy,
1647
+ dataFlow,
1648
+ };
1649
+ });
1650
+ }
1651
+
1652
+ function explainFunctionImpl(db, target, noTests, getFileLines) {
1653
+ let nodes = db
1654
+ .prepare(
1655
+ `SELECT * FROM nodes WHERE name LIKE ? AND kind IN ('function','method','class','interface','type','struct','enum','trait','record','module') ORDER BY file, line`,
1656
+ )
1657
+ .all(`%${target}%`);
1658
+ if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
1659
+ if (nodes.length === 0) return [];
1660
+
1661
+ return nodes.slice(0, 10).map((node) => {
1662
+ const fileLines = getFileLines(node.file);
1663
+ const lineCount = node.end_line ? node.end_line - node.line + 1 : null;
1664
+ const summary = fileLines ? extractSummary(fileLines, node.line) : null;
1665
+ const signature = fileLines ? extractSignature(fileLines, node.line) : null;
1666
+
1667
+ const callees = db
1668
+ .prepare(
1669
+ `SELECT n.name, n.kind, n.file, n.line
1670
+ FROM edges e JOIN nodes n ON e.target_id = n.id
1671
+ WHERE e.source_id = ? AND e.kind = 'calls'`,
1672
+ )
1673
+ .all(node.id)
1674
+ .map((c) => ({ name: c.name, kind: c.kind, file: c.file, line: c.line }));
1675
+
1676
+ let callers = db
1677
+ .prepare(
1678
+ `SELECT n.name, n.kind, n.file, n.line
1679
+ FROM edges e JOIN nodes n ON e.source_id = n.id
1680
+ WHERE e.target_id = ? AND e.kind = 'calls'`,
1681
+ )
1682
+ .all(node.id)
1683
+ .map((c) => ({ name: c.name, kind: c.kind, file: c.file, line: c.line }));
1684
+ if (noTests) callers = callers.filter((c) => !isTestFile(c.file));
1685
+
1686
+ const testCallerRows = db
1687
+ .prepare(
1688
+ `SELECT DISTINCT n.file FROM edges e JOIN nodes n ON e.source_id = n.id
1689
+ WHERE e.target_id = ? AND e.kind = 'calls'`,
1690
+ )
1691
+ .all(node.id);
1692
+ const relatedTests = testCallerRows
1693
+ .filter((r) => isTestFile(r.file))
1694
+ .map((r) => ({ file: r.file }));
1695
+
1696
+ return {
1697
+ name: node.name,
1698
+ kind: node.kind,
1699
+ file: node.file,
1700
+ line: node.line,
1701
+ endLine: node.end_line || null,
1702
+ lineCount,
1703
+ summary,
1704
+ signature,
1705
+ callees,
1706
+ callers,
1707
+ relatedTests,
1708
+ };
1709
+ });
1710
+ }
1711
+
1712
+ export function explainData(target, customDbPath, opts = {}) {
1713
+ const db = openReadonlyOrFail(customDbPath);
1714
+ const noTests = opts.noTests || false;
1715
+ const kind = isFileLikeTarget(target) ? 'file' : 'function';
1716
+
1717
+ const dbPath = findDbPath(customDbPath);
1718
+ const repoRoot = path.resolve(path.dirname(dbPath), '..');
1719
+
1720
+ const fileCache = new Map();
1721
+ function getFileLines(file) {
1722
+ if (fileCache.has(file)) return fileCache.get(file);
1723
+ try {
1724
+ const absPath = safePath(repoRoot, file);
1725
+ if (!absPath) {
1726
+ fileCache.set(file, null);
1727
+ return null;
1728
+ }
1729
+ const lines = fs.readFileSync(absPath, 'utf-8').split('\n');
1730
+ fileCache.set(file, lines);
1731
+ return lines;
1732
+ } catch (e) {
1733
+ debug(`getFileLines failed for ${file}: ${e.message}`);
1734
+ fileCache.set(file, null);
1735
+ return null;
1736
+ }
1737
+ }
1738
+
1739
+ const results =
1740
+ kind === 'file'
1741
+ ? explainFileImpl(db, target, getFileLines)
1742
+ : explainFunctionImpl(db, target, noTests, getFileLines);
1743
+
1744
+ db.close();
1745
+ return { target, kind, results };
1746
+ }
1747
+
1748
+ export function explain(target, customDbPath, opts = {}) {
1749
+ const data = explainData(target, customDbPath, opts);
1750
+ if (opts.json) {
1751
+ console.log(JSON.stringify(data, null, 2));
1752
+ return;
1753
+ }
1754
+ if (data.results.length === 0) {
1755
+ console.log(`No ${data.kind === 'file' ? 'file' : 'function/symbol'} matching "${target}"`);
1756
+ return;
1757
+ }
1758
+
1759
+ if (data.kind === 'file') {
1760
+ for (const r of data.results) {
1761
+ const publicCount = r.publicApi.length;
1762
+ const internalCount = r.internal.length;
1763
+ const lineInfo = r.lineCount ? `${r.lineCount} lines, ` : '';
1764
+ console.log(`\n# ${r.file}`);
1765
+ console.log(
1766
+ ` ${lineInfo}${r.symbolCount} symbols (${publicCount} exported, ${internalCount} internal)`,
1767
+ );
1768
+
1769
+ if (r.imports.length > 0) {
1770
+ console.log(` Imports: ${r.imports.map((i) => i.file).join(', ')}`);
1771
+ }
1772
+ if (r.importedBy.length > 0) {
1773
+ console.log(` Imported by: ${r.importedBy.map((i) => i.file).join(', ')}`);
1774
+ }
1775
+
1776
+ if (r.publicApi.length > 0) {
1777
+ console.log(`\n## Exported`);
1778
+ for (const s of r.publicApi) {
1779
+ const sig = s.signature?.params != null ? `(${s.signature.params})` : '';
1780
+ const summary = s.summary ? ` -- ${s.summary}` : '';
1781
+ console.log(` ${kindIcon(s.kind)} ${s.name}${sig} :${s.line}${summary}`);
1782
+ }
1783
+ }
1784
+
1785
+ if (r.internal.length > 0) {
1786
+ console.log(`\n## Internal`);
1787
+ for (const s of r.internal) {
1788
+ const sig = s.signature?.params != null ? `(${s.signature.params})` : '';
1789
+ const summary = s.summary ? ` -- ${s.summary}` : '';
1790
+ console.log(` ${kindIcon(s.kind)} ${s.name}${sig} :${s.line}${summary}`);
1791
+ }
1792
+ }
1793
+
1794
+ if (r.dataFlow.length > 0) {
1795
+ console.log(`\n## Data Flow`);
1796
+ for (const df of r.dataFlow) {
1797
+ console.log(` ${df.caller} -> ${df.callees.join(', ')}`);
1798
+ }
1799
+ }
1800
+ console.log();
1801
+ }
1802
+ } else {
1803
+ for (const r of data.results) {
1804
+ const lineRange = r.endLine ? `${r.line}-${r.endLine}` : `${r.line}`;
1805
+ const lineInfo = r.lineCount ? `${r.lineCount} lines` : '';
1806
+ const summaryPart = r.summary ? ` | ${r.summary}` : '';
1807
+ console.log(`\n# ${r.name} (${r.kind}) ${r.file}:${lineRange}`);
1808
+ if (lineInfo || r.summary) {
1809
+ console.log(` ${lineInfo}${summaryPart}`);
1810
+ }
1811
+ if (r.signature) {
1812
+ if (r.signature.params != null) console.log(` Parameters: (${r.signature.params})`);
1813
+ if (r.signature.returnType) console.log(` Returns: ${r.signature.returnType}`);
1814
+ }
1815
+
1816
+ if (r.callees.length > 0) {
1817
+ console.log(`\n## Calls (${r.callees.length})`);
1818
+ for (const c of r.callees) {
1819
+ console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`);
1820
+ }
1821
+ }
1822
+
1823
+ if (r.callers.length > 0) {
1824
+ console.log(`\n## Called by (${r.callers.length})`);
1825
+ for (const c of r.callers) {
1826
+ console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`);
1827
+ }
1828
+ }
1829
+
1830
+ if (r.relatedTests.length > 0) {
1831
+ const label = r.relatedTests.length === 1 ? 'file' : 'files';
1832
+ console.log(`\n## Tests (${r.relatedTests.length} ${label})`);
1833
+ for (const t of r.relatedTests) {
1834
+ console.log(` ${t.file}`);
1835
+ }
1836
+ }
1837
+
1838
+ if (r.callees.length === 0 && r.callers.length === 0) {
1839
+ console.log(` (no call edges found -- may be invoked dynamically or via re-exports)`);
1840
+ }
1841
+ console.log();
1842
+ }
1843
+ }
1844
+ }
1845
+
1846
+ // ─── whereData ──────────────────────────────────────────────────────────
1847
+
1848
+ function whereSymbolImpl(db, target, noTests) {
1849
+ const placeholders = ALL_SYMBOL_KINDS.map(() => '?').join(', ');
1850
+ let nodes = db
1851
+ .prepare(
1852
+ `SELECT * FROM nodes WHERE name LIKE ? AND kind IN (${placeholders}) ORDER BY file, line`,
1853
+ )
1854
+ .all(`%${target}%`, ...ALL_SYMBOL_KINDS);
1855
+ if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
1856
+
1857
+ return nodes.map((node) => {
1858
+ const crossFileCallers = db
1859
+ .prepare(
1860
+ `SELECT COUNT(*) as cnt FROM edges e JOIN nodes n ON e.source_id = n.id
1861
+ WHERE e.target_id = ? AND e.kind = 'calls' AND n.file != ?`,
1862
+ )
1863
+ .get(node.id, node.file);
1864
+ const exported = crossFileCallers.cnt > 0;
1865
+
1866
+ let uses = db
1867
+ .prepare(
1868
+ `SELECT n.name, n.file, n.line FROM edges e JOIN nodes n ON e.source_id = n.id
1869
+ WHERE e.target_id = ? AND e.kind = 'calls'`,
1870
+ )
1871
+ .all(node.id);
1872
+ if (noTests) uses = uses.filter((u) => !isTestFile(u.file));
1873
+
1874
+ return {
1875
+ name: node.name,
1876
+ kind: node.kind,
1877
+ file: node.file,
1878
+ line: node.line,
1879
+ exported,
1880
+ uses: uses.map((u) => ({ name: u.name, file: u.file, line: u.line })),
1881
+ };
1882
+ });
1883
+ }
1884
+
1885
+ function whereFileImpl(db, target) {
1886
+ const fileNodes = db
1887
+ .prepare(`SELECT * FROM nodes WHERE file LIKE ? AND kind = 'file'`)
1888
+ .all(`%${target}%`);
1889
+ if (fileNodes.length === 0) return [];
1890
+
1891
+ return fileNodes.map((fn) => {
1892
+ const symbols = db
1893
+ .prepare(`SELECT * FROM nodes WHERE file = ? AND kind != 'file' ORDER BY line`)
1894
+ .all(fn.file);
1895
+
1896
+ const imports = db
1897
+ .prepare(
1898
+ `SELECT n.file FROM edges e JOIN nodes n ON e.target_id = n.id
1899
+ WHERE e.source_id = ? AND e.kind IN ('imports', 'imports-type')`,
1900
+ )
1901
+ .all(fn.id)
1902
+ .map((r) => r.file);
1903
+
1904
+ const importedBy = db
1905
+ .prepare(
1906
+ `SELECT n.file FROM edges e JOIN nodes n ON e.source_id = n.id
1907
+ WHERE e.target_id = ? AND e.kind IN ('imports', 'imports-type')`,
1908
+ )
1909
+ .all(fn.id)
1910
+ .map((r) => r.file);
1911
+
1912
+ const exportedIds = new Set(
1913
+ db
1914
+ .prepare(
1915
+ `SELECT DISTINCT e.target_id FROM edges e
1916
+ JOIN nodes caller ON e.source_id = caller.id
1917
+ JOIN nodes target ON e.target_id = target.id
1918
+ WHERE target.file = ? AND caller.file != ? AND e.kind = 'calls'`,
1919
+ )
1920
+ .all(fn.file, fn.file)
1921
+ .map((r) => r.target_id),
1922
+ );
1923
+
1924
+ const exported = symbols.filter((s) => exportedIds.has(s.id)).map((s) => s.name);
1925
+
1926
+ return {
1927
+ file: fn.file,
1928
+ symbols: symbols.map((s) => ({ name: s.name, kind: s.kind, line: s.line })),
1929
+ imports,
1930
+ importedBy,
1931
+ exported,
1932
+ };
1933
+ });
1934
+ }
1935
+
1936
+ export function whereData(target, customDbPath, opts = {}) {
1937
+ const db = openReadonlyOrFail(customDbPath);
1938
+ const noTests = opts.noTests || false;
1939
+ const fileMode = opts.file || false;
1940
+
1941
+ const results = fileMode ? whereFileImpl(db, target) : whereSymbolImpl(db, target, noTests);
1942
+
1943
+ db.close();
1944
+ return { target, mode: fileMode ? 'file' : 'symbol', results };
1945
+ }
1946
+
1947
+ export function where(target, customDbPath, opts = {}) {
1948
+ const data = whereData(target, customDbPath, opts);
1949
+ if (opts.json) {
1950
+ console.log(JSON.stringify(data, null, 2));
1951
+ return;
1952
+ }
1953
+
1954
+ if (data.results.length === 0) {
1955
+ console.log(
1956
+ data.mode === 'file'
1957
+ ? `No file matching "${target}" in graph`
1958
+ : `No symbol matching "${target}" in graph`,
1959
+ );
1960
+ return;
1961
+ }
1962
+
1963
+ if (data.mode === 'symbol') {
1964
+ for (const r of data.results) {
1965
+ const tag = r.exported ? ' (exported)' : '';
1966
+ console.log(`\n${kindIcon(r.kind)} ${r.name} ${r.file}:${r.line}${tag}`);
1967
+ if (r.uses.length > 0) {
1968
+ const useStrs = r.uses.map((u) => `${u.file}:${u.line}`);
1969
+ console.log(` Used in: ${useStrs.join(', ')}`);
1970
+ } else {
1971
+ console.log(' No uses found');
1972
+ }
1973
+ }
1974
+ } else {
1975
+ for (const r of data.results) {
1976
+ console.log(`\n# ${r.file}`);
1977
+ if (r.symbols.length > 0) {
1978
+ const symStrs = r.symbols.map((s) => `${s.name}:${s.line}`);
1979
+ console.log(` Symbols: ${symStrs.join(', ')}`);
1980
+ }
1981
+ if (r.imports.length > 0) {
1982
+ console.log(` Imports: ${r.imports.join(', ')}`);
1983
+ }
1984
+ if (r.importedBy.length > 0) {
1985
+ console.log(` Imported by: ${r.importedBy.join(', ')}`);
1986
+ }
1987
+ if (r.exported.length > 0) {
1988
+ console.log(` Exported: ${r.exported.join(', ')}`);
1989
+ }
1990
+ }
1991
+ }
1992
+ console.log();
1993
+ }
1994
+
948
1995
  export function fnImpact(name, customDbPath, opts = {}) {
949
1996
  const data = fnImpactData(name, customDbPath, opts);
950
1997
  if (opts.json) {