ucn 3.8.12 → 3.8.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/.claude/skills/ucn/SKILL.md +3 -1
  2. package/.github/workflows/ci.yml +15 -3
  3. package/.github/workflows/publish.yml +20 -8
  4. package/README.md +1 -0
  5. package/cli/index.js +165 -246
  6. package/core/analysis.js +1400 -0
  7. package/core/build-worker.js +194 -0
  8. package/core/cache.js +105 -7
  9. package/core/callers.js +194 -64
  10. package/core/deadcode.js +22 -66
  11. package/core/discovery.js +9 -54
  12. package/core/execute.js +139 -54
  13. package/core/graph.js +615 -0
  14. package/core/output/analysis-ext.js +271 -0
  15. package/core/output/analysis.js +491 -0
  16. package/core/output/extraction.js +188 -0
  17. package/core/output/find.js +355 -0
  18. package/core/output/graph.js +399 -0
  19. package/core/output/refactoring.js +293 -0
  20. package/core/output/reporting.js +331 -0
  21. package/core/output/search.js +307 -0
  22. package/core/output/shared.js +271 -0
  23. package/core/output/tracing.js +416 -0
  24. package/core/output.js +15 -3293
  25. package/core/parallel-build.js +165 -0
  26. package/core/project.js +299 -3633
  27. package/core/registry.js +59 -0
  28. package/core/reporting.js +258 -0
  29. package/core/search.js +890 -0
  30. package/core/stacktrace.js +1 -1
  31. package/core/tracing.js +631 -0
  32. package/core/verify.js +10 -13
  33. package/eslint.config.js +43 -0
  34. package/jsconfig.json +10 -0
  35. package/languages/go.js +21 -2
  36. package/languages/html.js +8 -0
  37. package/languages/index.js +102 -40
  38. package/languages/java.js +13 -0
  39. package/languages/javascript.js +17 -1
  40. package/languages/python.js +14 -0
  41. package/languages/rust.js +13 -0
  42. package/languages/utils.js +1 -1
  43. package/mcp/server.js +45 -28
  44. package/package.json +8 -3
package/core/project.js CHANGED
@@ -8,45 +8,26 @@
8
8
  const fs = require('fs');
9
9
  const path = require('path');
10
10
  const crypto = require('crypto');
11
- const { execFileSync } = require('child_process');
12
11
  const { expandGlob, findProjectRoot, detectProjectPattern, isTestFile, parseGitignore, DEFAULT_IGNORES } = require('./discovery');
13
12
  const { extractImports, extractExports, resolveImport } = require('./imports');
14
- const { parse, parseFile, cleanHtmlScriptTags } = require('./parser');
15
- const { detectLanguage, getParser, getLanguageModule, safeParse } = require('../languages');
13
+ const { parse, cleanHtmlScriptTags } = require('./parser');
14
+ const { detectLanguage, getParser, getLanguageModule, safeParse, langTraits } = require('../languages');
16
15
  const { getTokenTypeAtPosition } = require('../languages/utils');
17
- const { escapeRegExp, NON_CALLABLE_TYPES, addTestExclusions } = require('./shared');
16
+ const { escapeRegExp, NON_CALLABLE_TYPES } = require('./shared');
18
17
  const stacktrace = require('./stacktrace');
19
18
  const indexCache = require('./cache');
20
19
  const deadcodeModule = require('./deadcode');
21
20
  const verifyModule = require('./verify');
22
21
  const callersModule = require('./callers');
22
+ const tracingModule = require('./tracing');
23
+ const searchModule = require('./search');
24
+ const analysisModule = require('./analysis');
25
+ const graphModule = require('./graph');
26
+ const reportingModule = require('./reporting');
23
27
 
24
28
  // Lazy-initialized per-language keyword sets (populated on first isKeyword call)
25
29
  let LANGUAGE_KEYWORDS = null;
26
30
 
27
- /**
28
- * Build a glob-style matcher: * matches any sequence, ? matches one char.
29
- * Case-insensitive by default. Returns a function (string) => boolean.
30
- */
31
- function buildGlobMatcher(pattern, caseSensitive) {
32
- const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&')
33
- .replace(/\*/g, '.*')
34
- .replace(/\?/g, '.');
35
- const regex = new RegExp('^' + escaped + '$', caseSensitive ? '' : 'i');
36
- return (name) => regex.test(name);
37
- }
38
-
39
- const STRUCTURAL_TYPES = new Set(['function', 'class', 'call', 'method', 'type']);
40
-
41
- /**
42
- * Substring match. Case-insensitive by default.
43
- */
44
- function matchesSubstring(text, pattern, caseSensitive) {
45
- if (!text) return false;
46
- if (caseSensitive) return text.includes(pattern);
47
- return text.toLowerCase().includes(pattern.toLowerCase());
48
- }
49
-
50
31
  /**
51
32
  * ProjectIndex - Manages symbol table for a project
52
33
  */
@@ -94,6 +75,7 @@ class ProjectIndex {
94
75
  if (!this._opContentCache) {
95
76
  this._opContentCache = new Map();
96
77
  this._opUsagesCache = new Map();
78
+ this._opCallsCountCache = new Map();
97
79
  this._opDepth = 0;
98
80
  }
99
81
  this._opDepth++;
@@ -104,6 +86,7 @@ class ProjectIndex {
104
86
  if (--this._opDepth <= 0) {
105
87
  this._opContentCache = null;
106
88
  this._opUsagesCache = null;
89
+ this._opCallsCountCache = null;
107
90
  this._opDepth = 0;
108
91
  }
109
92
  }
@@ -228,15 +211,43 @@ class ProjectIndex {
228
211
  let indexed = 0;
229
212
  let changed = 0;
230
213
  if (!this.failedFiles) this.failedFiles = new Set();
231
- for (const file of files) {
214
+
215
+ // Try parallel build for large projects
216
+ const workersSetting = options.workers;
217
+ const envWorkers = parseInt(process.env.UCN_WORKERS, 10);
218
+ const disableParallel = workersSetting === 0 || envWorkers === 0;
219
+ let usedParallel = false;
220
+
221
+ if (!disableParallel && files.length > 500) {
232
222
  try {
233
- if (this.indexFile(file)) changed++;
234
- indexed++;
235
- this.failedFiles.delete(file); // Succeeded now, remove from failed
223
+ const { parallelBuild } = require('./parallel-build');
224
+ const result = parallelBuild(this, files, {
225
+ workerCount: workersSetting > 0 ? workersSetting : (envWorkers > 0 ? envWorkers : undefined),
226
+ quiet,
227
+ });
228
+ if (result !== false) {
229
+ changed = result;
230
+ indexed = files.length;
231
+ usedParallel = true;
232
+ }
236
233
  } catch (e) {
237
- this.failedFiles.add(file); // Track files that fail to index
238
234
  if (!quiet) {
239
- console.error(` Warning: Could not index ${file}: ${e.message}`);
235
+ console.error(`Parallel build failed, falling back to sequential: ${e.message}`);
236
+ }
237
+ }
238
+ }
239
+
240
+ if (!usedParallel) {
241
+ for (const file of files) {
242
+ try {
243
+ if (this.indexFile(file)) changed++;
244
+ indexed++;
245
+ this.failedFiles.delete(file); // Succeeded now, remove from failed
246
+ } catch (e) {
247
+ this.failedFiles.add(file); // Track files that fail to index
248
+ if (!quiet) {
249
+ console.error(` Warning: Could not index ${file}: ${e.message}`);
250
+ }
240
251
  }
241
252
  }
242
253
  }
@@ -247,6 +258,13 @@ class ProjectIndex {
247
258
  this.buildInheritanceGraph();
248
259
  }
249
260
 
261
+ // Build directory→files index for O(1) same-package lookups
262
+ this._buildDirIndex();
263
+
264
+ // Build callee index eagerly: leverages warm parse cache from indexFile() above,
265
+ // avoiding the 2+ minute deferred cost when the first analysis command runs later.
266
+ this.buildCalleeIndex();
267
+
250
268
  this.buildTime = Date.now() - startTime;
251
269
 
252
270
  if (!quiet) {
@@ -308,22 +326,17 @@ class ProjectIndex {
308
326
  // These are build artifacts, not user-written source code
309
327
  // Count lines without splitting: count newlines + 1 (avoids allocating array)
310
328
  let lineCount = 1;
311
- let maxLineLen = 0;
312
329
  let longLineCount = 0;
313
330
  let lineStart = 0;
314
331
  for (let ci = 0; ci < content.length; ci++) {
315
332
  if (content.charCodeAt(ci) === 10) { // '\n'
316
- const lineLen = ci - lineStart;
317
- if (lineLen > maxLineLen) maxLineLen = lineLen;
318
- if (lineLen > 1000) longLineCount++;
333
+ if (ci - lineStart > 1000) longLineCount++;
319
334
  lineStart = ci + 1;
320
335
  lineCount++;
321
336
  }
322
337
  }
323
338
  // Handle last line (no trailing newline)
324
- const lastLineLen = content.length - lineStart;
325
- if (lastLineLen > maxLineLen) maxLineLen = lastLineLen;
326
- if (lastLineLen > 1000) longLineCount++;
339
+ if (content.length - lineStart > 1000) longLineCount++;
327
340
 
328
341
  const isBundled = (() => {
329
342
  // Webpack bundles contain __webpack_require__ or __webpack_modules__
@@ -335,6 +348,13 @@ class ProjectIndex {
335
348
  return false;
336
349
  })();
337
350
 
351
+ // Detect auto-generated files (e.g., Go client-gen, protobuf, code generators).
352
+ // Check first ~500 chars for common markers. These files are indexed but
353
+ // deprioritized in resolveSymbol() scoring.
354
+ const isGenerated = /^\/\/\s*Code generated\b|^\/\/\s*DO NOT EDIT|^\/\/ @generated|^# Generated by/m.test(
355
+ content.slice(0, 500)
356
+ );
357
+
338
358
  const fileEntry = {
339
359
  path: filePath,
340
360
  relativePath: path.relative(this.root, filePath),
@@ -350,7 +370,8 @@ class ProjectIndex {
350
370
  symbols: [],
351
371
  bindings: [],
352
372
  ...(importAliases && { importAliases }),
353
- ...(isBundled && { isBundled: true })
373
+ ...(isBundled && { isBundled: true }),
374
+ ...(isGenerated && { isGenerated: true })
354
375
  };
355
376
  fileEntry.dynamicImports = dynamicCount || 0;
356
377
 
@@ -455,6 +476,23 @@ class ProjectIndex {
455
476
  if (this._attrTypeCache) this._attrTypeCache.delete(filePath);
456
477
  }
457
478
 
479
+ /**
480
+ * Build directory→files index for O(1) same-package lookups.
481
+ * Replaces O(N) full-index scans in findCallers and countSymbolUsages.
482
+ */
483
+ _buildDirIndex() {
484
+ this.dirToFiles = new Map();
485
+ for (const filePath of this.files.keys()) {
486
+ const dir = path.dirname(filePath);
487
+ let list = this.dirToFiles.get(dir);
488
+ if (!list) {
489
+ list = [];
490
+ this.dirToFiles.set(dir, list);
491
+ }
492
+ list.push(filePath);
493
+ }
494
+ }
495
+
458
496
  /**
459
497
  * Build inverted call index: callee name -> Set<filePath>.
460
498
  * Built lazily on first findCallers call, from the calls cache.
@@ -465,7 +503,9 @@ class ProjectIndex {
465
503
  this.calleeIndex = new Map();
466
504
 
467
505
  for (const [filePath] of this.files) {
468
- const calls = getCachedCalls(this, filePath);
506
+ // Fast path: use pre-populated callsCache (avoids stat per file)
507
+ const cached = this.callsCache.get(filePath);
508
+ const calls = cached ? cached.calls : getCachedCalls(this, filePath);
469
509
  if (!calls) continue;
470
510
  for (const call of calls) {
471
511
  const name = call.name;
@@ -570,7 +610,7 @@ class ProjectIndex {
570
610
  // Pre-build filename→files map for Java import resolution (O(1) vs O(n) scan)
571
611
  const javaFileIndex = new Map();
572
612
  for (const [fp, fe] of this.files) {
573
- if (fe.language === 'go') {
613
+ if (langTraits(fe.language)?.packageScope === 'directory') {
574
614
  const dir = path.dirname(fp);
575
615
  if (!dirToGoFiles.has(dir)) dirToGoFiles.set(dir, []);
576
616
  dirToGoFiles.get(dir).push(fp);
@@ -610,7 +650,7 @@ class ProjectIndex {
610
650
  // For Go, a package import means all files in that directory are dependencies
611
651
  // (Go packages span multiple files in the same directory)
612
652
  const filesToLink = [resolved];
613
- if (fileEntry.language === 'go') {
653
+ if (langTraits(fileEntry.language)?.packageScope === 'directory') {
614
654
  const pkgDir = path.dirname(resolved);
615
655
  const dirFiles = dirToGoFiles.get(pkgDir) || [];
616
656
  const importerIsTest = filePath.endsWith('_test.go');
@@ -923,6 +963,14 @@ class ProjectIndex {
923
963
  if (/^(examples?|docs?|vendor|third[_-]?party|benchmarks?|samples?)\//i.test(rp)) {
924
964
  score -= 300;
925
965
  }
966
+ // Deprioritize auto-generated files (client-gen, protobuf, etc.)
967
+ // Light penalty (-100): generated code checked into the repo is often
968
+ // first-class API surface (Go client-gen, Java GRPC stubs), so prefer
969
+ // hand-written code but don't bury generated definitions.
970
+ const fileEntry = this.files.get(d.file);
971
+ if (fileEntry?.isGenerated) {
972
+ score -= 100;
973
+ }
926
974
  // Boost lib/src/core/internal directories (+200)
927
975
  if (/^(lib|src|core|internal|pkg|crates)\//i.test(rp)) {
928
976
  score += 200;
@@ -961,7 +1009,7 @@ class ProjectIndex {
961
1009
  }
962
1010
  // For Go, also count importers of sibling files (same package)
963
1011
  const candidateEntry = this.files.get(candidate.def.file);
964
- if (candidateEntry?.language === 'go') {
1012
+ if (langTraits(candidateEntry?.language)?.packageScope === 'directory') {
965
1013
  const candidateDir = path.dirname(candidate.def.file);
966
1014
  for (const [, importedFiles] of this.importGraph) {
967
1015
  for (const imp of importedFiles) {
@@ -1006,104 +1054,9 @@ class ProjectIndex {
1006
1054
  return { def, definitions, warnings };
1007
1055
  }
1008
1056
 
1009
- find(name, options = {}) {
1010
- this._beginOp();
1011
- try {
1012
- // Glob pattern matching (e.g., _update*, handle*Request, get?ata)
1013
- const isGlob = name.includes('*') || name.includes('?');
1014
- if (isGlob && !options.exact) {
1015
- // Bare wildcard: return all symbols
1016
- const stripped = name.replace(/[*?]/g, '');
1017
- if (stripped.length === 0) {
1018
- const all = [];
1019
- for (const [, symbols] of this.symbols) {
1020
- for (const sym of symbols) {
1021
- all.push({ ...sym, _fuzzyScore: 800 });
1022
- }
1023
- }
1024
- all.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
1025
- return this._applyFindFilters(all, options);
1026
- }
1027
- const globRegex = new RegExp('^' + name.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i');
1028
- const matches = [];
1029
- for (const [symName, symbols] of this.symbols) {
1030
- if (globRegex.test(symName)) {
1031
- for (const sym of symbols) {
1032
- matches.push({ ...sym, _fuzzyScore: 800 });
1033
- }
1034
- }
1035
- }
1036
- matches.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
1037
- return this._applyFindFilters(matches, options);
1038
- }
1039
-
1040
- const matches = this.symbols.get(name) || [];
1041
-
1042
- if (matches.length === 0 && !options.exact) {
1043
- // Smart fuzzy search with scoring
1044
- const candidates = [];
1045
- for (const [symName, symbols] of this.symbols) {
1046
- const score = this.fuzzyScore(name, symName);
1047
- if (score > 0) {
1048
- for (const sym of symbols) {
1049
- candidates.push({ ...sym, _fuzzyScore: score });
1050
- }
1051
- }
1052
- }
1053
- // Sort by fuzzy score descending
1054
- candidates.sort((a, b) => b._fuzzyScore - a._fuzzyScore);
1055
- matches.push(...candidates);
1056
- }
1057
-
1058
- return this._applyFindFilters(matches, options);
1059
- } finally { this._endOp(); }
1060
- }
1061
-
1062
- /**
1063
- * Apply file/exclude/in filters and usage counts to find results
1064
- */
1065
- _applyFindFilters(matches, options) {
1066
- let filtered = matches;
1067
-
1068
- // Filter by class name (Class.method syntax)
1069
- if (options.className) {
1070
- filtered = filtered.filter(m => m.className === options.className);
1071
- }
1072
-
1073
- // Filter by file pattern
1074
- if (options.file) {
1075
- filtered = filtered.filter(m =>
1076
- m.relativePath && m.relativePath.includes(options.file)
1077
- );
1078
- }
1079
-
1080
- // Apply semantic filters (--exclude, --in)
1081
- if (options.exclude || options.in) {
1082
- filtered = filtered.filter(m =>
1083
- this.matchesFilters(m.relativePath, { exclude: options.exclude, in: options.in })
1084
- );
1085
- }
1086
-
1087
- // Skip expensive usage counting when caller doesn't need it
1088
- if (options.skipCounts) {
1089
- return filtered;
1090
- }
1091
-
1092
- // Add per-symbol usage counts for disambiguation
1093
- const withCounts = filtered.map(m => {
1094
- const counts = this.countSymbolUsages(m);
1095
- return {
1096
- ...m,
1097
- usageCount: counts.total,
1098
- usageCounts: counts // { total, calls, definitions, imports, references }
1099
- };
1100
- });
1057
+ find(name, options) { return searchModule.find(this, name, options); }
1101
1058
 
1102
- // Sort by usage count (most-used first)
1103
- withCounts.sort((a, b) => b.usageCount - a.usageCount);
1104
-
1105
- return withCounts;
1106
- }
1059
+ _applyFindFilters(matches, options) { return searchModule._applyFindFilters(this, matches, options); }
1107
1060
 
1108
1061
 
1109
1062
  /**
@@ -1124,27 +1077,58 @@ class ProjectIndex {
1124
1077
  if (!this.calleeIndex) this.buildCalleeIndex();
1125
1078
  const hasFilters = options.exclude && options.exclude.length > 0;
1126
1079
 
1127
- // Count calls from callee index (files containing calls to this name)
1128
- const calleeFiles = this.calleeIndex.get(name);
1129
- let calls = 0;
1130
- if (calleeFiles) {
1131
- // Count actual call entries from calls cache for accuracy
1132
- const { getCachedCalls } = require('./callers');
1133
- for (const fp of calleeFiles) {
1134
- // Apply exclude filters
1135
- if (hasFilters) {
1136
- const fe = this.files.get(fp);
1137
- if (fe && !this.matchesFilters(fe.relativePath, { exclude: options.exclude })) continue;
1138
- }
1139
- const fileCalls = getCachedCalls(this, fp);
1140
- if (!fileCalls) continue;
1141
- for (const c of fileCalls) {
1142
- if (c.name === name || c.resolvedName === name ||
1143
- (c.resolvedNames && c.resolvedNames.includes(name))) {
1144
- calls++;
1080
+ // Pre-compute which files can reference THIS specific definition
1081
+ const importers = this.exportGraph.get(defFile) || [];
1082
+ const importersSet = new Set(importers);
1083
+ const defEntry = this.files.get(defFile);
1084
+ const isDirectoryScope = langTraits(defEntry?.language)?.packageScope === 'directory';
1085
+ const defDir = isDirectoryScope ? path.dirname(defFile) : null;
1086
+
1087
+ // Count calls from callee index, filtered per-definition.
1088
+ // Use per-operation cache to avoid re-iterating getCachedCalls for the same name
1089
+ // (e.g., `find Run` with 268 definitions sharing the name "Run").
1090
+ let perFileCallCounts;
1091
+ if (this._opCallsCountCache && this._opCallsCountCache.has(name)) {
1092
+ perFileCallCounts = this._opCallsCountCache.get(name);
1093
+ } else {
1094
+ perFileCallCounts = new Map();
1095
+ const calleeFiles = this.calleeIndex.get(name);
1096
+ if (calleeFiles) {
1097
+ const { getCachedCalls } = require('./callers');
1098
+ for (const fp of calleeFiles) {
1099
+ const fileCalls = getCachedCalls(this, fp);
1100
+ if (!fileCalls) continue;
1101
+ let fileCount = 0;
1102
+ for (const c of fileCalls) {
1103
+ if (c.name === name || c.resolvedName === name ||
1104
+ (c.resolvedNames && c.resolvedNames.includes(name))) {
1105
+ fileCount++;
1106
+ }
1145
1107
  }
1108
+ if (fileCount > 0) perFileCallCounts.set(fp, fileCount);
1146
1109
  }
1147
1110
  }
1111
+ if (this._opCallsCountCache) {
1112
+ this._opCallsCountCache.set(name, perFileCallCounts);
1113
+ }
1114
+ }
1115
+
1116
+ // Sum calls only from files that can reference THIS definition
1117
+ let calls = 0;
1118
+ for (const [fp, count] of perFileCallCounts) {
1119
+ if (hasFilters) {
1120
+ const fe = this.files.get(fp);
1121
+ if (fe && !this.matchesFilters(fe.relativePath, { exclude: options.exclude })) continue;
1122
+ }
1123
+ // Per-definition filtering for directory-scoped languages (Go/Java/Rust):
1124
+ // only count calls from files that import from defFile, are in the same
1125
+ // package, or are the definition file itself. For structural type systems
1126
+ // (JS/TS/Python), skip this filter — method calls can come from files
1127
+ // without import relationships (objects passed as parameters, etc.)
1128
+ if (isDirectoryScope && fp !== defFile && !importersSet.has(fp)) {
1129
+ if (path.dirname(fp) !== defDir) continue;
1130
+ }
1131
+ calls += count;
1148
1132
  }
1149
1133
 
1150
1134
  // Count definitions from symbol table
@@ -1158,7 +1142,6 @@ class ProjectIndex {
1158
1142
 
1159
1143
  // Count imports from import graph (files that import from defFile and use this name)
1160
1144
  let imports = 0;
1161
- const importers = this.exportGraph.get(defFile) || [];
1162
1145
  for (const importer of importers) {
1163
1146
  const fe = this.files.get(importer);
1164
1147
  if (!fe) continue;
@@ -1168,6 +1151,25 @@ class ProjectIndex {
1168
1151
  imports++;
1169
1152
  }
1170
1153
  }
1154
+ // Same-package: files in same directory don't need imports to reference symbols
1155
+ if (isDirectoryScope) {
1156
+ const pkgDir = defDir;
1157
+ for (const [fp, fe] of this.files) {
1158
+ if (fp === defFile || !fp.endsWith('.go') || path.dirname(fp) !== pkgDir) continue;
1159
+ if (hasFilters && !this.matchesFilters(fe.relativePath, { exclude: options.exclude })) continue;
1160
+ // Check if already counted as importer
1161
+ if (importersSet.has(fp)) continue;
1162
+ // Check callee index for actual calls from this file
1163
+ if (perFileCallCounts.has(fp)) {
1164
+ // Already counted in calls — don't double-count
1165
+ continue;
1166
+ }
1167
+ // Check if this same-package file has text references to the symbol
1168
+ if (fe.importNames && fe.importNames.includes(name)) {
1169
+ imports++;
1170
+ }
1171
+ }
1172
+ }
1171
1173
 
1172
1174
  const total = calls + definitions + imports;
1173
1175
  return { total, calls, definitions, imports, references: 0 };
@@ -1185,12 +1187,13 @@ class ProjectIndex {
1185
1187
  const relevantFiles = new Set([defFile]);
1186
1188
  const queue = [defFile];
1187
1189
 
1188
- // Go same-package: add all .go files in the same directory
1190
+ // Same-package: add all files in the same directory (Go package scope)
1189
1191
  const defEntry = this.files.get(defFile);
1190
- if (defEntry?.language === 'go') {
1192
+ if (langTraits(defEntry?.language)?.packageScope === 'directory') {
1191
1193
  const pkgDir = path.dirname(defFile);
1192
- for (const fp of this.files.keys()) {
1193
- if (fp !== defFile && fp.endsWith('.go') && path.dirname(fp) === pkgDir) {
1194
+ const siblings = this.dirToFiles?.get(pkgDir) || [];
1195
+ for (const fp of siblings) {
1196
+ if (fp !== defFile) {
1194
1197
  relevantFiles.add(fp);
1195
1198
  }
1196
1199
  }
@@ -1287,187 +1290,7 @@ class ProjectIndex {
1287
1290
  * @param {object} options - { codeOnly, context, exclude, in }
1288
1291
  * @returns {Array} Usages grouped as definitions, calls, imports, references
1289
1292
  */
1290
- usages(name, options = {}) {
1291
- this._beginOp();
1292
- try {
1293
- const usages = [];
1294
-
1295
- // Resolve file pattern for --file filter
1296
- const fileFilter = options.file ? this.resolveFilePathForQuery(options.file) : null;
1297
-
1298
- // Get definitions (filtered)
1299
- let allDefinitions = this.symbols.get(name) || [];
1300
- if (options.className) {
1301
- allDefinitions = allDefinitions.filter(d => d.className === options.className);
1302
- }
1303
- if (fileFilter) {
1304
- allDefinitions = allDefinitions.filter(d => d.file === fileFilter);
1305
- }
1306
- const definitions = options.exclude || options.in
1307
- ? allDefinitions.filter(d => this.matchesFilters(d.relativePath, options))
1308
- : allDefinitions;
1309
-
1310
- for (const def of definitions) {
1311
- usages.push({
1312
- ...def,
1313
- isDefinition: true,
1314
- line: def.startLine,
1315
- content: this.getLineContent(def.file, def.startLine),
1316
- signature: this.formatSignature(def)
1317
- });
1318
- }
1319
-
1320
- // Scan all files for usages
1321
- for (const [filePath, fileEntry] of this.files) {
1322
- // Apply --file filter
1323
- if (fileFilter && filePath !== fileFilter) {
1324
- continue;
1325
- }
1326
- // Apply filters
1327
- if (!this.matchesFilters(fileEntry.relativePath, options)) {
1328
- continue;
1329
- }
1330
-
1331
- try {
1332
- const content = this._readFile(filePath);
1333
-
1334
- // Fast pre-check: skip if name doesn't appear in file at all
1335
- if (!content.includes(name)) continue;
1336
-
1337
- const lines = content.split('\n');
1338
-
1339
- // Try AST-based detection first (with per-operation cache)
1340
- const astUsages = this._getCachedUsages(filePath, name);
1341
- if (astUsages !== null) {
1342
- // Pre-compute: does any imported project file define this name?
1343
- // Used to filter namespace member expressions (e.g., DropdownMenuPrimitive.Separator)
1344
- // while keeping module access patterns (e.g., output.formatExample())
1345
- let _importedHasDef = null;
1346
- const importedFileHasDef = () => {
1347
- if (_importedHasDef !== null) return _importedHasDef;
1348
- const importedFiles = this.importGraph.get(filePath) || [];
1349
- _importedHasDef = importedFiles.some(imp => {
1350
- const impEntry = this.files.get(imp);
1351
- return impEntry?.symbols?.some(s => s.name === name);
1352
- });
1353
- return _importedHasDef;
1354
- };
1355
-
1356
- for (const u of astUsages) {
1357
- // Skip if this is a definition line (already added above)
1358
- if (definitions.some(d => d.file === filePath && d.startLine === u.line)) {
1359
- continue;
1360
- }
1361
-
1362
- // Filter member expressions with unrelated receivers in JS/TS/Python.
1363
- // Keeps: standalone usages, self/this/cls/super, method calls on known types,
1364
- // and module access (output.fn()) when the imported file defines the name.
1365
- // Filters: namespace access to external packages (DropdownMenuPrimitive.Separator).
1366
- if (u.receiver && !['self', 'this', 'cls', 'super'].includes(u.receiver) &&
1367
- fileEntry.language !== 'go' && fileEntry.language !== 'java' && fileEntry.language !== 'rust') {
1368
- const hasMethodDef = definitions.some(d => d.className);
1369
- if (!hasMethodDef && !importedFileHasDef()) {
1370
- continue;
1371
- }
1372
- }
1373
-
1374
- const lineContent = lines[u.line - 1] || '';
1375
-
1376
- const usage = {
1377
- file: filePath,
1378
- relativePath: fileEntry.relativePath,
1379
- line: u.line,
1380
- content: lineContent,
1381
- usageType: u.usageType,
1382
- isDefinition: false,
1383
- ...(u.receiver && { receiver: u.receiver })
1384
- };
1385
-
1386
- // Add context lines if requested
1387
- if (options.context && options.context > 0) {
1388
- const idx = u.line - 1;
1389
- const before = [];
1390
- const after = [];
1391
- for (let i = 1; i <= options.context; i++) {
1392
- if (idx - i >= 0) before.unshift(lines[idx - i]);
1393
- if (idx + i < lines.length) after.push(lines[idx + i]);
1394
- }
1395
- usage.before = before;
1396
- usage.after = after;
1397
- }
1398
-
1399
- usages.push(usage);
1400
- }
1401
- continue; // Skip to next file
1402
- }
1403
-
1404
- // Fallback to regex-based detection
1405
- const regex = new RegExp('\\b' + escapeRegExp(name) + '\\b');
1406
- lines.forEach((line, idx) => {
1407
- const lineNum = idx + 1;
1408
-
1409
- // Skip if this is a definition line
1410
- if (definitions.some(d => d.file === filePath && d.startLine === lineNum)) {
1411
- return;
1412
- }
1413
-
1414
- if (regex.test(line)) {
1415
- // Skip if codeOnly and line is comment/string
1416
- if (options.codeOnly && this.isCommentOrStringAtPosition(content, lineNum, 0, filePath)) {
1417
- return;
1418
- }
1419
-
1420
- // Skip if the match is inside a string literal
1421
- if (this.isInsideStringAST(content, lineNum, line, name, filePath)) {
1422
- return;
1423
- }
1424
-
1425
- // Classify usage type (AST-based, defaults to 'reference' for unsupported languages)
1426
- const usageType = this.classifyUsageAST(content, lineNum, name, filePath) ?? 'reference';
1427
-
1428
- const usage = {
1429
- file: filePath,
1430
- relativePath: fileEntry.relativePath,
1431
- line: lineNum,
1432
- content: line,
1433
- usageType,
1434
- isDefinition: false
1435
- };
1436
-
1437
- // Add context lines if requested
1438
- if (options.context && options.context > 0) {
1439
- const before = [];
1440
- const after = [];
1441
- for (let i = 1; i <= options.context; i++) {
1442
- if (idx - i >= 0) before.unshift(lines[idx - i]);
1443
- if (idx + i < lines.length) after.push(lines[idx + i]);
1444
- }
1445
- usage.before = before;
1446
- usage.after = after;
1447
- }
1448
-
1449
- usages.push(usage);
1450
- }
1451
- });
1452
- } catch (e) {
1453
- // Skip unreadable files
1454
- }
1455
- }
1456
-
1457
- // Deduplicate same-file, same-line, same-usageType entries
1458
- // (e.g., `detectLanguage: parser.detectLanguage` has the name twice on one line)
1459
- const seen = new Set();
1460
- const deduped = [];
1461
- for (const u of usages) {
1462
- const key = `${u.file}:${u.line}:${u.usageType}:${u.isDefinition}`;
1463
- if (!seen.has(key)) {
1464
- seen.add(key);
1465
- deduped.push(u);
1466
- }
1467
- }
1468
- return deduped;
1469
- } finally { this._endOp(); }
1470
- }
1293
+ usages(name, options) { return searchModule.usages(this, name, options); }
1471
1294
 
1472
1295
  /**
1473
1296
  * Find methods that belong to a class/struct/type
@@ -1522,111 +1345,7 @@ class ProjectIndex {
1522
1345
  /**
1523
1346
  * Get context for a symbol (callers + callees)
1524
1347
  */
1525
- context(name, options = {}) {
1526
- this._beginOp();
1527
- try {
1528
- const resolved = this.resolveSymbol(name, { file: options.file, className: options.className });
1529
- let { def, definitions, warnings } = resolved;
1530
- if (!def) {
1531
- return null;
1532
- }
1533
-
1534
- // Special handling for class/struct/interface types
1535
- if (['class', 'struct', 'interface', 'type'].includes(def.type)) {
1536
- const methods = this.findMethodsForType(name);
1537
-
1538
- let typeCallers = this.findCallers(name, { includeMethods: options.includeMethods, includeUncertain: options.includeUncertain });
1539
- // Apply exclude filter
1540
- if (options.exclude && options.exclude.length > 0) {
1541
- typeCallers = typeCallers.filter(c => this.matchesFilters(c.relativePath, { exclude: options.exclude }));
1542
- }
1543
-
1544
- const result = {
1545
- type: def.type,
1546
- name: name,
1547
- file: def.relativePath,
1548
- startLine: def.startLine,
1549
- endLine: def.endLine,
1550
- methods: methods.map(m => ({
1551
- name: m.name,
1552
- file: m.relativePath,
1553
- line: m.startLine,
1554
- params: m.params,
1555
- returnType: m.returnType,
1556
- receiver: m.receiver
1557
- })),
1558
- // Also include places where the type is used in function parameters/returns
1559
- callers: typeCallers
1560
- };
1561
-
1562
- if (warnings.length > 0) {
1563
- result.warnings = warnings;
1564
- }
1565
-
1566
- return result;
1567
- }
1568
-
1569
- const stats = { uncertain: 0 };
1570
- let callers = this.findCallers(name, { includeMethods: options.includeMethods, includeUncertain: options.includeUncertain, stats, targetDefinitions: [def] });
1571
- let callees = this.findCallees(def, { includeMethods: options.includeMethods, includeUncertain: options.includeUncertain, stats });
1572
-
1573
- // Apply exclude filter
1574
- if (options.exclude && options.exclude.length > 0) {
1575
- callers = callers.filter(c => this.matchesFilters(c.relativePath, { exclude: options.exclude }));
1576
- callees = callees.filter(c => this.matchesFilters(c.relativePath, { exclude: options.exclude }));
1577
- }
1578
-
1579
- // Apply confidence filtering
1580
- let confidenceFiltered = 0;
1581
- if (options.minConfidence > 0) {
1582
- const { filterByConfidence } = require('./confidence');
1583
- const callerResult = filterByConfidence(callers, options.minConfidence);
1584
- const calleeResult = filterByConfidence(callees, options.minConfidence);
1585
- callers = callerResult.kept;
1586
- callees = calleeResult.kept;
1587
- confidenceFiltered = callerResult.filtered + calleeResult.filtered;
1588
- }
1589
-
1590
- const filesInScope = new Set([def.file]);
1591
- callers.forEach(c => filesInScope.add(c.file));
1592
- callees.forEach(c => filesInScope.add(c.file));
1593
- let dynamicImports = 0;
1594
- for (const f of filesInScope) {
1595
- const fe = this.files.get(f);
1596
- if (fe?.dynamicImports) dynamicImports += fe.dynamicImports;
1597
- }
1598
-
1599
- const result = {
1600
- function: name,
1601
- file: def.relativePath,
1602
- startLine: def.startLine,
1603
- endLine: def.endLine,
1604
- params: def.params,
1605
- returnType: def.returnType,
1606
- callers,
1607
- callees,
1608
- meta: {
1609
- complete: stats.uncertain === 0 && dynamicImports === 0 && confidenceFiltered === 0,
1610
- skipped: 0,
1611
- dynamicImports,
1612
- uncertain: stats.uncertain,
1613
- confidenceFiltered,
1614
- includeMethods: !!options.includeMethods,
1615
- projectLanguage: this._getPredominantLanguage(),
1616
- // Structural facts for reliability hints
1617
- ...(def.isMethod && { isMethod: true }),
1618
- ...(def.className && { className: def.className }),
1619
- ...(def.receiver && { receiver: def.receiver })
1620
- }
1621
- };
1622
-
1623
- if (warnings.length > 0) {
1624
- result.warnings = warnings;
1625
- }
1626
-
1627
- return result;
1628
- } finally { this._endOp(); }
1629
- }
1348
+ context(name, options) { return analysisModule.context(this, name, options); }
1630
1349
 
1631
1350
  /** Get cached call sites for a file, with mtime/hash validation */
1632
1351
  getCachedCalls(filePath, options) { return callersModule.getCachedCalls(this, filePath, options); }
@@ -1692,75 +1411,8 @@ class ProjectIndex {
1692
1411
  return 'normal';
1693
1412
  }
1694
1413
 
1695
- /**
1696
- * Smart extraction: function + dependencies
1697
- */
1698
- smart(name, options = {}) {
1699
- this._beginOp();
1700
- try {
1701
- const { def } = this.resolveSymbol(name, { file: options.file, className: options.className });
1702
- if (!def) {
1703
- return null;
1704
- }
1705
- const code = this.extractCode(def);
1706
- const stats = { uncertain: 0 };
1707
- const callees = this.findCallees(def, { includeMethods: options.includeMethods, includeUncertain: options.includeUncertain, stats });
1708
-
1709
- const filesInScope = new Set([def.file]);
1710
- callees.forEach(c => filesInScope.add(c.file));
1711
- let dynamicImports = 0;
1712
- for (const f of filesInScope) {
1713
- const fe = this.files.get(f);
1714
- if (fe?.dynamicImports) dynamicImports += fe.dynamicImports;
1715
- }
1716
-
1717
- // Extract code for each dependency, excluding the exact same function
1718
- // (but keeping same-name overloads, e.g. Java toJson(Object) vs toJson(Object, Class))
1719
- const defBindingId = def.bindingId;
1720
- const dependencies = callees
1721
- .filter(callee => callee.bindingId !== defBindingId)
1722
- .map(callee => ({
1723
- ...callee,
1724
- code: this.extractCode(callee)
1725
- }));
1726
-
1727
- // Find type definitions if requested
1728
- const types = [];
1729
- if (options.withTypes) {
1730
- // Look for type annotations in params/return type
1731
- const typeNames = this.extractTypeNames(def);
1732
- for (const typeName of typeNames) {
1733
- const typeSymbols = this.symbols.get(typeName);
1734
- if (typeSymbols) {
1735
- for (const sym of typeSymbols) {
1736
- if (['type', 'interface', 'class', 'struct'].includes(sym.type)) {
1737
- types.push({
1738
- ...sym,
1739
- code: this.extractCode(sym)
1740
- });
1741
- }
1742
- }
1743
- }
1744
- }
1745
- }
1746
-
1747
- return {
1748
- target: {
1749
- ...def,
1750
- code
1751
- },
1752
- dependencies,
1753
- types,
1754
- meta: {
1755
- complete: stats.uncertain === 0 && dynamicImports === 0,
1756
- skipped: 0,
1757
- dynamicImports,
1758
- uncertain: stats.uncertain,
1759
- projectLanguage: this._getPredominantLanguage()
1760
- }
1761
- };
1762
- } finally { this._endOp(); }
1763
- }
1414
+ /** Smart extraction: function + dependencies */
1415
+ smart(name, options) { return analysisModule.smart(this, name, options); }
1764
1416
 
1765
1417
  // ========================================================================
1766
1418
  // HELPER METHODS
@@ -2156,349 +1808,50 @@ class ProjectIndex {
2156
1808
  * @param {string} filePath - File to get imports for
2157
1809
  * @returns {Array} Imports with resolved paths
2158
1810
  */
2159
- imports(filePath) {
2160
- const resolved = this.resolveFilePathForQuery(filePath);
2161
- if (typeof resolved !== 'string') return resolved;
2162
-
2163
- const normalizedPath = resolved;
2164
- const fileEntry = this.files.get(normalizedPath);
2165
- if (!fileEntry) {
2166
- return { error: 'file-not-found', filePath };
2167
- }
2168
-
2169
- try {
2170
- const content = this._readFile(normalizedPath);
2171
- const { imports: rawImports } = extractImports(content, fileEntry.language);
2172
-
2173
- const contentLines = content.split('\n');
2174
-
2175
- return rawImports.map(imp => {
2176
- // Skip imports with null module (e.g. Rust include! with dynamic path)
2177
- if (!imp.module) {
2178
- return {
2179
- module: null,
2180
- names: imp.names,
2181
- type: imp.type,
2182
- resolved: null,
2183
- isExternal: false,
2184
- isDynamic: true,
2185
- line: null
2186
- };
2187
- }
2188
-
2189
- // Dynamic imports with variable path (e.g. require(varName), import(varExpr)) can't be resolved.
2190
- // Only JS/TS require()/import() with dynamic=true has unresolvable paths.
2191
- // Go side-effect/dot imports and Rust glob uses also set dynamic=true but have valid module paths.
2192
- const isUnresolvableDynamic = imp.dynamic && (imp.type === 'require' || imp.type === 'dynamic');
2193
- if (isUnresolvableDynamic) {
2194
- let line = null;
2195
- for (let i = 0; i < contentLines.length; i++) {
2196
- if (contentLines[i].includes(imp.module || 'require')) {
2197
- line = i + 1;
2198
- break;
2199
- }
2200
- }
2201
- return {
2202
- module: imp.module,
2203
- names: imp.names,
2204
- type: imp.type,
2205
- resolved: null,
2206
- isExternal: false,
2207
- isDynamic: true,
2208
- line
2209
- };
2210
- }
2211
-
2212
- let resolved = resolveImport(imp.module, normalizedPath, {
2213
- aliases: this.config.aliases,
2214
- language: fileEntry.language,
2215
- root: this.root
2216
- });
2217
-
2218
- // Java package imports: resolve by progressive suffix matching
2219
- // Handles regular, static (com.pkg.Class.method), and wildcard (com.pkg.Class.*) imports
2220
- if (!resolved && fileEntry.language === 'java' && !imp.module.startsWith('.')) {
2221
- resolved = this._resolveJavaPackageImport(imp.module);
2222
- }
2223
-
2224
- // Find line number of import
2225
- let line = null;
2226
- for (let i = 0; i < contentLines.length; i++) {
2227
- if (contentLines[i].includes(imp.module)) {
2228
- line = i + 1;
2229
- break;
2230
- }
2231
- }
2232
-
2233
- return {
2234
- module: imp.module,
2235
- names: imp.names,
2236
- type: imp.type,
2237
- resolved: resolved ? path.relative(this.root, resolved) : null,
2238
- isExternal: !resolved,
2239
- isDynamic: false,
2240
- line
2241
- };
2242
- });
2243
- } catch (e) {
2244
- return [];
2245
- }
2246
- }
1811
+ imports(filePath) { return graphModule.imports(this, filePath); }
2247
1812
 
2248
1813
  /**
2249
1814
  * Get files that import a given file
2250
1815
  * @param {string} filePath - File to check
2251
1816
  * @returns {Array} Files that import this file
2252
1817
  */
2253
- exporters(filePath) {
2254
- const resolved = this.resolveFilePathForQuery(filePath);
2255
- if (typeof resolved !== 'string') return resolved;
2256
-
2257
- const targetPath = resolved;
2258
-
2259
- const importers = this.exportGraph.get(targetPath) || [];
2260
-
2261
- return importers.map(importerPath => {
2262
- const fileEntry = this.files.get(importerPath);
2263
-
2264
- // Find the import line
2265
- let importLine = null;
2266
- try {
2267
- const content = this._readFile(importerPath);
2268
- const lines = content.split('\n');
2269
- let targetBasename = path.basename(targetPath, path.extname(targetPath));
2270
-
2271
- // For __init__.py, search for the package name (parent dir)
2272
- // e.g., "from tools import X" → search for "tools" not "__init__"
2273
- if (targetBasename === '__init__') {
2274
- targetBasename = path.basename(path.dirname(targetPath));
2275
- }
2276
-
2277
- for (let i = 0; i < lines.length; i++) {
2278
- if (lines[i].includes(targetBasename) &&
2279
- (lines[i].includes('import') || lines[i].includes('require') || lines[i].includes('from'))) {
2280
- importLine = i + 1;
2281
- break;
2282
- }
2283
- }
2284
- } catch (e) {
2285
- // Skip
2286
- }
2287
-
2288
- return {
2289
- file: fileEntry ? fileEntry.relativePath : path.relative(this.root, importerPath),
2290
- importLine
2291
- };
2292
- });
2293
- }
1818
+ exporters(filePath) { return graphModule.exporters(this, filePath); }
2294
1819
 
2295
1820
  /**
2296
1821
  * Find type definitions
2297
1822
  * @param {string} name - Type name to find
2298
1823
  * @returns {Array} Matching type definitions
2299
1824
  */
2300
- typedef(name, options = {}) {
2301
- const typeKinds = ['type', 'interface', 'enum', 'struct', 'trait', 'class', 'record'];
2302
- const matches = this.find(name, options);
2303
-
2304
- return matches.filter(m => typeKinds.includes(m.type)).map(m => ({
2305
- ...m,
2306
- code: this.extractCode(m)
2307
- }));
2308
- }
1825
+ typedef(name, options) { return searchModule.typedef(this, name, options); }
2309
1826
 
2310
1827
  /**
2311
1828
  * Find tests for a function or file
2312
1829
  * @param {string} nameOrFile - Function name or file path
2313
1830
  * @returns {Array} Test files and matches
2314
1831
  */
2315
- tests(nameOrFile, options = {}) {
2316
- this._beginOp();
2317
- try {
2318
- const results = [];
1832
+ tests(nameOrFile, options) { return searchModule.tests(this, nameOrFile, options); }
2319
1833
 
2320
- // Check if it's a file path
2321
- const isFilePath = nameOrFile.includes('/') || nameOrFile.includes('\\') ||
2322
- nameOrFile.endsWith('.js') || nameOrFile.endsWith('.ts') ||
2323
- nameOrFile.endsWith('.py') || nameOrFile.endsWith('.go') ||
2324
- nameOrFile.endsWith('.java') || nameOrFile.endsWith('.rs');
2325
-
2326
- // Find all test files
2327
- const testFiles = [];
2328
- for (const [filePath, fileEntry] of this.files) {
2329
- if (isTestFile(fileEntry.relativePath, fileEntry.language)) {
2330
- testFiles.push({ path: filePath, entry: fileEntry });
2331
- } else if (fileEntry.language === 'rust') {
2332
- // Rust idiomatically puts tests in #[cfg(test)] modules inside source files.
2333
- // Check if file has any symbols with 'test' modifier (#[test] attribute).
2334
- const hasInlineTests = fileEntry.symbols?.some(s =>
2335
- s.modifiers?.includes('test')
2336
- );
2337
- if (hasInlineTests) {
2338
- testFiles.push({ path: filePath, entry: fileEntry });
2339
- }
2340
- }
2341
- }
1834
+ /**
1835
+ * Get all exported/public symbols
1836
+ * @param {string} [filePath] - Optional file to limit to
1837
+ * @returns {Array} Exported symbols
1838
+ */
1839
+ api(filePath, options = {}) { return graphModule.api(this, filePath, options); }
2342
1840
 
2343
- const searchTerm = isFilePath
2344
- ? path.basename(nameOrFile, path.extname(nameOrFile))
2345
- : nameOrFile;
1841
+ /**
1842
+ * Resolve a file path query to an indexed file (with ambiguity detection)
1843
+ * @param {string} filePath - File path to resolve
1844
+ * @returns {string|{error: string, filePath: string, candidates?: string[]}}
1845
+ */
1846
+ resolveFilePathForQuery(filePath) {
1847
+ // 1. Exact absolute/relative path match
1848
+ const normalizedPath = path.isAbsolute(filePath)
1849
+ ? filePath
1850
+ : path.join(this.root, filePath);
2346
1851
 
2347
- // Note: no 'g' flag - we only need to test for presence per line
2348
- // The 'i' flag is kept for case-insensitive matching
2349
- const regex = new RegExp('\\b' + escapeRegExp(searchTerm) + '\\b', 'i');
2350
- // Pre-compile patterns used inside per-line loop
2351
- const callPattern = new RegExp(escapeRegExp(searchTerm) + '\\s*\\(');
2352
- const strPattern = new RegExp("['\"`]" + escapeRegExp(searchTerm) + "['\"`]");
2353
-
2354
- for (const { path: testPath, entry } of testFiles) {
2355
- try {
2356
- const content = this._readFile(testPath);
2357
- const lines = content.split('\n');
2358
- const matches = [];
2359
-
2360
- lines.forEach((line, idx) => {
2361
- if (regex.test(line)) {
2362
- let matchType = 'reference';
2363
- if (/\b(describe|it|test|spec)\s*\(/.test(line)) {
2364
- matchType = 'test-case';
2365
- } else if (/\b(import|require|from)\b/.test(line)) {
2366
- matchType = 'import';
2367
- } else if (callPattern.test(line)) {
2368
- matchType = 'call';
2369
- }
2370
- // Detect if the match is inside a string literal (e.g., 'parseFile' or "parseFile")
2371
- if (matchType === 'reference' || matchType === 'call') {
2372
- if (strPattern.test(line)) {
2373
- matchType = 'string-ref';
2374
- }
2375
- }
2376
-
2377
- matches.push({
2378
- line: idx + 1,
2379
- content: line.trim(),
2380
- matchType
2381
- });
2382
- }
2383
- });
2384
-
2385
- const filtered = options.callsOnly
2386
- ? matches.filter(m => m.matchType === 'call' || m.matchType === 'test-case')
2387
- : matches;
2388
- if (filtered.length > 0) {
2389
- results.push({
2390
- file: entry.relativePath,
2391
- matches: filtered
2392
- });
2393
- }
2394
- } catch (e) {
2395
- // Skip unreadable files
2396
- }
2397
- }
2398
-
2399
- return results;
2400
- } finally { this._endOp(); }
2401
- }
2402
-
2403
- /**
2404
- * Get all exported/public symbols
2405
- * @param {string} [filePath] - Optional file to limit to
2406
- * @returns {Array} Exported symbols
2407
- */
2408
- api(filePath, options = {}) {
2409
- const results = [];
2410
-
2411
- let fileIterator;
2412
- if (filePath) {
2413
- // Try exact resolution first
2414
- const resolved = this.resolveFilePathForQuery(filePath);
2415
- if (typeof resolved === 'string') {
2416
- const fileEntry = this.files.get(resolved);
2417
- if (!fileEntry) return { error: 'file-not-found', filePath };
2418
- fileIterator = [[resolved, fileEntry]];
2419
- } else {
2420
- // Fall back to pattern filter (substring match on relative path)
2421
- const matches = [];
2422
- for (const [absPath, fe] of this.files) {
2423
- if (fe.relativePath.includes(filePath)) {
2424
- matches.push([absPath, fe]);
2425
- }
2426
- }
2427
- if (matches.length === 0) return { error: 'file-not-found', filePath };
2428
- fileIterator = matches;
2429
- }
2430
- } else {
2431
- fileIterator = this.files.entries();
2432
- }
2433
-
2434
- for (const [absPath, fileEntry] of fileIterator) {
2435
- if (!fileEntry) continue;
2436
-
2437
- // Skip test files by default (test classes aren't part of public API)
2438
- if (!options.includeTests && isTestFile(fileEntry.relativePath, fileEntry.language)) {
2439
- continue;
2440
- }
2441
-
2442
- const exportedNames = new Set(fileEntry.exports);
2443
-
2444
- for (const symbol of fileEntry.symbols) {
2445
- const isExported = exportedNames.has(symbol.name) ||
2446
- (symbol.modifiers && symbol.modifiers.includes('export')) ||
2447
- (symbol.modifiers && symbol.modifiers.includes('public')) ||
2448
- (fileEntry.language === 'go' && /^[A-Z]/.test(symbol.name));
2449
-
2450
- if (isExported) {
2451
- results.push({
2452
- name: symbol.name,
2453
- type: symbol.type,
2454
- file: fileEntry.relativePath,
2455
- startLine: symbol.startLine,
2456
- endLine: symbol.endLine,
2457
- params: symbol.params,
2458
- returnType: symbol.returnType,
2459
- signature: this.formatSignature(symbol)
2460
- });
2461
- }
2462
- }
2463
-
2464
- // Add variable exports (export const/let/var) not matched to symbols
2465
- if (fileEntry.exportDetails) {
2466
- const matchedNames = new Set(results.filter(r => r.file === fileEntry.relativePath).map(r => r.name));
2467
- for (const exp of fileEntry.exportDetails) {
2468
- if (exp.isVariable && !matchedNames.has(exp.name)) {
2469
- const sig = `${exp.declKind} ${exp.name}${exp.typeAnnotation ? ': ' + exp.typeAnnotation : ''}`;
2470
- results.push({
2471
- name: exp.name,
2472
- type: 'variable',
2473
- file: fileEntry.relativePath,
2474
- startLine: exp.line,
2475
- endLine: exp.line,
2476
- params: undefined,
2477
- returnType: exp.typeAnnotation || null,
2478
- signature: sig
2479
- });
2480
- }
2481
- }
2482
- }
2483
- }
2484
-
2485
- return results;
2486
- }
2487
-
2488
- /**
2489
- * Resolve a file path query to an indexed file (with ambiguity detection)
2490
- * @param {string} filePath - File path to resolve
2491
- * @returns {string|{error: string, filePath: string, candidates?: string[]}}
2492
- */
2493
- resolveFilePathForQuery(filePath) {
2494
- // 1. Exact absolute/relative path match
2495
- const normalizedPath = path.isAbsolute(filePath)
2496
- ? filePath
2497
- : path.join(this.root, filePath);
2498
-
2499
- if (this.files.has(normalizedPath)) {
2500
- return normalizedPath;
2501
- }
1852
+ if (this.files.has(normalizedPath)) {
1853
+ return normalizedPath;
1854
+ }
2502
1855
 
2503
1856
  // 2. Collect ALL suffix/partial candidates
2504
1857
  const candidates = [];
@@ -2536,193 +1889,7 @@ class ProjectIndex {
2536
1889
  * @param {string} filePath - File path
2537
1890
  * @returns {Array} Exported symbols from that file
2538
1891
  */
2539
- fileExports(filePath, _visited) {
2540
- const resolved = this.resolveFilePathForQuery(filePath);
2541
- if (typeof resolved !== 'string') return resolved;
2542
-
2543
- const absPath = resolved;
2544
- const visited = _visited || new Set();
2545
- if (visited.has(absPath)) return [];
2546
- visited.add(absPath);
2547
-
2548
- const fileEntry = this.files.get(absPath);
2549
- if (!fileEntry) {
2550
- return [];
2551
- }
2552
-
2553
- const results = [];
2554
- const exportedNames = new Set(fileEntry.exports);
2555
-
2556
- for (const symbol of fileEntry.symbols) {
2557
- const isExported = exportedNames.has(symbol.name) ||
2558
- (symbol.modifiers && symbol.modifiers.includes('export')) ||
2559
- (symbol.modifiers && symbol.modifiers.includes('public')) ||
2560
- (fileEntry.language === 'go' && /^[A-Z]/.test(symbol.name));
2561
-
2562
- if (isExported) {
2563
- results.push({
2564
- name: symbol.name,
2565
- type: symbol.type,
2566
- file: fileEntry.relativePath,
2567
- startLine: symbol.startLine,
2568
- endLine: symbol.endLine,
2569
- params: symbol.params,
2570
- returnType: symbol.returnType,
2571
- signature: this.formatSignature(symbol)
2572
- });
2573
- }
2574
- }
2575
-
2576
- // Add variable exports (export const/let/var) not matched to symbols
2577
- if (fileEntry.exportDetails) {
2578
- const matchedNames = new Set(results.map(r => r.name));
2579
- for (const exp of fileEntry.exportDetails) {
2580
- if (exp.isVariable && !matchedNames.has(exp.name)) {
2581
- const sig = `${exp.declKind} ${exp.name}${exp.typeAnnotation ? ': ' + exp.typeAnnotation : ''}`;
2582
- results.push({
2583
- name: exp.name,
2584
- type: 'variable',
2585
- file: fileEntry.relativePath,
2586
- startLine: exp.line,
2587
- endLine: exp.line,
2588
- params: undefined,
2589
- returnType: exp.typeAnnotation || null,
2590
- signature: sig
2591
- });
2592
- }
2593
- }
2594
-
2595
- // Add re-exports: export { X } from './module'
2596
- // Resolve to the source file and look up the symbol there
2597
- for (const exp of fileEntry.exportDetails) {
2598
- if ((exp.type === 're-export' || exp.type === 're-export-all') && exp.source && !matchedNames.has(exp.name)) {
2599
- const { resolveImport } = require('./imports');
2600
- const resolved = resolveImport(exp.source, absPath, {
2601
- language: fileEntry.language,
2602
- root: this.root,
2603
- extensions: this.extensions
2604
- });
2605
- if (resolved) {
2606
- const sourceEntry = this.files.get(resolved);
2607
- if (sourceEntry) {
2608
- // For star re-exports, include all exported symbols from source
2609
- if (exp.type === 're-export-all') {
2610
- const sourceExports = this.fileExports(resolved, visited);
2611
- for (const srcExp of sourceExports) {
2612
- if (!matchedNames.has(srcExp.name)) {
2613
- matchedNames.add(srcExp.name);
2614
- results.push({ ...srcExp, file: fileEntry.relativePath, reExportedFrom: srcExp.file });
2615
- }
2616
- }
2617
- } else {
2618
- // Named re-export: find the specific symbol
2619
- const srcSymbol = sourceEntry.symbols.find(s => s.name === exp.name);
2620
- if (srcSymbol) {
2621
- matchedNames.add(exp.name);
2622
- results.push({
2623
- name: exp.name,
2624
- type: srcSymbol.type,
2625
- file: fileEntry.relativePath,
2626
- startLine: exp.line,
2627
- endLine: exp.line,
2628
- params: srcSymbol.params,
2629
- returnType: srcSymbol.returnType,
2630
- signature: this.formatSignature(srcSymbol),
2631
- reExportedFrom: sourceEntry.relativePath
2632
- });
2633
- } else {
2634
- // Symbol not found in source — still list it as a re-export
2635
- matchedNames.add(exp.name);
2636
- results.push({
2637
- name: exp.name,
2638
- type: 're-export',
2639
- file: fileEntry.relativePath,
2640
- startLine: exp.line,
2641
- endLine: exp.line,
2642
- params: undefined,
2643
- returnType: null,
2644
- signature: `re-export ${exp.name} from '${exp.source}'`,
2645
- reExportedFrom: sourceEntry.relativePath
2646
- });
2647
- }
2648
- }
2649
- }
2650
- }
2651
- }
2652
- }
2653
- }
2654
-
2655
- // Python __all__ re-exports: names listed in __all__ that come from imports
2656
- // e.g. __init__.py: `from .utils import helper` + `__all__ = ["helper"]`
2657
- // `helper` is in fileEntry.exports but not in fileEntry.symbols
2658
- if (fileEntry.language === 'python' && fileEntry.exports.length > 0) {
2659
- const matchedNames = new Set(results.map(r => r.name));
2660
- const unmatched = fileEntry.exports.filter(name => !matchedNames.has(name));
2661
- if (unmatched.length > 0) {
2662
- // Re-extract raw imports to get name→module mapping (not stored in fileEntry)
2663
- const { extractImports, resolveImport } = require('./imports');
2664
- try {
2665
- const content = this._readFile(absPath);
2666
- const { imports: rawImports } = extractImports(content, 'python');
2667
- // Build name→module map from raw imports
2668
- const nameToModule = new Map();
2669
- for (const imp of rawImports) {
2670
- if (imp.names) {
2671
- for (const name of imp.names) {
2672
- if (name !== '*') nameToModule.set(name, imp.module);
2673
- }
2674
- }
2675
- }
2676
- for (const name of unmatched) {
2677
- const sourceModule = nameToModule.get(name);
2678
- if (!sourceModule) continue;
2679
- const resolvedSrc = resolveImport(sourceModule, absPath, {
2680
- language: 'python',
2681
- root: this.root,
2682
- extensions: this.extensions
2683
- });
2684
- if (!resolvedSrc) continue;
2685
- const sourceEntry = this.files.get(resolvedSrc);
2686
- const srcSymbol = sourceEntry && sourceEntry.symbols.find(s => s.name === name);
2687
- if (srcSymbol) {
2688
- matchedNames.add(name);
2689
- results.push({
2690
- name,
2691
- type: srcSymbol.type,
2692
- file: fileEntry.relativePath,
2693
- startLine: srcSymbol.startLine,
2694
- endLine: srcSymbol.endLine,
2695
- params: srcSymbol.params,
2696
- returnType: srcSymbol.returnType,
2697
- signature: this.formatSignature(srcSymbol),
2698
- reExportedFrom: sourceEntry.relativePath
2699
- });
2700
- } else {
2701
- // Source not indexed or symbol not found — still list it
2702
- matchedNames.add(name);
2703
- results.push({
2704
- name,
2705
- type: 're-export',
2706
- file: fileEntry.relativePath,
2707
- startLine: undefined,
2708
- endLine: undefined,
2709
- params: undefined,
2710
- returnType: null,
2711
- signature: `re-export ${name} from '${sourceModule}'`,
2712
- reExportedFrom: resolvedSrc
2713
- ? (sourceEntry ? sourceEntry.relativePath : resolvedSrc)
2714
- : sourceModule
2715
- });
2716
- }
2717
- }
2718
- } catch (_) {
2719
- // File read failure — skip Python re-export resolution
2720
- }
2721
- }
2722
- }
2723
-
2724
- return results;
2725
- }
1892
+ fileExports(filePath, _visited) { return graphModule.fileExports(this, filePath, _visited); }
2726
1893
 
2727
1894
  /** Check if a function is used as a callback anywhere in the codebase */
2728
1895
  findCallbackUsages(name) { return callersModule.findCallbackUsages(this, name); }
@@ -2740,2651 +1907,150 @@ class ProjectIndex {
2740
1907
  * @param {object} options - { direction: 'imports' | 'importers' | 'both', maxDepth }
2741
1908
  * @returns {object} - Graph structure with root, nodes, edges
2742
1909
  */
2743
- graph(filePath, options = {}) {
2744
- const direction = options.direction || 'both';
2745
- // Sanitize depth: use default for null/undefined, clamp negative to 0
2746
- const rawDepth = options.maxDepth ?? 5;
2747
- const maxDepth = Math.max(0, rawDepth);
1910
+ graph(filePath, options = {}) { return graphModule.graph(this, filePath, options); }
2748
1911
 
2749
- const resolved = this.resolveFilePathForQuery(filePath);
2750
- if (typeof resolved !== 'string') return resolved;
1912
+ /**
1913
+ * Detect circular dependencies in the import graph.
1914
+ * Uses DFS with 3-color marking to find all cycles.
1915
+ * @param {object} options - { file, exclude }
1916
+ * @returns {object} - { cycles, totalFiles, summary }
1917
+ */
1918
+ circularDeps(options = {}) { return graphModule.circularDeps(this, options); }
2751
1919
 
2752
- const targetPath = resolved;
1920
+ /**
1921
+ * Detect patterns that may cause incomplete results
1922
+ * Returns warnings about dynamic code patterns
1923
+ * Cached to avoid rescanning on every query
1924
+ */
1925
+ detectCompleteness() { return analysisModule.detectCompleteness(this); }
2753
1926
 
2754
- const buildSubgraph = (dir) => {
2755
- const visited = new Set();
2756
- const nodes = [];
2757
- const edges = [];
2758
1927
 
2759
- const traverse = (file, depth) => {
2760
- if (visited.has(file)) return;
2761
- visited.add(file);
1928
+ /** Find related functions — same file, similar names, shared dependencies */
1929
+ related(name, options) { return analysisModule.related(this, name, options); }
2762
1930
 
2763
- const fileEntry = this.files.get(file);
2764
- const relPath = fileEntry ? fileEntry.relativePath : path.relative(this.root, file);
2765
- nodes.push({ file, relativePath: relPath, depth });
1931
+ /**
1932
+ * Trace call flow - show call tree visualization
1933
+ * This is the "what calls what" command
1934
+ *
1935
+ * @param {string} name - Function name to trace from
1936
+ * @param {object} options - { depth, direction }
1937
+ * @returns {object} Call tree structure
1938
+ */
1939
+ trace(name, options) { return tracingModule.trace(this, name, options); }
2766
1940
 
2767
- // Stop traversal at max depth but still register the node above
2768
- if (depth >= maxDepth) return;
1941
+ /** Impact analysis what call sites need updating if a function changes */
1942
+ impact(name, options) { return analysisModule.impact(this, name, options); }
2769
1943
 
2770
- let neighbors = [];
2771
- if (dir === 'imports') {
2772
- neighbors = this.importGraph.get(file) || [];
2773
- } else {
2774
- neighbors = this.exportGraph.get(file) || [];
2775
- }
1944
+ /**
1945
+ * Transitive blast radius — walk UP the caller chain recursively.
1946
+ * Answers: "What breaks transitively if I change this function?"
1947
+ *
1948
+ * @param {string} name - Function name
1949
+ * @param {object} options - { depth, file, className, all, exclude, includeMethods, includeUncertain }
1950
+ * @returns {object|null} Blast radius tree with summary
1951
+ */
1952
+ blast(name, options) { return tracingModule.blast(this, name, options); }
2776
1953
 
2777
- // Deduplicate neighbors (same file may be imported multiple times, e.g. Java inner classes)
2778
- const uniqueNeighbors = [...new Set(neighbors)];
1954
+ /**
1955
+ * Reverse trace: walk UP the caller chain to entry points.
1956
+ * Like blast but focused on "how does execution reach this function?"
1957
+ * Marks leaf nodes (functions with no callers) as entry points.
1958
+ */
1959
+ reverseTrace(name, options) { return tracingModule.reverseTrace(this, name, options); }
2779
1960
 
2780
- for (const neighbor of uniqueNeighbors) {
2781
- edges.push({ from: file, to: neighbor });
2782
- traverse(neighbor, depth + 1);
2783
- }
2784
- };
1961
+ /**
1962
+ * Find tests affected by a change to the given function.
1963
+ * Composes blast() (transitive callers) with test file scanning.
1964
+ */
1965
+ affectedTests(name, options) { return tracingModule.affectedTests(this, name, options); }
2785
1966
 
2786
- traverse(targetPath, 0);
2787
- return { nodes, edges };
2788
- };
1967
+ /** Plan a refactoring operation */
1968
+ plan(name, options) { return verifyModule.plan(this, name, options); }
2789
1969
 
2790
- if (direction === 'both') {
2791
- // Build separate sub-graphs for imports and importers
2792
- const importsGraph = buildSubgraph('imports');
2793
- const importersGraph = buildSubgraph('importers');
2794
-
2795
- return {
2796
- root: targetPath,
2797
- direction: 'both',
2798
- imports: { nodes: importsGraph.nodes, edges: importsGraph.edges },
2799
- importers: { nodes: importersGraph.nodes, edges: importersGraph.edges },
2800
- // Keep combined for backward compat
2801
- nodes: [...importsGraph.nodes, ...importersGraph.nodes.filter(n =>
2802
- !importsGraph.nodes.some(in_ => in_.file === n.file))],
2803
- edges: [...importsGraph.edges, ...importersGraph.edges]
2804
- };
2805
- }
1970
+ /** Parse a stack trace and show code for each frame */
1971
+ parseStackTrace(stackText) {
1972
+ return stacktrace.parseStackTrace(this, stackText);
1973
+ }
2806
1974
 
2807
- const subgraph = buildSubgraph(direction);
2808
- return {
2809
- root: targetPath,
2810
- direction,
2811
- nodes: subgraph.nodes,
2812
- edges: subgraph.edges
2813
- };
1975
+ /** Calculate path similarity score between two file paths */
1976
+ calculatePathSimilarity(query, candidate) {
1977
+ return stacktrace.calculatePathSimilarity(query, candidate);
2814
1978
  }
2815
1979
 
2816
- /**
2817
- * Detect circular dependencies in the import graph.
2818
- * Uses DFS with 3-color marking to find all cycles.
2819
- * @param {object} options - { file, exclude }
2820
- * @returns {object} - { cycles, totalFiles, summary }
2821
- */
2822
- circularDeps(options = {}) {
2823
- this._beginOp();
2824
- try {
2825
- const exclude = options.exclude || [];
2826
- const fileFilter = options.file || null;
2827
-
2828
- const WHITE = 0, GRAY = 1, BLACK = 2;
2829
- const color = new Map();
2830
- const cycles = [];
2831
- const stack = [];
2832
-
2833
- const shouldSkip = (file) => {
2834
- if (!this.files.has(file)) return true;
2835
- if (exclude.length > 0) {
2836
- const entry = this.files.get(file);
2837
- if (entry && !this.matchesFilters(entry.relativePath, { exclude })) return true;
2838
- }
2839
- return false;
2840
- };
1980
+ /** Find the best matching file for a stack trace path */
1981
+ findBestMatchingFile(filePath, funcName, lineNum) {
1982
+ return stacktrace.findBestMatchingFile(this, filePath, funcName, lineNum);
1983
+ }
2841
1984
 
2842
- const dfs = (file) => {
2843
- color.set(file, GRAY);
2844
- stack.push(file);
1985
+ /** Create a stack frame with code context */
1986
+ createStackFrame(filePath, lineNum, funcName, col, rawLine) {
1987
+ return stacktrace.createStackFrame(this, filePath, lineNum, funcName, col, rawLine);
1988
+ }
2845
1989
 
2846
- const neighbors = [...new Set(this.importGraph.get(file) || [])];
1990
+ /** Verify that all call sites match a function's signature */
1991
+ verify(name, options) { return verifyModule.verify(this, name, options); }
2847
1992
 
2848
- for (const neighbor of neighbors) {
2849
- if (shouldSkip(neighbor)) continue;
2850
- const nc = color.get(neighbor) || WHITE;
2851
- if (nc === GRAY) {
2852
- const idx = stack.indexOf(neighbor);
2853
- cycles.push(stack.slice(idx));
2854
- } else if (nc === WHITE) {
2855
- dfs(neighbor);
2856
- }
2857
- }
1993
+ /** Analyze a call site to understand how it's being called (AST-based) */
1994
+ analyzeCallSite(call, funcName) { return verifyModule.analyzeCallSite(this, call, funcName); }
2858
1995
 
2859
- stack.pop();
2860
- color.set(file, BLACK);
2861
- };
1996
+ /** Find a call expression node at the target line matching funcName */
1997
+ _findCallNode(node, callTypes, targetRow, funcName) { return verifyModule.findCallNode(node, callTypes, targetRow, funcName); }
2862
1998
 
2863
- for (const file of this.files.keys()) {
2864
- if ((color.get(file) || WHITE) === WHITE && !shouldSkip(file)) {
2865
- dfs(file);
2866
- }
2867
- }
1999
+ /** Clear the AST tree cache (call after batch operations) */
2000
+ _clearTreeCache() { verifyModule.clearTreeCache(this); }
2868
2001
 
2869
- // Convert to relative paths and deduplicate
2870
- const seen = new Set();
2871
- const uniqueCycles = [];
2872
- for (const cycle of cycles) {
2873
- const relCycle = cycle.map(f => this.files.get(f)?.relativePath || path.relative(this.root, f));
2874
- // Normalize: rotate so lexicographically smallest file is first
2875
- const sorted = relCycle.slice().sort();
2876
- const minIdx = relCycle.indexOf(sorted[0]);
2877
- const rotated = [...relCycle.slice(minIdx), ...relCycle.slice(0, minIdx)];
2878
- const key = rotated.join('\0');
2879
- if (!seen.has(key)) {
2880
- seen.add(key);
2881
- uniqueCycles.push({ files: rotated, length: rotated.length });
2882
- }
2883
- }
2002
+ /** Identify common calling patterns */
2003
+ identifyCallPatterns(callSites, funcName) { return verifyModule.identifyCallPatterns(callSites, funcName); }
2884
2004
 
2885
- // Filter by file pattern
2886
- let result = uniqueCycles;
2887
- if (fileFilter) {
2888
- result = uniqueCycles.filter(c => c.files.some(f => f.includes(fileFilter)));
2889
- }
2005
+ /** About: comprehensive symbol metadata */
2006
+ about(name, options) { return analysisModule.about(this, name, options); }
2890
2007
 
2891
- result.sort((a, b) => a.length - b.length || a.files[0].localeCompare(b.files[0]));
2008
+ search(term, options) { return searchModule.search(this, term, options); }
2892
2009
 
2893
- // Count files that participate in import graph (have edges)
2894
- let filesWithImports = 0;
2895
- for (const [, targets] of this.importGraph) {
2896
- if (targets && targets.length > 0) filesWithImports++;
2897
- }
2010
+ structuralSearch(options) { return searchModule.structuralSearch(this, options); }
2898
2011
 
2899
- return {
2900
- cycles: result,
2901
- totalFiles: this.files.size,
2902
- filesWithImports,
2903
- fileFilter: fileFilter || undefined,
2904
- summary: {
2905
- totalCycles: result.length,
2906
- filesInCycles: new Set(result.flatMap(c => c.files)).size,
2907
- }
2908
- };
2909
- } finally {
2910
- this._endOp();
2911
- }
2912
- }
2012
+ // ========================================================================
2013
+ // PROJECT INFO
2014
+ // ========================================================================
2913
2015
 
2914
2016
  /**
2915
- * Detect patterns that may cause incomplete results
2916
- * Returns warnings about dynamic code patterns
2917
- * Cached to avoid rescanning on every query
2017
+ * Get project statistics
2918
2018
  */
2919
- detectCompleteness() {
2920
- // Return cached result if available
2921
- if (this._completenessCache) {
2922
- return this._completenessCache;
2923
- }
2924
-
2925
- const warnings = [];
2926
- let dynamicImports = 0;
2927
- let evalUsage = 0;
2928
- let reflectionUsage = 0;
2929
-
2930
- const predominantLang = this._getPredominantLanguage();
2931
-
2932
- for (const [filePath, fileEntry] of this.files) {
2933
- // Skip node_modules - we don't care about their patterns
2934
- if (filePath.includes('node_modules')) continue;
2935
-
2936
- try {
2937
- const content = this._readFile(filePath);
2938
-
2939
- if (fileEntry.language !== 'go') {
2940
- // Dynamic imports: import(), require(variable), __import__
2941
- dynamicImports += (content.match(/import\s*\([^'"]/g) || []).length;
2942
- dynamicImports += (content.match(/require\s*\([^'"]/g) || []).length;
2943
- dynamicImports += (content.match(/__import__\s*\(/g) || []).length;
2944
-
2945
- // eval, Function constructor
2946
- evalUsage += (content.match(/(^|[^a-zA-Z_])eval\s*\(/gm) || []).length;
2947
- evalUsage += (content.match(/new\s+Function\s*\(/g) || []).length;
2948
- }
2949
-
2950
- // Reflection: getattr, hasattr, Reflect
2951
- reflectionUsage += (content.match(/\bgetattr\s*\(/g) || []).length;
2952
- reflectionUsage += (content.match(/\bhasattr\s*\(/g) || []).length;
2953
- reflectionUsage += (content.match(/\bReflect\./g) || []).length;
2954
- } catch (e) {
2955
- // Skip unreadable files
2956
- }
2957
- }
2019
+ getStats(options) { return reportingModule.getStats(this, options); }
2958
2020
 
2959
- if (dynamicImports > 0) {
2960
- warnings.push({
2961
- type: 'dynamic_imports',
2962
- count: dynamicImports,
2963
- message: `${dynamicImports} dynamic import(s) detected - some dependencies may be missed`
2964
- });
2965
- }
2021
+ getToc(options) { return reportingModule.getToc(this, options); }
2966
2022
 
2967
- if (evalUsage > 0) {
2968
- warnings.push({
2969
- type: 'eval',
2970
- count: evalUsage,
2971
- message: `${evalUsage} eval/exec usage(s) detected - dynamically generated code not analyzed`
2972
- });
2973
- }
2023
+ // ========================================================================
2024
+ // CACHE METHODS
2025
+ // ========================================================================
2974
2026
 
2975
- if (reflectionUsage > 0) {
2976
- warnings.push({
2977
- type: 'reflection',
2978
- count: reflectionUsage,
2979
- message: `${reflectionUsage} reflection usage(s) detected - dynamic attribute access not tracked`
2980
- });
2981
- }
2027
+ /** Save index to cache file */
2028
+ saveCache(cachePath) { return indexCache.saveCache(this, cachePath); }
2982
2029
 
2983
- this._completenessCache = {
2984
- complete: warnings.length === 0,
2985
- warnings
2986
- };
2030
+ /** Load index from cache file */
2031
+ loadCache(cachePath) { return indexCache.loadCache(this, cachePath); }
2987
2032
 
2988
- return this._completenessCache;
2989
- }
2033
+ /** Load callsCache from separate file on demand (called by findCallers/findCallees) */
2034
+ loadCallsCache() { return indexCache.loadCallsCache(this); }
2990
2035
 
2036
+ /** Check if cache is stale (any files changed or new files added) */
2037
+ isCacheStale() { return indexCache.isCacheStale(this); }
2991
2038
 
2992
2039
  /**
2993
- * Find related functions - same file, similar names, shared dependencies
2994
- * This is the "what else should I look at" command
2995
- *
2996
- * @param {string} name - Function name
2997
- * @returns {object} Related functions grouped by relationship type
2040
+ * Find the best usage example of a function.
2041
+ * Scores call sites using AST analysis (await, destructuring, typed assignment, etc.)
2042
+ * @param {string} name - Symbol name
2043
+ * @returns {{ best: object, totalCalls: number } | null}
2998
2044
  */
2999
- related(name, options = {}) {
3000
- this._beginOp();
3001
- try {
3002
- const { def } = this.resolveSymbol(name, { file: options.file, className: options.className });
3003
- if (!def) {
3004
- return null;
3005
- }
3006
- const related = {
3007
- target: {
3008
- name: def.name,
3009
- file: def.relativePath,
3010
- line: def.startLine,
3011
- type: def.type
3012
- },
3013
- sameFile: [],
3014
- similarNames: [],
3015
- sharedCallers: [],
3016
- sharedCallees: []
3017
- };
3018
-
3019
- // 1. Same file functions (sorted by proximity to target)
3020
- const fileEntry = this.files.get(def.file);
3021
- if (fileEntry) {
3022
- for (const sym of fileEntry.symbols) {
3023
- if (sym.name !== name && !NON_CALLABLE_TYPES.has(sym.type)) {
3024
- related.sameFile.push({
3025
- name: sym.name,
3026
- line: sym.startLine,
3027
- params: sym.params
3028
- });
3029
- }
3030
- }
3031
- // Sort by distance from target function (nearest first)
3032
- related.sameFile.sort((a, b) =>
3033
- Math.abs(a.line - def.startLine) - Math.abs(b.line - def.startLine)
3034
- );
3035
- }
3036
-
3037
- // 2. Similar names (shared prefix/suffix, camelCase similarity)
3038
- const nameParts = name.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase().split('_');
3039
- for (const [symName, symbols] of this.symbols) {
3040
- if (symName === name) continue;
3041
- const symParts = symName.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase().split('_');
3042
-
3043
- // Check for shared parts (require ≥50% of the longer name to match)
3044
- const sharedParts = nameParts.filter(p => symParts.includes(p) && p.length > 3);
3045
- const maxParts = Math.max(nameParts.length, symParts.length);
3046
- if (sharedParts.length > 0 && sharedParts.length / maxParts >= 0.5) {
3047
- const sym = symbols[0];
3048
- related.similarNames.push({
3049
- name: symName,
3050
- file: sym.relativePath,
3051
- line: sym.startLine,
3052
- sharedParts,
3053
- type: sym.type
3054
- });
3055
- }
3056
- }
3057
- // Sort by number of shared parts
3058
- related.similarNames.sort((a, b) => b.sharedParts.length - a.sharedParts.length);
3059
- const similarLimit = options.top || (options.all ? Infinity : 10);
3060
- if (related.similarNames.length > similarLimit) related.similarNames = related.similarNames.slice(0, similarLimit);
3061
-
3062
- // 3. Shared callers - functions called by the same callers
3063
- const myCallers = new Set(this.findCallers(name).map(c => c.callerName).filter(Boolean));
3064
- if (myCallers.size > 0) {
3065
- const callerCounts = new Map();
3066
- for (const callerName of myCallers) {
3067
- const callerDef = this.symbols.get(callerName)?.[0];
3068
- if (callerDef) {
3069
- const callees = this.findCallees(callerDef);
3070
- for (const callee of callees) {
3071
- if (callee.name !== name) {
3072
- callerCounts.set(callee.name, (callerCounts.get(callee.name) || 0) + 1);
3073
- }
3074
- }
3075
- }
3076
- }
3077
- // Sort by shared caller count
3078
- const maxShared = options.top || (options.all ? Infinity : 5);
3079
- const sorted = Array.from(callerCounts.entries())
3080
- .sort((a, b) => b[1] - a[1])
3081
- .slice(0, maxShared);
3082
- for (const [symName, count] of sorted) {
3083
- const sym = this.symbols.get(symName)?.[0];
3084
- if (sym) {
3085
- related.sharedCallers.push({
3086
- name: symName,
3087
- file: sym.relativePath,
3088
- line: sym.startLine,
3089
- sharedCallerCount: count
3090
- });
3091
- }
3092
- }
3093
- }
3094
-
3095
- // 4. Shared callees - functions that call the same things
3096
- // Optimized: instead of computing callees for every symbol (O(N*M)),
3097
- // find who else calls each of our callees (O(K) where K = our callee count)
3098
- if (def.type === 'function' || def.params !== undefined) {
3099
- const myCallees = this.findCallees(def);
3100
- const myCalleeNames = new Set(myCallees.map(c => c.name));
3101
- if (myCalleeNames.size > 0) {
3102
- const calleeCounts = new Map();
3103
- for (const calleeName of myCalleeNames) {
3104
- // Find other functions that also call this callee
3105
- const callers = this.findCallers(calleeName);
3106
- for (const caller of callers) {
3107
- if (caller.callerName && caller.callerName !== name) {
3108
- calleeCounts.set(caller.callerName, (calleeCounts.get(caller.callerName) || 0) + 1);
3109
- }
3110
- }
3111
- }
3112
- // Sort by shared callee count
3113
- const sorted = Array.from(calleeCounts.entries())
3114
- .sort((a, b) => b[1] - a[1])
3115
- .slice(0, options.top || (options.all ? Infinity : 5));
3116
- for (const [symName, count] of sorted) {
3117
- const sym = this.symbols.get(symName)?.[0];
3118
- if (sym) {
3119
- related.sharedCallees.push({
3120
- name: symName,
3121
- file: sym.relativePath,
3122
- line: sym.startLine,
3123
- sharedCalleeCount: count
3124
- });
3125
- }
3126
- }
3127
- }
3128
- }
3129
-
3130
- return related;
3131
- } finally { this._endOp(); }
3132
- }
3133
-
3134
- /**
3135
- * Trace call flow - show call tree visualization
3136
- * This is the "what calls what" command
3137
- *
3138
- * @param {string} name - Function name to trace from
3139
- * @param {object} options - { depth, direction }
3140
- * @returns {object} Call tree structure
3141
- */
3142
- trace(name, options = {}) {
3143
- this._beginOp();
3144
- try {
3145
- // Sanitize depth: use default for null/undefined, clamp negative to 0
3146
- const rawDepth = options.depth ?? 3;
3147
- const maxDepth = Math.max(0, rawDepth);
3148
- const direction = options.direction || 'down'; // 'down' = callees, 'up' = callers, 'both'
3149
- const maxChildren = options.all ? Infinity : 10;
3150
- // trace defaults to includeMethods=true (execution flow should show method calls)
3151
- const includeMethods = options.includeMethods ?? true;
3152
-
3153
- const { def, definitions, warnings } = this.resolveSymbol(name, { file: options.file, className: options.className });
3154
- if (!def) {
3155
- return null;
3156
- }
3157
- const visited = new Set();
3158
- const defDir = path.dirname(def.file);
3159
-
3160
- const buildTree = (funcDef, currentDepth, dir) => {
3161
- const funcName = funcDef.name;
3162
- const key = `${funcDef.file}:${funcDef.startLine}`;
3163
- if (currentDepth > maxDepth) {
3164
- return null;
3165
- }
3166
- if (visited.has(key)) {
3167
- // Already explored — show as leaf node without recursing (prevents infinite loops)
3168
- return {
3169
- name: funcName,
3170
- file: funcDef.relativePath,
3171
- line: funcDef.startLine,
3172
- type: funcDef.type,
3173
- children: [],
3174
- alreadyShown: true
3175
- };
3176
- }
3177
- visited.add(key);
3178
-
3179
- const node = {
3180
- name: funcName,
3181
- file: funcDef.relativePath,
3182
- line: funcDef.startLine,
3183
- type: funcDef.type,
3184
- children: []
3185
- };
3186
-
3187
- if (dir === 'down' || dir === 'both') {
3188
- const callees = this.findCallees(funcDef, { includeMethods, includeUncertain: options.includeUncertain });
3189
- for (const callee of callees.slice(0, maxChildren)) {
3190
- // callee already has the best-matched definition from findCallees
3191
- const childTree = buildTree(callee, currentDepth + 1, 'down');
3192
- if (childTree) {
3193
- node.children.push({
3194
- ...childTree,
3195
- callCount: callee.callCount,
3196
- weight: callee.weight
3197
- });
3198
- }
3199
- }
3200
- if (callees.length > maxChildren) {
3201
- node.truncatedChildren = callees.length - maxChildren;
3202
- }
3203
- }
3204
-
3205
- return node;
3206
- };
3207
-
3208
- const tree = buildTree(def, 0, direction);
3209
-
3210
- // Also get callers if direction is 'up' or 'both'
3211
- let callers = [];
3212
- let truncatedCallers = 0;
3213
- if (direction === 'up' || direction === 'both') {
3214
- const allCallers = this.findCallers(name, { includeMethods, includeUncertain: options.includeUncertain, targetDefinitions: [def] });
3215
- callers = allCallers.slice(0, maxChildren).map(c => ({
3216
- name: c.callerName || '(anonymous)',
3217
- file: c.relativePath,
3218
- line: c.line,
3219
- expression: c.content.trim()
3220
- }));
3221
- if (allCallers.length > maxChildren) {
3222
- truncatedCallers = allCallers.length - maxChildren;
3223
- }
3224
- }
3225
-
3226
- // Add smart hint when resolved function has zero callees
3227
- if (tree && tree.children && tree.children.length === 0) {
3228
- if (maxDepth === 0) {
3229
- warnings.push({
3230
- message: `depth=0: showing root function only. Increase depth to see callees.`
3231
- });
3232
- } else if (definitions.length > 1 && !options.file) {
3233
- warnings.push({
3234
- message: `Resolved to ${def.relativePath}:${def.startLine} which has no callees. ${definitions.length - 1} other definition(s) exist — specify a file to pick a different one.`
3235
- });
3236
- }
3237
- }
3238
-
3239
- return {
3240
- root: name,
3241
- file: def.relativePath,
3242
- line: def.startLine,
3243
- direction,
3244
- maxDepth,
3245
- includeMethods,
3246
- tree,
3247
- callers: direction !== 'down' ? callers : undefined,
3248
- truncatedCallers: truncatedCallers > 0 ? truncatedCallers : undefined,
3249
- warnings: warnings.length > 0 ? warnings : undefined
3250
- };
3251
- } finally { this._endOp(); }
3252
- }
3253
-
3254
- /**
3255
- * Analyze impact of changing a function - what call sites would need updating
3256
- * This is the "what breaks if I change this" command
3257
- *
3258
- * @param {string} name - Function name
3259
- * @param {object} options - { groupByFile }
3260
- * @returns {object} Impact analysis
3261
- */
3262
- impact(name, options = {}) {
3263
- this._beginOp();
3264
- try {
3265
- const { def } = this.resolveSymbol(name, { file: options.file, className: options.className });
3266
- if (!def) {
3267
- return null;
3268
- }
3269
- const defIsMethod = def.isMethod || def.type === 'method' || def.className || def.receiver;
3270
-
3271
- // Use findCallers for className-scoped or method queries (sophisticated binding resolution)
3272
- // Fall back to usages-based approach for simple function queries (backward compatible)
3273
- let callSites;
3274
- if (options.className || defIsMethod) {
3275
- // findCallers has proper method call resolution (self/this, binding IDs, receiver checks)
3276
- let callerResults = this.findCallers(name, {
3277
- includeMethods: true,
3278
- includeUncertain: false,
3279
- targetDefinitions: [def],
3280
- });
3281
-
3282
- // When the target definition has a className (including Go/Rust methods which
3283
- // now get className from receiver), filter out method calls whose receiver
3284
- // clearly belongs to a different type. This helps with common method names
3285
- // like .close(), .get() etc. where many types have the same method.
3286
- if (def.className) {
3287
- const targetClassName = def.className;
3288
- // Pre-compute how many types share this method name
3289
- const _impMethodDefs = this.symbols.get(name);
3290
- const _impClassNames = new Set();
3291
- if (_impMethodDefs) {
3292
- for (const d of _impMethodDefs) {
3293
- if (d.className) _impClassNames.add(d.className);
3294
- else if (d.receiver) _impClassNames.add(d.receiver.replace(/^\*/, ''));
3295
- }
3296
- }
3297
- callerResults = callerResults.filter(c => {
3298
- // Keep non-method calls and self/this/cls calls (already resolved by findCallers)
3299
- if (!c.isMethod) return true;
3300
- const r = c.receiver;
3301
- if (r && ['self', 'cls', 'this', 'super'].includes(r)) return true;
3302
- // Use receiverType from findCallers when available (Go/Java/Rust type inference)
3303
- if (c.receiverType) {
3304
- return c.receiverType === targetClassName;
3305
- }
3306
- // No receiver (chained/complex expression): only include if method is
3307
- // unique or rare across types — otherwise too many false positives
3308
- if (!r) {
3309
- return _impClassNames.size <= 1;
3310
- }
3311
- // Check if receiver matches the target class name (case-insensitive camelCase convention)
3312
- if (r.toLowerCase().includes(targetClassName.toLowerCase())) return true;
3313
- // Check if receiver is an instance of the target class using local variable type inference
3314
- if (c.callerFile) {
3315
- const callerDef = c.callerStartLine ? { file: c.callerFile, startLine: c.callerStartLine, endLine: c.callerEndLine } : null;
3316
- if (callerDef) {
3317
- const callerCalls = this.getCachedCalls(c.callerFile);
3318
- if (callerCalls && Array.isArray(callerCalls)) {
3319
- const localTypes = new Map();
3320
- for (const call of callerCalls) {
3321
- if (call.line >= callerDef.startLine && call.line <= callerDef.endLine) {
3322
- if (!call.isMethod && !call.receiver) {
3323
- const syms = this.symbols.get(call.name);
3324
- if (syms && syms.some(s => s.type === 'class')) {
3325
- // Found a constructor call — check for assignment pattern
3326
- const fileEntry = this.files.get(c.callerFile);
3327
- if (fileEntry) {
3328
- const content = this._readFile(c.callerFile);
3329
- const lines = content.split('\n');
3330
- const line = lines[call.line - 1] || '';
3331
- // Match "var = ClassName(...)" or "var = new ClassName(...)" or "Type var = new ClassName<>(...)"
3332
- const m = line.match(/(\w+)\s*=\s*(?:await\s+)?(?:new\s+)?(\w+)\s*(?:<[^>]*>)?\s*\(/);
3333
- if (m && m[2] === call.name) {
3334
- localTypes.set(m[1], call.name);
3335
- }
3336
- }
3337
- }
3338
- }
3339
- }
3340
- }
3341
- const receiverType = localTypes.get(r);
3342
- if (receiverType) {
3343
- return receiverType === targetClassName;
3344
- }
3345
- }
3346
- }
3347
- }
3348
- // Check class field declarations for receiver type: private DataService service
3349
- if (c.callerFile) {
3350
- const callerEnclosing = this.findEnclosingFunction(c.callerFile, c.line, true);
3351
- if (callerEnclosing?.className) {
3352
- const classSyms = this.symbols.get(callerEnclosing.className);
3353
- if (classSyms) {
3354
- const classDef = classSyms.find(s => s.type === 'class' || s.type === 'struct' || s.type === 'interface');
3355
- if (classDef) {
3356
- const content = this._readFile(c.callerFile);
3357
- const lines = content.split('\n');
3358
- // Scan class body for field declarations matching the receiver
3359
- for (let li = classDef.startLine - 1; li < (classDef.endLine || classDef.startLine + 50) && li < lines.length; li++) {
3360
- const line = lines[li];
3361
- // Match Java/TS field: [modifiers] TypeName<...> receiverName [= ...]
3362
- const fieldMatch = line.match(new RegExp(`\\b(\\w+)(?:<[^>]*>)?\\s+${r.replace(/[.*+?^${}()|[\]\\]/g, '\\\\$&')}\\s*[;=]`));
3363
- if (fieldMatch) {
3364
- const fieldType = fieldMatch[1];
3365
- if (fieldType === targetClassName) return true;
3366
- break;
3367
- }
3368
- }
3369
- }
3370
- }
3371
- }
3372
- }
3373
- // Check parameter type annotations: def foo(tracker: SourceTracker) → tracker.record()
3374
- if (c.callerFile && c.callerStartLine) {
3375
- const callerSymbol = this.findEnclosingFunction(c.callerFile, c.line, true);
3376
- if (callerSymbol && callerSymbol.paramsStructured) {
3377
- for (const param of callerSymbol.paramsStructured) {
3378
- if (param.name === r && param.type) {
3379
- // Check if the type annotation contains the target class name
3380
- const typeMatches = param.type.match(/\b([A-Za-z_]\w*)\b/g);
3381
- if (typeMatches && typeMatches.some(t => t === targetClassName)) {
3382
- return true;
3383
- }
3384
- // Type annotation exists but doesn't match target class — filter out
3385
- return false;
3386
- }
3387
- }
3388
- }
3389
- }
3390
- // Unique method heuristic: if the called method exists on exactly one class/type
3391
- // and it matches the target, include the call (no other class could match)
3392
- if (_impClassNames.size === 1 && _impClassNames.has(targetClassName)) {
3393
- return true;
3394
- }
3395
- // Type-scoped query but receiver type unknown — filter it out.
3396
- // Unknown receivers are likely unrelated.
3397
- return false;
3398
- });
3399
- }
3400
-
3401
- callSites = [];
3402
- for (const c of callerResults) {
3403
- const analysis = this.analyzeCallSite(
3404
- { file: c.file, relativePath: c.relativePath, line: c.line, content: c.content },
3405
- name
3406
- );
3407
- callSites.push({
3408
- file: c.relativePath,
3409
- line: c.line,
3410
- expression: c.content.trim(),
3411
- callerName: c.callerName,
3412
- ...analysis
3413
- });
3414
- }
3415
- this._clearTreeCache();
3416
- } else {
3417
- // Use findCallers (benefits from callee index) instead of usages() for speed
3418
- const callerResults = this.findCallers(name, {
3419
- includeMethods: false,
3420
- includeUncertain: false,
3421
- targetDefinitions: [def],
3422
- });
3423
- const targetBindingId = def.bindingId;
3424
- // Convert findCallers results to the format expected by analyzeCallSite
3425
- const calls = callerResults.map(c => ({
3426
- file: c.file,
3427
- relativePath: c.relativePath,
3428
- line: c.line,
3429
- content: c.content,
3430
- usageType: 'call',
3431
- callerName: c.callerName,
3432
- }));
3433
- // Keep the same binding filter for backward compat (findCallers already handles this,
3434
- // but cross-check with usages-based binding filter for safety)
3435
- const filteredCalls = calls.filter(u => {
3436
- const fileEntry = this.files.get(u.file);
3437
- if (fileEntry && targetBindingId) {
3438
- let localBindings = (fileEntry.bindings || []).filter(b => b.name === name);
3439
- if (localBindings.length === 0 && fileEntry.language === 'go') {
3440
- const dir = path.dirname(u.file);
3441
- for (const [fp, fe] of this.files) {
3442
- if (fp !== u.file && path.dirname(fp) === dir) {
3443
- const sibling = (fe.bindings || []).filter(b => b.name === name);
3444
- localBindings = localBindings.concat(sibling);
3445
- }
3446
- }
3447
- }
3448
- if (localBindings.length > 0 && !localBindings.some(b => b.id === targetBindingId)) {
3449
- return false;
3450
- }
3451
- }
3452
- return true;
3453
- });
3454
- // (findCallers already handles binding resolution and scope-aware filtering)
3455
-
3456
- // Analyze each call site, filtering out method calls for non-method definitions
3457
- callSites = [];
3458
- const defFileEntry = this.files.get(def.file);
3459
- const defLang = defFileEntry?.language;
3460
- const targetDir = defLang === 'go' ? path.basename(path.dirname(def.file)) : null;
3461
- for (const call of filteredCalls) {
3462
- const analysis = this.analyzeCallSite(call, name);
3463
- // Skip method calls (obj.parse()) when target is a standalone function (parse())
3464
- // For Go, allow calls where receiver matches the package directory name
3465
- // (e.g., controller.FilterActive() where file is in pkg/controller/)
3466
- if (analysis.isMethodCall && !defIsMethod) {
3467
- if (targetDir) {
3468
- // Get receiver from parsed calls cache
3469
- const parsedCalls = this.getCachedCalls(call.file);
3470
- const matchedCall = parsedCalls?.find(c => c.name === name && c.line === call.line);
3471
- if (matchedCall?.receiver === targetDir) {
3472
- // Receiver matches package directory — keep it
3473
- } else {
3474
- continue;
3475
- }
3476
- } else {
3477
- continue;
3478
- }
3479
- }
3480
- callSites.push({
3481
- file: call.relativePath,
3482
- line: call.line,
3483
- expression: call.content.trim(),
3484
- callerName: call.callerName || this.findEnclosingFunction(call.file, call.line),
3485
- ...analysis
3486
- });
3487
- }
3488
- this._clearTreeCache();
3489
- }
3490
-
3491
- // Apply exclude filter
3492
- let filteredSites = callSites;
3493
- if (options.exclude && options.exclude.length > 0) {
3494
- filteredSites = callSites.filter(s => this.matchesFilters(s.file, { exclude: options.exclude }));
3495
- }
3496
-
3497
- // Apply top limit if specified (limits total call sites shown)
3498
- const totalBeforeLimit = filteredSites.length;
3499
- if (options.top && options.top > 0 && filteredSites.length > options.top) {
3500
- filteredSites = filteredSites.slice(0, options.top);
3501
- }
3502
-
3503
- // Group by file
3504
- const byFile = new Map();
3505
- for (const site of filteredSites) {
3506
- if (!byFile.has(site.file)) {
3507
- byFile.set(site.file, []);
3508
- }
3509
- byFile.get(site.file).push(site);
3510
- }
3511
-
3512
- // Identify patterns
3513
- const patterns = this.identifyCallPatterns(filteredSites, name);
3514
-
3515
- // Detect scope pollution: multiple class definitions for the same method name
3516
- let scopeWarning = null;
3517
- if (defIsMethod) {
3518
- const allDefs = this.symbols.get(name);
3519
- if (allDefs && allDefs.length > 1) {
3520
- const classNames = [...new Set(allDefs
3521
- .filter(d => d.className && d.className !== def.className)
3522
- .map(d => d.className))];
3523
- if (classNames.length > 0 && !options.className && !options.file) {
3524
- scopeWarning = {
3525
- targetClass: def.className || '(unknown)',
3526
- otherClasses: classNames,
3527
- hint: `Results may include calls to ${classNames.join(', ')}.${name}(). Use file= or className= to narrow scope.`
3528
- };
3529
- }
3530
- }
3531
- }
3532
-
3533
- return {
3534
- function: name,
3535
- file: def.relativePath,
3536
- startLine: def.startLine,
3537
- signature: this.formatSignature(def),
3538
- params: def.params,
3539
- paramsStructured: def.paramsStructured,
3540
- totalCallSites: totalBeforeLimit,
3541
- shownCallSites: filteredSites.length,
3542
- byFile: Array.from(byFile.entries()).map(([file, sites]) => ({
3543
- file,
3544
- count: sites.length,
3545
- sites
3546
- })),
3547
- patterns,
3548
- scopeWarning
3549
- };
3550
- } finally { this._endOp(); }
3551
- }
3552
-
3553
- /**
3554
- * Transitive blast radius — walk UP the caller chain recursively.
3555
- * Answers: "What breaks transitively if I change this function?"
3556
- *
3557
- * @param {string} name - Function name
3558
- * @param {object} options - { depth, file, className, all, exclude, includeMethods, includeUncertain }
3559
- * @returns {object|null} Blast radius tree with summary
3560
- */
3561
- blast(name, options = {}) {
3562
- this._beginOp();
3563
- try {
3564
- const maxDepth = Math.max(0, options.depth ?? 3);
3565
- const maxChildren = options.all ? Infinity : 10;
3566
- const includeMethods = options.includeMethods ?? true;
3567
- const includeUncertain = options.includeUncertain || false;
3568
- const exclude = options.exclude || [];
3569
-
3570
- const { def, definitions, warnings } = this.resolveSymbol(name, { file: options.file, className: options.className });
3571
- if (!def) return null;
3572
-
3573
- const visited = new Set();
3574
- const affectedFunctions = new Set();
3575
- const affectedFiles = new Set();
3576
- let maxDepthReached = 0;
3577
-
3578
- const buildCallerTree = (funcDef, currentDepth) => {
3579
- const key = `${funcDef.file}:${funcDef.startLine}`;
3580
- if (currentDepth > maxDepth) return null;
3581
- if (visited.has(key)) {
3582
- return {
3583
- name: funcDef.name,
3584
- file: funcDef.relativePath,
3585
- line: funcDef.startLine,
3586
- type: funcDef.type || 'function',
3587
- children: [],
3588
- alreadyShown: true
3589
- };
3590
- }
3591
- visited.add(key);
3592
-
3593
- if (currentDepth > maxDepthReached) maxDepthReached = currentDepth;
3594
- if (currentDepth > 0) {
3595
- affectedFunctions.add(key);
3596
- affectedFiles.add(funcDef.file);
3597
- }
3598
-
3599
- const node = {
3600
- name: funcDef.name,
3601
- file: funcDef.relativePath,
3602
- line: funcDef.startLine,
3603
- type: funcDef.type || 'function',
3604
- children: []
3605
- };
3606
-
3607
- if (currentDepth < maxDepth) {
3608
- const callers = this.findCallers(funcDef.name, {
3609
- includeMethods,
3610
- includeUncertain,
3611
- targetDefinitions: funcDef.bindingId ? [funcDef] : undefined,
3612
- });
3613
-
3614
- // Deduplicate callers by enclosing function (multiple call sites → one tree node)
3615
- const uniqueCallers = new Map();
3616
- for (const c of callers) {
3617
- if (!c.callerName) continue; // skip module-level code
3618
- // Apply exclude filter
3619
- if (exclude.length > 0 && !this.matchesFilters(c.relativePath, { exclude })) continue;
3620
- const callerKey = c.callerStartLine
3621
- ? `${c.callerFile}:${c.callerStartLine}`
3622
- : `${c.callerFile}:${c.callerName}`;
3623
- if (!uniqueCallers.has(callerKey)) {
3624
- uniqueCallers.set(callerKey, {
3625
- name: c.callerName,
3626
- file: c.callerFile,
3627
- relativePath: c.relativePath,
3628
- startLine: c.callerStartLine,
3629
- endLine: c.callerEndLine,
3630
- callSites: 1
3631
- });
3632
- } else {
3633
- uniqueCallers.get(callerKey).callSites++;
3634
- }
3635
- }
3636
-
3637
- // Resolve definitions and build child nodes
3638
- const callerEntries = [];
3639
- for (const [, caller] of uniqueCallers) {
3640
- // Look up actual definition from symbol table
3641
- const defs = this.symbols.get(caller.name);
3642
- let callerDef = defs?.find(d => d.file === caller.file && d.startLine === caller.startLine);
3643
-
3644
- if (!callerDef) {
3645
- // Pseudo-definition for callers not in symbol table
3646
- callerDef = {
3647
- name: caller.name,
3648
- file: caller.file,
3649
- relativePath: caller.relativePath,
3650
- startLine: caller.startLine,
3651
- endLine: caller.endLine,
3652
- type: 'function'
3653
- };
3654
- }
3655
-
3656
- callerEntries.push({ def: callerDef, callSites: caller.callSites });
3657
- }
3658
-
3659
- // Stable sort by file + line
3660
- callerEntries.sort((a, b) =>
3661
- a.def.file.localeCompare(b.def.file) || a.def.startLine - b.def.startLine
3662
- );
3663
-
3664
- for (const { def: cDef, callSites } of callerEntries.slice(0, maxChildren)) {
3665
- const childTree = buildCallerTree(cDef, currentDepth + 1);
3666
- if (childTree) {
3667
- childTree.callSites = callSites;
3668
- node.children.push(childTree);
3669
- }
3670
- }
3671
-
3672
- if (callerEntries.length > maxChildren) {
3673
- node.truncatedChildren = callerEntries.length - maxChildren;
3674
- // Count truncated callers in summary
3675
- for (const { def: cDef } of callerEntries.slice(maxChildren)) {
3676
- const key = `${cDef.file}:${cDef.startLine}`;
3677
- if (!visited.has(key)) {
3678
- affectedFunctions.add(key);
3679
- affectedFiles.add(cDef.file);
3680
- }
3681
- }
3682
- }
3683
- }
3684
-
3685
- return node;
3686
- };
3687
-
3688
- const tree = buildCallerTree(def, 0);
3689
-
3690
- // Smart hints
3691
- if (tree && tree.children.length === 0) {
3692
- if (maxDepth === 0) {
3693
- warnings.push({ message: 'depth=0: showing root function only. Increase depth to see callers.' });
3694
- } else if (definitions.length > 1 && !options.file) {
3695
- warnings.push({
3696
- message: `Resolved to ${def.relativePath}:${def.startLine} which has no callers. ${definitions.length - 1} other definition(s) exist — specify a file to pick a different one.`
3697
- });
3698
- }
3699
- }
3700
-
3701
- return {
3702
- root: name,
3703
- file: def.relativePath,
3704
- line: def.startLine,
3705
- maxDepth,
3706
- includeMethods,
3707
- tree,
3708
- summary: {
3709
- totalAffected: affectedFunctions.size,
3710
- totalFiles: affectedFiles.size,
3711
- maxDepthReached
3712
- },
3713
- warnings: warnings.length > 0 ? warnings : undefined
3714
- };
3715
- } finally { this._endOp(); }
3716
- }
3717
-
3718
- /**
3719
- * Reverse trace: walk UP the caller chain to entry points.
3720
- * Like blast but focused on "how does execution reach this function?"
3721
- * Marks leaf nodes (functions with no callers) as entry points.
3722
- */
3723
- reverseTrace(name, options = {}) {
3724
- this._beginOp();
3725
- try {
3726
- const maxDepth = Math.max(0, options.depth ?? 5);
3727
- const maxChildren = options.all ? Infinity : 10;
3728
- const includeMethods = options.includeMethods ?? true;
3729
- const includeUncertain = options.includeUncertain || false;
3730
- const exclude = options.exclude || [];
3731
-
3732
- const { def, definitions, warnings } = this.resolveSymbol(name, { file: options.file, className: options.className });
3733
- if (!def) return null;
3734
-
3735
- const visited = new Set();
3736
- const entryPoints = [];
3737
- let maxDepthReached = 0;
3738
-
3739
- const buildCallerTree = (funcDef, currentDepth) => {
3740
- const key = `${funcDef.file}:${funcDef.startLine}`;
3741
- if (currentDepth > maxDepth) return null;
3742
- if (visited.has(key)) {
3743
- return {
3744
- name: funcDef.name,
3745
- file: funcDef.relativePath,
3746
- line: funcDef.startLine,
3747
- type: funcDef.type || 'function',
3748
- children: [],
3749
- alreadyShown: true
3750
- };
3751
- }
3752
- visited.add(key);
3753
- if (currentDepth > maxDepthReached) maxDepthReached = currentDepth;
3754
-
3755
- const node = {
3756
- name: funcDef.name,
3757
- file: funcDef.relativePath,
3758
- line: funcDef.startLine,
3759
- type: funcDef.type || 'function',
3760
- children: []
3761
- };
3762
-
3763
- if (currentDepth < maxDepth) {
3764
- const callers = this.findCallers(funcDef.name, {
3765
- includeMethods,
3766
- includeUncertain,
3767
- targetDefinitions: funcDef.bindingId ? [funcDef] : undefined,
3768
- });
3769
-
3770
- // Deduplicate callers by enclosing function
3771
- const uniqueCallers = new Map();
3772
- for (const c of callers) {
3773
- if (!c.callerName) continue;
3774
- if (exclude.length > 0 && !this.matchesFilters(c.relativePath, { exclude })) continue;
3775
- const callerKey = c.callerStartLine
3776
- ? `${c.callerFile}:${c.callerStartLine}`
3777
- : `${c.callerFile}:${c.callerName}`;
3778
- if (!uniqueCallers.has(callerKey)) {
3779
- uniqueCallers.set(callerKey, {
3780
- name: c.callerName,
3781
- file: c.callerFile,
3782
- relativePath: c.relativePath,
3783
- startLine: c.callerStartLine,
3784
- endLine: c.callerEndLine,
3785
- callSites: 1
3786
- });
3787
- } else {
3788
- uniqueCallers.get(callerKey).callSites++;
3789
- }
3790
- }
3791
-
3792
- // Resolve definitions and build child nodes
3793
- const callerEntries = [];
3794
- for (const [, caller] of uniqueCallers) {
3795
- const defs = this.symbols.get(caller.name);
3796
- let callerDef = defs?.find(d => d.file === caller.file && d.startLine === caller.startLine);
3797
- if (!callerDef) {
3798
- callerDef = {
3799
- name: caller.name,
3800
- file: caller.file,
3801
- relativePath: caller.relativePath,
3802
- startLine: caller.startLine,
3803
- endLine: caller.endLine,
3804
- type: 'function'
3805
- };
3806
- }
3807
- callerEntries.push({ def: callerDef, callSites: caller.callSites });
3808
- }
3809
-
3810
- callerEntries.sort((a, b) =>
3811
- a.def.file.localeCompare(b.def.file) || a.def.startLine - b.def.startLine
3812
- );
3813
-
3814
- for (const { def: cDef, callSites } of callerEntries.slice(0, maxChildren)) {
3815
- const childTree = buildCallerTree(cDef, currentDepth + 1);
3816
- if (childTree) {
3817
- childTree.callSites = callSites;
3818
- node.children.push(childTree);
3819
- }
3820
- }
3821
-
3822
- if (callerEntries.length > maxChildren) {
3823
- node.truncatedChildren = callerEntries.length - maxChildren;
3824
- // Count entry points in truncated branches so summary is accurate
3825
- for (const { def: cDef } of callerEntries.slice(maxChildren)) {
3826
- const key = `${cDef.file}:${cDef.startLine}`;
3827
- if (!visited.has(key)) {
3828
- const cCallers = this.findCallers(cDef.name, {
3829
- includeMethods, includeUncertain,
3830
- targetDefinitions: cDef.bindingId ? [cDef] : undefined,
3831
- });
3832
- if (cCallers.length === 0) {
3833
- entryPoints.push({ name: cDef.name, file: cDef.relativePath || path.relative(this.root, cDef.file), line: cDef.startLine });
3834
- }
3835
- }
3836
- }
3837
- }
3838
-
3839
- // Mark as entry point if no callers found (and not at depth limit)
3840
- if (uniqueCallers.size === 0 && currentDepth > 0) {
3841
- node.entryPoint = true;
3842
- entryPoints.push({ name: funcDef.name, file: funcDef.relativePath, line: funcDef.startLine });
3843
- }
3844
- } else if (currentDepth > 0) {
3845
- // At depth limit: check if this node is an entry point
3846
- const callers = this.findCallers(funcDef.name, {
3847
- includeMethods,
3848
- includeUncertain,
3849
- targetDefinitions: funcDef.bindingId ? [funcDef] : undefined,
3850
- });
3851
- const hasCallers = callers.some(c => c.callerName &&
3852
- (exclude.length === 0 || this.matchesFilters(c.relativePath, { exclude })));
3853
- if (!hasCallers) {
3854
- node.entryPoint = true;
3855
- entryPoints.push({ name: funcDef.name, file: funcDef.relativePath, line: funcDef.startLine });
3856
- }
3857
- }
3858
-
3859
- return node;
3860
- };
3861
-
3862
- const tree = buildCallerTree(def, 0);
3863
-
3864
- // Also mark root as entry point if it has no callers
3865
- if (tree && tree.children.length === 0 && maxDepth > 0) {
3866
- tree.entryPoint = true;
3867
- entryPoints.push({ name: def.name, file: def.relativePath, line: def.startLine });
3868
- }
3869
-
3870
- // Smart hints
3871
- if (tree && tree.children.length === 0) {
3872
- if (maxDepth === 0) {
3873
- warnings.push({ message: 'depth=0: showing root function only. Increase depth to see callers.' });
3874
- } else if (definitions.length > 1 && !options.file) {
3875
- warnings.push({
3876
- message: `Resolved to ${def.relativePath}:${def.startLine} which has no callers. ${definitions.length - 1} other definition(s) exist — specify a file to pick a different one.`
3877
- });
3878
- }
3879
- }
3880
-
3881
- return {
3882
- root: name,
3883
- file: def.relativePath,
3884
- line: def.startLine,
3885
- maxDepth,
3886
- includeMethods,
3887
- tree,
3888
- entryPoints,
3889
- summary: {
3890
- totalEntryPoints: entryPoints.length,
3891
- totalFunctions: visited.size - 1, // exclude root
3892
- maxDepthReached
3893
- },
3894
- warnings: warnings.length > 0 ? warnings : undefined
3895
- };
3896
- } finally { this._endOp(); }
3897
- }
3898
-
3899
- /**
3900
- * Find tests affected by a change to the given function.
3901
- * Composes blast() (transitive callers) with test file scanning.
3902
- */
3903
- affectedTests(name, options = {}) {
3904
- this._beginOp();
3905
- try {
3906
- // Step 1: Get all transitively affected functions via blast
3907
- const blastResult = this.blast(name, {
3908
- depth: options.depth ?? 3,
3909
- file: options.file,
3910
- className: options.className,
3911
- all: true,
3912
- exclude: options.exclude,
3913
- includeMethods: options.includeMethods,
3914
- includeUncertain: options.includeUncertain,
3915
- });
3916
- if (!blastResult) return null;
3917
-
3918
- // Step 2: Collect all affected function names from the tree
3919
- const affectedNames = new Set();
3920
- affectedNames.add(name);
3921
- const collectNames = (node) => {
3922
- if (!node) return;
3923
- affectedNames.add(node.name);
3924
- for (const child of node.children || []) collectNames(child);
3925
- };
3926
- collectNames(blastResult.tree);
3927
-
3928
- // Step 3: Build regex patterns for all names
3929
- const namePatterns = new Map();
3930
- for (const n of affectedNames) {
3931
- const escaped = escapeRegExp(n);
3932
- namePatterns.set(n, {
3933
- regex: new RegExp('\\b' + escaped + '\\b'),
3934
- callPattern: new RegExp(escaped + '\\s*\\('),
3935
- });
3936
- }
3937
-
3938
- // Step 4: Scan test files once for all affected names
3939
- const exclude = options.exclude;
3940
- const excludeArr = exclude ? (Array.isArray(exclude) ? exclude : [exclude]) : [];
3941
- const results = [];
3942
- for (const [filePath, fileEntry] of this.files) {
3943
- let isTest = isTestFile(fileEntry.relativePath, fileEntry.language);
3944
- // Rust inline #[cfg(test)] modules: source files with #[test]-marked symbols
3945
- if (!isTest && fileEntry.language === 'rust') {
3946
- isTest = fileEntry.symbols?.some(s => s.modifiers?.includes('test'));
3947
- }
3948
- if (!isTest) continue;
3949
- if (excludeArr.length > 0 && !this.matchesFilters(fileEntry.relativePath, { exclude: excludeArr })) continue;
3950
- try {
3951
- const content = this._readFile(filePath);
3952
- const lines = content.split('\n');
3953
- const fileMatches = new Map();
3954
-
3955
- lines.forEach((line, idx) => {
3956
- for (const [funcName, patterns] of namePatterns) {
3957
- if (patterns.regex.test(line)) {
3958
- let matchType = 'reference';
3959
- if (/\b(describe|it|test|spec)\s*\(/.test(line)) {
3960
- matchType = 'test-case';
3961
- } else if (/\b(import|require|from)\b/.test(line)) {
3962
- matchType = 'import';
3963
- } else if (patterns.callPattern.test(line)) {
3964
- matchType = 'call';
3965
- }
3966
- if (!fileMatches.has(funcName)) fileMatches.set(funcName, []);
3967
- fileMatches.get(funcName).push({
3968
- line: idx + 1, content: line.trim(),
3969
- matchType, functionName: funcName
3970
- });
3971
- }
3972
- }
3973
- });
3974
-
3975
- if (fileMatches.size > 0) {
3976
- const coveredFunctions = [...fileMatches.keys()];
3977
- const allMatches = [];
3978
- for (const matches of fileMatches.values()) allMatches.push(...matches);
3979
- allMatches.sort((a, b) => a.line - b.line);
3980
- results.push({
3981
- file: fileEntry.relativePath,
3982
- coveredFunctions,
3983
- matchCount: allMatches.length,
3984
- matches: allMatches
3985
- });
3986
- }
3987
- } catch (e) { /* skip unreadable */ }
3988
- }
3989
-
3990
- // Sort by coverage breadth then alphabetically
3991
- results.sort((a, b) => b.coveredFunctions.length - a.coveredFunctions.length || a.file.localeCompare(b.file));
3992
-
3993
- // Compute coverage stats
3994
- const coveredSet = new Set();
3995
- for (const r of results) for (const f of r.coveredFunctions) coveredSet.add(f);
3996
- const uncovered = [...affectedNames].filter(n => !coveredSet.has(n));
3997
-
3998
- return {
3999
- root: blastResult.root, file: blastResult.file, line: blastResult.line,
4000
- depth: blastResult.maxDepth,
4001
- affectedFunctions: [...affectedNames],
4002
- testFiles: results,
4003
- summary: {
4004
- totalAffected: affectedNames.size,
4005
- totalTestFiles: results.length,
4006
- coveredFunctions: coveredSet.size,
4007
- uncoveredCount: uncovered.length,
4008
- },
4009
- uncovered,
4010
- warnings: blastResult.warnings,
4011
- };
4012
- } finally { this._endOp(); }
4013
- }
4014
-
4015
- /** Plan a refactoring operation */
4016
- plan(name, options) { return verifyModule.plan(this, name, options); }
4017
-
4018
- /** Parse a stack trace and show code for each frame */
4019
- parseStackTrace(stackText) {
4020
- return stacktrace.parseStackTrace(this, stackText);
4021
- }
4022
-
4023
- /** Calculate path similarity score between two file paths */
4024
- calculatePathSimilarity(query, candidate) {
4025
- return stacktrace.calculatePathSimilarity(query, candidate);
4026
- }
4027
-
4028
- /** Find the best matching file for a stack trace path */
4029
- findBestMatchingFile(filePath, funcName, lineNum) {
4030
- return stacktrace.findBestMatchingFile(this, filePath, funcName, lineNum);
4031
- }
4032
-
4033
- /** Create a stack frame with code context */
4034
- createStackFrame(filePath, lineNum, funcName, col, rawLine) {
4035
- return stacktrace.createStackFrame(this, filePath, lineNum, funcName, col, rawLine);
4036
- }
4037
-
4038
- /** Verify that all call sites match a function's signature */
4039
- verify(name, options) { return verifyModule.verify(this, name, options); }
4040
-
4041
- /** Analyze a call site to understand how it's being called (AST-based) */
4042
- analyzeCallSite(call, funcName) { return verifyModule.analyzeCallSite(this, call, funcName); }
4043
-
4044
- /** Find a call expression node at the target line matching funcName */
4045
- _findCallNode(node, callTypes, targetRow, funcName) { return verifyModule.findCallNode(node, callTypes, targetRow, funcName); }
4046
-
4047
- /** Clear the AST tree cache (call after batch operations) */
4048
- _clearTreeCache() { verifyModule.clearTreeCache(this); }
4049
-
4050
- /** Identify common calling patterns */
4051
- identifyCallPatterns(callSites, funcName) { return verifyModule.identifyCallPatterns(callSites, funcName); }
4052
-
4053
- /**
4054
- * Get complete information about a symbol - definition, usages, callers, callees, tests, code
4055
- * This is the "tell me everything" command for AI agents
4056
- *
4057
- * @param {string} name - Symbol name
4058
- * @param {object} options - { maxCallers, maxCallees, withCode, withTypes }
4059
- * @returns {object} Complete symbol info
4060
- */
4061
- about(name, options = {}) {
4062
- this._beginOp();
4063
- try {
4064
- const maxCallers = options.all ? Infinity : (options.maxCallers || 10);
4065
- const maxCallees = options.all ? Infinity : (options.maxCallees || 10);
4066
-
4067
- // Find symbol definition(s) — skip counts since about() computes its own via usages()
4068
- const definitions = this.find(name, { exact: true, file: options.file, className: options.className, skipCounts: true });
4069
- if (definitions.length === 0) {
4070
- // Try fuzzy match (needs counts for suggestion ranking)
4071
- const fuzzy = this.find(name, { file: options.file, className: options.className });
4072
- if (fuzzy.length === 0) {
4073
- return null;
4074
- }
4075
- // Return suggestion
4076
- return {
4077
- found: false,
4078
- suggestions: (options.all ? fuzzy : fuzzy.slice(0, 5)).map(s => ({
4079
- name: s.name,
4080
- file: s.relativePath,
4081
- line: s.startLine,
4082
- type: s.type,
4083
- usageCount: s.usageCount
4084
- }))
4085
- };
4086
- }
4087
-
4088
- // Use resolveSymbol for consistent primary selection (prefers non-test files)
4089
- const { def: resolved } = this.resolveSymbol(name, { file: options.file, className: options.className });
4090
- const primary = resolved || definitions[0];
4091
- const others = definitions.filter(d =>
4092
- d.relativePath !== primary.relativePath || d.startLine !== primary.startLine
4093
- );
4094
-
4095
- // Use the actual symbol name (may differ from query if fuzzy matched)
4096
- const symbolName = primary.name;
4097
-
4098
- // Default includeMethods: true when target is a class method (method calls are the primary way
4099
- // class methods are invoked), false for standalone functions (reduces noise from unrelated obj.fn() calls)
4100
- const isMethod = !!(primary.isMethod || primary.type === 'method' || primary.className);
4101
- const includeMethods = options.includeMethods ?? isMethod;
4102
-
4103
- // Get usage counts by type (fast path uses callee index, no file reads)
4104
- // Exclude test files by default (matching usages command behavior)
4105
- const countExclude = !options.includeTests ? addTestExclusions(options.exclude) : options.exclude;
4106
- const usagesByType = this.countSymbolUsages(primary, { exclude: countExclude });
4107
-
4108
- // Get callers and callees (only for functions)
4109
- let callers = [];
4110
- let callees = [];
4111
- let allCallers = null;
4112
- let allCallees = null;
4113
- let aboutConfFiltered = 0;
4114
- if (primary.type === 'function' || primary.params !== undefined) {
4115
- // Use maxResults to limit file iteration (with buffer for exclude filtering)
4116
- const callerCap = maxCallers === Infinity ? undefined : maxCallers * 3;
4117
- allCallers = this.findCallers(symbolName, { includeMethods, includeUncertain: options.includeUncertain, targetDefinitions: [primary], maxResults: callerCap });
4118
- // Apply exclude filter before slicing
4119
- if (options.exclude && options.exclude.length > 0) {
4120
- allCallers = allCallers.filter(c => this.matchesFilters(c.relativePath, { exclude: options.exclude }));
4121
- }
4122
- // Apply confidence filtering before slicing
4123
- if (options.minConfidence > 0) {
4124
- const { filterByConfidence } = require('./confidence');
4125
- const callerResult = filterByConfidence(allCallers, options.minConfidence);
4126
- allCallers = callerResult.kept;
4127
- aboutConfFiltered += callerResult.filtered;
4128
- }
4129
- callers = allCallers.slice(0, maxCallers).map(c => ({
4130
- file: c.relativePath,
4131
- line: c.line,
4132
- expression: c.content.trim(),
4133
- callerName: c.callerName,
4134
- confidence: c.confidence,
4135
- resolution: c.resolution,
4136
- }));
4137
-
4138
- allCallees = this.findCallees(primary, { includeMethods, includeUncertain: options.includeUncertain });
4139
- // Apply exclude filter before slicing
4140
- if (options.exclude && options.exclude.length > 0) {
4141
- allCallees = allCallees.filter(c => this.matchesFilters(c.relativePath, { exclude: options.exclude }));
4142
- }
4143
- // Apply confidence filtering before slicing
4144
- if (options.minConfidence > 0) {
4145
- const { filterByConfidence } = require('./confidence');
4146
- const calleeResult = filterByConfidence(allCallees, options.minConfidence);
4147
- allCallees = calleeResult.kept;
4148
- aboutConfFiltered += calleeResult.filtered;
4149
- }
4150
- callees = allCallees.slice(0, maxCallees).map(c => ({
4151
- name: c.name,
4152
- file: c.relativePath,
4153
- line: c.startLine,
4154
- startLine: c.startLine,
4155
- endLine: c.endLine,
4156
- weight: c.weight,
4157
- callCount: c.callCount,
4158
- confidence: c.confidence,
4159
- resolution: c.resolution,
4160
- }));
4161
- }
4162
-
4163
- // Find tests
4164
- const tests = this.tests(symbolName);
4165
- const testSummary = {
4166
- fileCount: tests.length,
4167
- totalMatches: tests.reduce((sum, t) => sum + t.matches.length, 0),
4168
- files: (options.all ? tests : tests.slice(0, 3)).map(t => t.file)
4169
- };
4170
-
4171
- // Extract code if requested (default: true)
4172
- let code = null;
4173
- if (options.withCode !== false) {
4174
- code = this.extractCode(primary);
4175
- }
4176
-
4177
- // Get type definitions if requested
4178
- let types = [];
4179
- if (options.withTypes) {
4180
- const TYPE_KINDS = ['type', 'interface', 'class', 'struct'];
4181
- const seen = new Set();
4182
-
4183
- const addType = (typeName) => {
4184
- if (seen.has(typeName)) return;
4185
- seen.add(typeName);
4186
- const typeSymbols = this.symbols.get(typeName);
4187
- if (typeSymbols) {
4188
- for (const sym of typeSymbols) {
4189
- if (TYPE_KINDS.includes(sym.type)) {
4190
- types.push({
4191
- name: sym.name,
4192
- type: sym.type,
4193
- file: sym.relativePath,
4194
- line: sym.startLine
4195
- });
4196
- }
4197
- }
4198
- }
4199
- };
4200
-
4201
- // From signature annotations
4202
- const typeNames = this.extractTypeNames(primary);
4203
- for (const typeName of typeNames) addType(typeName);
4204
-
4205
- // From callee signatures — types used by functions this function calls
4206
- if (allCallees) {
4207
- for (const callee of allCallees) {
4208
- const calleeTypeNames = this.extractTypeNames(callee);
4209
- for (const tn of calleeTypeNames) addType(tn);
4210
- }
4211
- }
4212
- }
4213
-
4214
- const result = {
4215
- found: true,
4216
- symbol: {
4217
- name: primary.name,
4218
- type: primary.type,
4219
- file: primary.relativePath,
4220
- startLine: primary.startLine,
4221
- endLine: primary.endLine,
4222
- params: primary.params,
4223
- returnType: primary.returnType,
4224
- modifiers: primary.modifiers,
4225
- docstring: primary.docstring,
4226
- signature: this.formatSignature(primary)
4227
- },
4228
- usages: usagesByType,
4229
- totalUsages: usagesByType.calls + usagesByType.imports + usagesByType.references,
4230
- callers: {
4231
- total: allCallers?.length ?? 0,
4232
- top: callers
4233
- },
4234
- callees: {
4235
- total: allCallees?.length ?? 0,
4236
- top: callees
4237
- },
4238
- tests: testSummary,
4239
- otherDefinitions: (options.all ? others : others.slice(0, 3)).map(d => ({
4240
- file: d.relativePath,
4241
- line: d.startLine,
4242
- usageCount: d.usageCount ?? this.countSymbolUsages(d).total
4243
- })),
4244
- types,
4245
- code,
4246
- includeMethods,
4247
- ...(aboutConfFiltered > 0 && { confidenceFiltered: aboutConfFiltered }),
4248
- completeness: this.detectCompleteness()
4249
- };
4250
-
4251
- return result;
4252
- } finally { this._endOp(); }
4253
- }
4254
-
4255
- /**
4256
- * Search for text across the project
4257
- * @param {string} term - Search term
4258
- * @param {object} options - { codeOnly, context, caseSensitive, exclude, in }
4259
- */
4260
- search(term, options = {}) {
4261
- this._beginOp();
4262
- try {
4263
- const results = [];
4264
- let filesScanned = 0;
4265
- let filesSkipped = 0;
4266
- let filesFilteredByFlag = 0;
4267
- const regexFlags = options.caseSensitive ? 'g' : 'gi';
4268
- const useRegex = options.regex !== false; // Default: regex ON
4269
- let regex;
4270
- let regexFallback = false;
4271
- if (useRegex) {
4272
- try {
4273
- regex = new RegExp(term, regexFlags);
4274
- } catch (e) {
4275
- // Invalid regex — fall back to plain text
4276
- regex = new RegExp(escapeRegExp(term), regexFlags);
4277
- regexFallback = e.message;
4278
- }
4279
- } else {
4280
- regex = new RegExp(escapeRegExp(term), regexFlags);
4281
- }
4282
-
4283
- for (const [filePath, fileEntry] of this.files) {
4284
- // Apply --file filter
4285
- if (options.file) {
4286
- const fp = fileEntry.relativePath;
4287
- if (!fp.includes(options.file) && !fp.endsWith(options.file)) {
4288
- filesFilteredByFlag++;
4289
- continue;
4290
- }
4291
- }
4292
- // Apply exclude/in filters
4293
- if ((options.exclude && options.exclude.length > 0) || options.in) {
4294
- if (!this.matchesFilters(fileEntry.relativePath, { exclude: options.exclude, in: options.in })) {
4295
- filesSkipped++;
4296
- continue;
4297
- }
4298
- }
4299
- filesScanned++;
4300
- try {
4301
- const content = this._readFile(filePath);
4302
- const lines = content.split('\n');
4303
- const matches = [];
4304
-
4305
- // Use AST-based filtering for codeOnly mode when language is supported
4306
- if (options.codeOnly) {
4307
- const language = detectLanguage(filePath);
4308
- if (language) {
4309
- try {
4310
- const parser = getParser(language);
4311
- const { findMatchesWithASTFilter } = require('../languages/utils');
4312
- const astMatches = findMatchesWithASTFilter(content, term, parser, { codeOnly: true, regex: useRegex });
4313
-
4314
- for (const m of astMatches) {
4315
- const match = {
4316
- line: m.line,
4317
- content: m.content
4318
- };
4319
-
4320
- // Add context lines if requested
4321
- if (options.context && options.context > 0) {
4322
- const idx = m.line - 1;
4323
- const before = [];
4324
- const after = [];
4325
- for (let i = 1; i <= options.context; i++) {
4326
- if (idx - i >= 0) before.unshift(lines[idx - i]);
4327
- if (idx + i < lines.length) after.push(lines[idx + i]);
4328
- }
4329
- match.before = before;
4330
- match.after = after;
4331
- }
4332
-
4333
- matches.push(match);
4334
- }
4335
-
4336
- if (matches.length > 0) {
4337
- results.push({
4338
- file: fileEntry.relativePath,
4339
- matches
4340
- });
4341
- }
4342
- continue; // Skip to next file
4343
- } catch (e) {
4344
- // Fall through to regex-based search
4345
- }
4346
- }
4347
- }
4348
-
4349
- // Fallback to regex-based search (non-codeOnly or unsupported language)
4350
- lines.forEach((line, idx) => {
4351
- regex.lastIndex = 0; // Reset regex state
4352
- if (regex.test(line)) {
4353
- const lineNum = idx + 1;
4354
- // Skip if codeOnly and line is comment/string
4355
- if (options.codeOnly && this.isCommentOrStringAtPosition(content, lineNum, 0, filePath)) {
4356
- return;
4357
- }
4358
-
4359
- const match = {
4360
- line: idx + 1,
4361
- content: line
4362
- };
4363
-
4364
- // Add context lines if requested
4365
- if (options.context && options.context > 0) {
4366
- const before = [];
4367
- const after = [];
4368
- for (let i = 1; i <= options.context; i++) {
4369
- if (idx - i >= 0) before.unshift(lines[idx - i]);
4370
- if (idx + i < lines.length) after.push(lines[idx + i]);
4371
- }
4372
- match.before = before;
4373
- match.after = after;
4374
- }
4375
-
4376
- matches.push(match);
4377
- }
4378
- });
4379
-
4380
- if (matches.length > 0) {
4381
- results.push({
4382
- file: fileEntry.relativePath,
4383
- matches
4384
- });
4385
- }
4386
- } catch (e) {
4387
- // Expected: binary/minified files fail to read or parse.
4388
- // These are not actionable errors — silently skip.
4389
- }
4390
- }
4391
-
4392
- // Apply top limit (limits total matches across all files)
4393
- const totalMatches = results.reduce((sum, r) => sum + r.matches.length, 0);
4394
- let truncatedMatches = 0;
4395
- if (options.top && options.top > 0 && totalMatches > options.top) {
4396
- let remaining = options.top;
4397
- const truncated = [];
4398
- for (const r of results) {
4399
- if (remaining <= 0) break;
4400
- if (r.matches.length <= remaining) {
4401
- truncated.push(r);
4402
- remaining -= r.matches.length;
4403
- } else {
4404
- truncated.push({ ...r, matches: r.matches.slice(0, remaining) });
4405
- remaining = 0;
4406
- }
4407
- }
4408
- truncatedMatches = totalMatches - options.top;
4409
- results.length = 0;
4410
- results.push(...truncated);
4411
- }
4412
-
4413
- results.meta = { filesScanned, filesSkipped, filesFilteredByFlag, totalFiles: this.files.size, regexFallback, totalMatches, truncatedMatches };
4414
- return results;
4415
- } finally { this._endOp(); }
4416
- }
4417
-
4418
- /**
4419
- * Structural search — query the symbol table and call index, not raw text.
4420
- * Answers questions like "functions taking Request param", "all db.* calls",
4421
- * "exported async functions", "decorated route handlers".
4422
- *
4423
- * @param {object} options
4424
- * @param {string} [options.term] - Name filter (glob: * and ? supported)
4425
- * @param {string} [options.type] - Symbol kind: function, class, call, method, type
4426
- * @param {string} [options.param] - Parameter name or type substring
4427
- * @param {string} [options.receiver] - Call receiver pattern (for type=call)
4428
- * @param {string} [options.returns] - Return type substring
4429
- * @param {string} [options.decorator] - Decorator/annotation name substring
4430
- * @param {boolean} [options.exported] - Only exported symbols
4431
- * @param {boolean} [options.unused] - Only symbols with zero callers
4432
- * @param {string[]} [options.exclude] - Exclude file patterns
4433
- * @param {string} [options.in] - Restrict to subdirectory
4434
- * @param {string} [options.file] - File pattern filter
4435
- * @param {number} [options.top] - Limit results
4436
- * @returns {{ results: Array, meta: object }}
4437
- */
4438
- structuralSearch(options = {}) {
4439
- this._beginOp();
4440
- try {
4441
- const { term, param, receiver, returns: returnType, decorator, exported, unused } = options;
4442
- // Auto-infer type: --receiver implies type=call
4443
- const type = options.type || (receiver ? 'call' : undefined);
4444
- const results = [];
4445
-
4446
- // Validate type if provided
4447
- if (type && !STRUCTURAL_TYPES.has(type)) {
4448
- return {
4449
- results: [],
4450
- meta: {
4451
- mode: 'structural',
4452
- query: { type },
4453
- totalMatched: 0,
4454
- shown: 0,
4455
- error: `Invalid type "${type}". Valid types: ${[...STRUCTURAL_TYPES].join(', ')}`,
4456
- }
4457
- };
4458
- }
4459
-
4460
- // Build glob-style name matcher from term
4461
- const nameMatcher = term ? buildGlobMatcher(term, options.caseSensitive) : null;
4462
-
4463
- // Helper: check if file passes filters
4464
- const passesFileFilter = (fileEntry) => {
4465
- if (!fileEntry) return false;
4466
- if (options.file) {
4467
- const rp = fileEntry.relativePath;
4468
- if (!rp.includes(options.file) && !rp.endsWith(options.file)) return false;
4469
- }
4470
- if ((options.exclude && options.exclude.length > 0) || options.in) {
4471
- if (!this.matchesFilters(fileEntry.relativePath, { exclude: options.exclude, in: options.in })) return false;
4472
- }
4473
- return true;
4474
- };
4475
-
4476
- if (type === 'call') {
4477
- // Search call sites from callee index
4478
- const { getCachedCalls } = require('./callers');
4479
- const seenFiles = new Set();
4480
-
4481
- // If term is given, only scan files that might contain that call
4482
- if (term && !term.includes('*') && !term.includes('?')) {
4483
- // Exact or substring — use callee index for fast lookup
4484
- this.buildCalleeIndex();
4485
- const files = this.calleeIndex.get(term);
4486
- if (files) for (const f of files) seenFiles.add(f);
4487
- } else {
4488
- // Scan all files
4489
- for (const fp of this.files.keys()) seenFiles.add(fp);
4490
- }
4491
-
4492
- for (const filePath of seenFiles) {
4493
- const fileEntry = this.files.get(filePath);
4494
- if (!passesFileFilter(fileEntry)) continue;
4495
- const calls = getCachedCalls(this, filePath);
4496
- if (!calls) continue;
4497
- for (const call of calls) {
4498
- if (nameMatcher && !nameMatcher(call.name)) continue;
4499
- if (receiver) {
4500
- if (!call.receiver) continue;
4501
- if (!matchesSubstring(call.receiver, receiver, options.caseSensitive)) continue;
4502
- }
4503
- results.push({
4504
- kind: 'call',
4505
- name: call.receiver ? `${call.receiver}.${call.name}` : call.name,
4506
- file: fileEntry.relativePath,
4507
- line: call.line,
4508
- receiver: call.receiver || null,
4509
- isMethod: call.isMethod || false,
4510
- });
4511
- }
4512
- }
4513
- } else {
4514
- // Search symbols (functions, classes, methods, types)
4515
- const functionTypes = new Set(['function', 'constructor', 'method', 'arrow', 'static', 'classmethod', 'abstract']);
4516
- const classTypes = new Set(['class', 'struct', 'interface', 'impl', 'trait']);
4517
- const typeTypes = new Set(['type', 'enum', 'interface', 'trait']);
4518
- const methodTypes = new Set(['method', 'constructor']);
4519
-
4520
- for (const [symbolName, definitions] of this.symbols) {
4521
- if (nameMatcher && !nameMatcher(symbolName)) continue;
4522
-
4523
- for (const def of definitions) {
4524
- // Type filter
4525
- if (type === 'function' && !functionTypes.has(def.type)) continue;
4526
- if (type === 'class' && !classTypes.has(def.type)) continue;
4527
- if (type === 'method' && !methodTypes.has(def.type) && !def.isMethod) continue;
4528
- if (type === 'type' && !typeTypes.has(def.type)) continue;
4529
-
4530
- // File filters
4531
- const fileEntry = this.files.get(def.file);
4532
- if (!passesFileFilter(fileEntry)) continue;
4533
-
4534
- // Param filter: match param name or type
4535
- if (param) {
4536
- const cs = options.caseSensitive;
4537
- const ps = def.paramsStructured || [];
4538
- const paramStr = def.params || '';
4539
- const hasMatch = ps.some(p =>
4540
- matchesSubstring(p.name, param, cs) ||
4541
- (p.type && matchesSubstring(p.type, param, cs))
4542
- ) || matchesSubstring(paramStr, param, cs);
4543
- if (!hasMatch) continue;
4544
- }
4545
-
4546
- // Receiver filter: match className for methods
4547
- if (receiver) {
4548
- if (!def.className || !matchesSubstring(def.className, receiver, options.caseSensitive)) continue;
4549
- }
4550
-
4551
- // Return type filter
4552
- if (returnType) {
4553
- if (!def.returnType || !matchesSubstring(def.returnType, returnType, options.caseSensitive)) continue;
4554
- }
4555
-
4556
- // Decorator filter: checks decorators (Python), modifiers (Java annotations stored lowercase)
4557
- if (decorator) {
4558
- const cs = options.caseSensitive;
4559
- const hasDecorator = (def.decorators && def.decorators.some(d => matchesSubstring(d, decorator, cs))) ||
4560
- (def.modifiers && def.modifiers.some(m => matchesSubstring(m, decorator, cs)));
4561
- if (!hasDecorator) continue;
4562
- }
4563
-
4564
- // Exported filter
4565
- if (exported) {
4566
- const mods = def.modifiers || [];
4567
- const isExp = (fileEntry && fileEntry.exports.includes(symbolName)) ||
4568
- mods.includes('export') || mods.includes('public') ||
4569
- mods.some(m => m.startsWith('pub')) ||
4570
- (fileEntry && fileEntry.language === 'go' && /^[A-Z]/.test(symbolName));
4571
- if (!isExp) continue;
4572
- }
4573
-
4574
- // Unused filter (expensive — last check)
4575
- if (unused) {
4576
- this.buildCalleeIndex();
4577
- if (this.calleeIndex.has(symbolName)) continue;
4578
- }
4579
-
4580
- // Merge decorators from both Python-style decorators and Java-style modifiers
4581
- const allDecorators = def.decorators || null;
4582
-
4583
- results.push({
4584
- kind: def.type,
4585
- name: symbolName,
4586
- file: def.relativePath,
4587
- line: def.startLine,
4588
- params: def.params || null,
4589
- returnType: def.returnType || null,
4590
- decorators: allDecorators,
4591
- className: def.className || null,
4592
- exported: exported ? true : undefined,
4593
- });
4594
- }
4595
- }
4596
- }
4597
-
4598
- // Sort by file, then line
4599
- results.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
4600
-
4601
- // Apply top limit
4602
- const total = results.length;
4603
- const top = options.top;
4604
- if (top && top > 0 && results.length > top) {
4605
- results.length = top;
4606
- }
4607
-
4608
- return {
4609
- results,
4610
- meta: {
4611
- mode: 'structural',
4612
- query: Object.fromEntries(Object.entries({
4613
- type: type || 'any', term, param, receiver, returns: returnType,
4614
- decorator, exported: exported || undefined, unused: unused || undefined,
4615
- }).filter(([, v]) => v !== undefined && v !== null)),
4616
- totalMatched: total,
4617
- shown: results.length,
4618
- }
4619
- };
4620
- } finally { this._endOp(); }
4621
- }
4622
-
4623
- // ========================================================================
4624
- // PROJECT INFO
4625
- // ========================================================================
4626
-
4627
- /**
4628
- * Get project statistics
4629
- */
4630
- getStats(options = {}) {
4631
- // Count total symbols (not just unique names)
4632
- let totalSymbols = 0;
4633
- for (const [name, symbols] of this.symbols) {
4634
- totalSymbols += symbols.length;
4635
- }
4636
-
4637
- const stats = {
4638
- root: this.root,
4639
- files: this.files.size,
4640
- symbols: totalSymbols, // Total symbol count, not unique names
4641
- buildTime: this.buildTime,
4642
- byLanguage: {},
4643
- byType: {},
4644
- ...(this.truncated && { truncated: this.truncated })
4645
- };
4646
-
4647
- for (const [filePath, fileEntry] of this.files) {
4648
- const lang = fileEntry.language;
4649
- if (!stats.byLanguage[lang]) {
4650
- stats.byLanguage[lang] = { files: 0, lines: 0, symbols: 0 };
4651
- }
4652
- stats.byLanguage[lang].files++;
4653
- stats.byLanguage[lang].lines += fileEntry.lines;
4654
- stats.byLanguage[lang].symbols += fileEntry.symbols.length;
4655
- }
4656
-
4657
- for (const [name, symbols] of this.symbols) {
4658
- for (const sym of symbols) {
4659
- if (!Object.hasOwn(stats.byType, sym.type)) {
4660
- stats.byType[sym.type] = 0;
4661
- }
4662
- stats.byType[sym.type]++;
4663
- }
4664
- }
4665
-
4666
- // Per-function line counts for complexity audits
4667
- if (options.functions) {
4668
- const functions = [];
4669
- for (const [name, symbols] of this.symbols) {
4670
- for (const sym of symbols) {
4671
- if (sym.type === 'function' || sym.type === 'method' || sym.type === 'static' ||
4672
- sym.type === 'constructor' || sym.type === 'public' || sym.type === 'abstract' ||
4673
- sym.type === 'classmethod') {
4674
- const lineCount = sym.endLine - sym.startLine + 1;
4675
- const relativePath = sym.relativePath || (sym.file ? path.relative(this.root, sym.file) : '');
4676
- functions.push({
4677
- name: sym.className ? `${sym.className}.${sym.name}` : sym.name,
4678
- file: relativePath,
4679
- startLine: sym.startLine,
4680
- lines: lineCount
4681
- });
4682
- }
4683
- }
4684
- }
4685
- functions.sort((a, b) => b.lines - a.lines);
4686
- stats.functions = functions;
4687
- }
4688
-
4689
- return stats;
4690
- }
4691
-
4692
- /**
4693
- * Get TOC for all files
4694
- */
4695
- getToc(options = {}) {
4696
- const files = [];
4697
- let totalFunctions = 0;
4698
- let totalClasses = 0;
4699
- let totalState = 0;
4700
- let totalLines = 0;
4701
- let totalDynamic = 0;
4702
- let totalTests = 0;
4703
-
4704
- // When file= is specified, scope to matching files only
4705
- let fileFilter = null;
4706
- if (options.file) {
4707
- const resolved = this.findFile(options.file);
4708
- if (resolved) {
4709
- fileFilter = new Set([resolved]);
4710
- } else {
4711
- // Try substring match for partial paths
4712
- const matching = [];
4713
- for (const fp of this.files.keys()) {
4714
- const rp = path.relative(this.root, fp);
4715
- if (rp.includes(options.file) || fp.includes(options.file)) {
4716
- matching.push(fp);
4717
- }
4718
- }
4719
- if (matching.length > 0) {
4720
- fileFilter = new Set(matching);
4721
- } else {
4722
- return {
4723
- meta: { complete: true, skipped: 0, dynamicImports: 0, uncertain: 0 },
4724
- totals: { files: 0, lines: 0, functions: 0, classes: 0, state: 0, testFiles: 0 },
4725
- summary: { topFunctionFiles: [], topLineFiles: [], entryFiles: [] },
4726
- files: [],
4727
- hiddenFiles: 0,
4728
- error: `File not found in project: ${options.file}`
4729
- };
4730
- }
4731
- }
4732
- }
4733
-
4734
- for (const [filePath, fileEntry] of this.files) {
4735
- if (fileFilter && !fileFilter.has(filePath)) continue;
4736
- if (options.exclude && options.exclude.length > 0) {
4737
- if (!this.matchesFilters(fileEntry.relativePath, { exclude: options.exclude })) continue;
4738
- }
4739
- if (options.in) {
4740
- if (!this.matchesFilters(fileEntry.relativePath, { in: options.in })) continue;
4741
- }
4742
- let functions = fileEntry.symbols.filter(s =>
4743
- s.type === 'function' || s.type === 'method' || s.type === 'static' ||
4744
- s.type === 'constructor' || s.type === 'public' || s.type === 'abstract' ||
4745
- s.type === 'classmethod'
4746
- );
4747
- const classes = fileEntry.symbols.filter(s =>
4748
- ['class', 'interface', 'type', 'enum', 'struct', 'trait', 'impl', 'record', 'namespace'].includes(s.type)
4749
- );
4750
- const state = fileEntry.symbols.filter(s => s.type === 'state');
4751
-
4752
- if (options.topLevel) {
4753
- functions = functions.filter(fn => !fn.isNested && (!fn.indent || fn.indent === 0));
4754
- }
4755
-
4756
- totalFunctions += functions.length;
4757
- totalClasses += classes.length;
4758
- totalState += state.length;
4759
- totalLines += fileEntry.lines;
4760
- totalDynamic += fileEntry.dynamicImports || 0;
4761
- if (isTestFile(fileEntry.relativePath, fileEntry.language)) totalTests += 1;
4762
-
4763
- const entry = {
4764
- file: fileEntry.relativePath,
4765
- language: fileEntry.language,
4766
- lines: fileEntry.lines,
4767
- functions: functions.length,
4768
- classes: classes.length,
4769
- state: state.length
4770
- };
4771
-
4772
- if (options.detailed) {
4773
- entry.symbols = { functions, classes, state };
4774
- }
4775
-
4776
- files.push(entry);
4777
- }
4778
-
4779
- // Hints: top files by function count and lines
4780
- const hintLimit = options.all ? Infinity : 3;
4781
- const topFunctionFiles = [...files]
4782
- .sort((a, b) => b.functions - a.functions || b.lines - a.lines)
4783
- .filter(f => f.functions > 0)
4784
- .slice(0, hintLimit)
4785
- .map(f => ({ file: f.file, functions: f.functions }));
4786
-
4787
- const topLineFiles = [...files]
4788
- .sort((a, b) => b.lines - a.lines)
4789
- .slice(0, hintLimit)
4790
- .map(f => ({ file: f.file, lines: f.lines }));
4791
-
4792
- // Entry point candidates
4793
- const entryPattern = /(main|index|server|app)\.(js|jsx|ts|tsx|py|go|rs|java)$/i;
4794
- const entryFiles = files
4795
- .filter(f => entryPattern.test(f.file))
4796
- .slice(0, options.all ? Infinity : 5)
4797
- .map(f => f.file);
4798
-
4799
- // Also detect entry points from package.json main/exports fields
4800
- const pkgJsonPath = path.join(this.root, 'package.json');
4801
- try {
4802
- const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
4803
- const mainField = pkgJson.main || pkgJson.module;
4804
- if (mainField) {
4805
- const mainFile = path.relative(this.root, path.resolve(this.root, mainField));
4806
- if (files.some(f => f.file === mainFile) && !entryFiles.includes(mainFile)) {
4807
- entryFiles.unshift(mainFile);
4808
- }
4809
- }
4810
- } catch {
4811
- // No package.json or invalid JSON — skip
4812
- }
4813
-
4814
- // Apply top limit for detailed mode to avoid massive output
4815
- const top = options.top > 0 ? options.top : (options.detailed && !options.all ? 50 : Infinity);
4816
- let hiddenFiles = 0;
4817
- let displayFiles = files;
4818
- if (top < files.length) {
4819
- hiddenFiles = files.length - top;
4820
- displayFiles = files.slice(0, top);
4821
- }
4822
-
4823
- // Count files with no symbols (generated/empty files)
4824
- const emptyFiles = files.filter(f => f.functions === 0 && f.classes === 0 && f.state === 0).length;
4825
-
4826
- return {
4827
- meta: {
4828
- complete: totalDynamic === 0,
4829
- skipped: 0,
4830
- dynamicImports: totalDynamic,
4831
- uncertain: 0,
4832
- projectLanguage: this._getPredominantLanguage(),
4833
- ...(fileFilter && { filteredBy: options.file, matchedFiles: files.length }),
4834
- ...(options.in && { scopedTo: options.in }),
4835
- ...(emptyFiles > 0 && fileFilter && { emptyFiles })
4836
- },
4837
- totals: {
4838
- files: files.length,
4839
- lines: totalLines,
4840
- functions: totalFunctions,
4841
- classes: totalClasses,
4842
- state: totalState,
4843
- testFiles: totalTests
4844
- },
4845
- summary: {
4846
- topFunctionFiles,
4847
- topLineFiles,
4848
- entryFiles
4849
- },
4850
- files: displayFiles,
4851
- hiddenFiles
4852
- };
4853
- }
4854
-
4855
- // ========================================================================
4856
- // CACHE METHODS
4857
- // ========================================================================
4858
-
4859
- /** Save index to cache file */
4860
- saveCache(cachePath) { return indexCache.saveCache(this, cachePath); }
4861
-
4862
- /** Load index from cache file */
4863
- loadCache(cachePath) { return indexCache.loadCache(this, cachePath); }
4864
-
4865
- /** Load callsCache from separate file on demand (called by findCallers/findCallees) */
4866
- loadCallsCache() { return indexCache.loadCallsCache(this); }
4867
-
4868
- /** Check if cache is stale (any files changed or new files added) */
4869
- isCacheStale() { return indexCache.isCacheStale(this); }
4870
-
4871
- /**
4872
- * Find the best usage example of a function.
4873
- * Scores call sites using AST analysis (await, destructuring, typed assignment, etc.)
4874
- * @param {string} name - Symbol name
4875
- * @returns {{ best: object, totalCalls: number } | null}
4876
- */
4877
- example(name, options = {}) {
4878
- this._beginOp();
4879
- try {
4880
- const usages = this.usages(name, {
4881
- codeOnly: true,
4882
- className: options.className,
4883
- exclude: ['test', 'spec', '__tests__', '__mocks__', 'fixture', 'mock'],
4884
- context: 5
4885
- });
4886
-
4887
- const calls = usages.filter(u => u.usageType === 'call' && !u.isDefinition);
4888
- if (calls.length === 0) return null;
4889
-
4890
- const scored = calls.map(call => {
4891
- let score = 0;
4892
- const reasons = [];
4893
- const line = call.content.trim();
4894
-
4895
- const astInfo = this._analyzeCallSiteAST(call.file, call.line, name);
4896
-
4897
- if (astInfo.isTypedAssignment) { score += 15; reasons.push('typed assignment'); }
4898
- if (astInfo.isInReturn) { score += 10; reasons.push('in return'); }
4899
- if (astInfo.isAwait) { score += 10; reasons.push('async usage'); }
4900
- if (astInfo.isDestructured) { score += 8; reasons.push('destructured'); }
4901
- if (astInfo.isStandalone) { score += 5; reasons.push('standalone'); }
4902
- if (astInfo.hasComment) { score += 3; reasons.push('documented'); }
4903
- if (astInfo.isInCatch) { score -= 5; reasons.push('in catch block'); }
4904
- if (astInfo.isInConditional) { score -= 3; reasons.push('in conditional'); }
4905
-
4906
- if (score === 0) {
4907
- if (/^(const|let|var|return)\s/.test(line) || /^\w+\s*=/.test(line)) {
4908
- score += 10; reasons.push('return value used');
4909
- }
4910
- if (line.startsWith(name + '(') || /^(const|let|var)\s+\w+\s*=\s*\w*$/.test(line.split(name)[0])) {
4911
- score += 5; reasons.push('clear usage');
4912
- }
4913
- }
4914
-
4915
- if (call.before && call.before.length > 0) score += 3;
4916
- if (call.after && call.after.length > 0) score += 3;
4917
- if (call.before?.length > 0 && call.after?.length > 0) reasons.push('has context');
4918
-
4919
- const beforeCall = line.split(name + '(')[0];
4920
- if (!beforeCall.includes('(') || /^\s*(const|let|var|return)?\s*\w+\s*=\s*$/.test(beforeCall)) {
4921
- score += 2;
4922
- }
4923
- if (call.line < 100) score += 1;
4924
-
4925
- return { ...call, score, reasons };
4926
- });
4927
-
4928
- scored.sort((a, b) => b.score - a.score);
4929
- return { best: scored[0], totalCalls: calls.length };
4930
- } finally { this._endOp(); }
4931
- }
2045
+ example(name, options) { return searchModule.example(this, name, options); }
4932
2046
 
4933
2047
  /** Analyze a call site using AST for example scoring */
4934
2048
  _analyzeCallSiteAST(filePath, lineNum, funcName) { return verifyModule.analyzeCallSiteAST(this, filePath, lineNum, funcName); }
4935
2049
 
4936
- /**
4937
- * Diff-based impact analysis: find which functions changed and who calls them
4938
- *
4939
- * @param {object} options - { base, staged, file }
4940
- * @returns {object} - { base, functions, moduleLevelChanges, newFunctions, deletedFunctions, summary }
4941
- */
4942
- diffImpact(options = {}) {
4943
- this._beginOp();
4944
- try {
4945
- const { base = 'HEAD', staged = false, file } = options;
4946
-
4947
- // Validate base ref format to prevent argument injection
4948
- if (base && !/^[a-zA-Z0-9._\-~\/^@{}:]+$/.test(base)) {
4949
- throw new Error(`Invalid git ref format: ${base}`);
4950
- }
4951
-
4952
- // Verify git repo
4953
- let gitRoot;
4954
- try {
4955
- gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: this.root, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
4956
- } catch (e) {
4957
- throw new Error('Not a git repository. diff-impact requires git.');
4958
- }
4959
-
4960
- // Build git diff command (use execFileSync to avoid shell expansion)
4961
- const diffArgs = ['diff', '--unified=0'];
4962
- if (staged) {
4963
- diffArgs.push('--staged');
4964
- } else {
4965
- diffArgs.push(base);
4966
- }
4967
- if (file) {
4968
- diffArgs.push('--', file);
4969
- }
4970
-
4971
- let diffText;
4972
- try {
4973
- diffText = execFileSync('git', diffArgs, { cwd: this.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
4974
- } catch (e) {
4975
- // git diff exits non-zero when there are diff errors, but also for invalid refs
4976
- if (e.stdout) {
4977
- diffText = e.stdout;
4978
- } else {
4979
- throw new Error(`git diff failed: ${e.message}`);
4980
- }
4981
- }
4982
-
4983
- if (!diffText || !diffText.trim()) {
4984
- return {
4985
- base: staged ? '(staged)' : base,
4986
- functions: [],
4987
- moduleLevelChanges: [],
4988
- newFunctions: [],
4989
- deletedFunctions: [],
4990
- summary: { modifiedFunctions: 0, deletedFunctions: 0, newFunctions: 0, totalCallSites: 0, affectedFiles: 0 }
4991
- };
4992
- }
4993
-
4994
- // Diff paths are git-root-relative. Resolve to this.root for file lookup.
4995
- // Normalize both through realpath to handle macOS /var → /private/var symlinks.
4996
- let realGitRoot, realProjectRoot;
4997
- try { realGitRoot = fs.realpathSync(gitRoot); } catch (_) { realGitRoot = gitRoot; }
4998
- try { realProjectRoot = fs.realpathSync(this.root); } catch (_) { realProjectRoot = this.root; }
4999
- const projectPrefix = realGitRoot === realProjectRoot
5000
- ? ''
5001
- : path.relative(realGitRoot, realProjectRoot);
5002
-
5003
- const rawChanges = parseDiff(diffText, gitRoot);
5004
- // Filter to files under this.root and remap paths.
5005
- // Preserve gitRelativePath (repo-relative) for git show commands.
5006
- const changes = [];
5007
- for (const c of rawChanges) {
5008
- if (projectPrefix && !c.relativePath.startsWith(projectPrefix + '/')) continue;
5009
- const localRel = projectPrefix ? c.relativePath.slice(projectPrefix.length + 1) : c.relativePath;
5010
- changes.push({ ...c, gitRelativePath: c.relativePath, filePath: path.join(this.root, localRel), relativePath: localRel });
5011
- }
5012
-
5013
- const functions = [];
5014
- const moduleLevelChanges = [];
5015
- const newFunctions = [];
5016
- const deletedFunctions = [];
5017
- const callerFileSet = new Set();
5018
- let totalCallSites = 0;
5019
-
5020
- for (const change of changes) {
5021
- const lang = detectLanguage(change.filePath);
5022
- if (!lang) continue;
5023
-
5024
- const fileEntry = this.files.get(change.filePath);
5025
-
5026
- // Handle deleted files: entire file was removed, all functions are deleted
5027
- if (!fileEntry) {
5028
- if (change.isDeleted && change.deletedLines.length > 0) {
5029
- const ref = staged ? 'HEAD' : base;
5030
- try {
5031
- const oldContent = execFileSync(
5032
- 'git', ['show', `${ref}:${change.gitRelativePath}`],
5033
- { cwd: this.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }
5034
- );
5035
- const oldParsed = parse(oldContent, lang);
5036
- for (const oldFn of extractCallableSymbols(oldParsed)) {
5037
- deletedFunctions.push({
5038
- name: oldFn.name,
5039
- filePath: change.filePath,
5040
- relativePath: change.relativePath,
5041
- startLine: oldFn.startLine
5042
- });
5043
- }
5044
- } catch (e) {
5045
- // git show failed — skip
5046
- }
5047
- }
5048
- continue;
5049
- }
5050
-
5051
- // Track which functions are affected by added/modified lines
5052
- const affectedSymbols = new Map(); // symbolName -> { symbol, addedLines, deletedLines }
5053
-
5054
- for (const line of change.addedLines) {
5055
- const symbol = this.findEnclosingFunction(change.filePath, line, true);
5056
- if (symbol) {
5057
- const key = `${symbol.name}:${symbol.startLine}`;
5058
- if (!affectedSymbols.has(key)) {
5059
- affectedSymbols.set(key, { symbol, addedLines: [], deletedLines: [] });
5060
- }
5061
- affectedSymbols.get(key).addedLines.push(line);
5062
- } else {
5063
- // Module-level change
5064
- const existing = moduleLevelChanges.find(m => m.filePath === change.filePath);
5065
- if (existing) {
5066
- existing.addedLines.push(line);
5067
- } else {
5068
- moduleLevelChanges.push({
5069
- filePath: change.filePath,
5070
- relativePath: change.relativePath,
5071
- addedLines: [line],
5072
- deletedLines: []
5073
- });
5074
- }
5075
- }
5076
- }
5077
-
5078
- for (const line of change.deletedLines) {
5079
- // For deleted lines, we can't use findEnclosingFunction on the current file
5080
- // since those lines no longer exist. Track as module-level unless they map
5081
- // to a function that still exists (the function was modified, not deleted).
5082
- // We approximate: if a deleted line is within the range of a known symbol, it's a modification.
5083
- let matched = false;
5084
- for (const symbol of fileEntry.symbols) {
5085
- if (NON_CALLABLE_TYPES.has(symbol.type)) continue;
5086
- // Use a generous range — deleted lines near a function likely belong to it
5087
- if (line >= symbol.startLine - 2 && line <= symbol.endLine + 2) {
5088
- const key = `${symbol.name}:${symbol.startLine}`;
5089
- if (!affectedSymbols.has(key)) {
5090
- affectedSymbols.set(key, { symbol, addedLines: [], deletedLines: [] });
5091
- }
5092
- affectedSymbols.get(key).deletedLines.push(line);
5093
- matched = true;
5094
- break;
5095
- }
5096
- }
5097
- if (!matched) {
5098
- const existing = moduleLevelChanges.find(m => m.filePath === change.filePath);
5099
- if (existing) {
5100
- existing.deletedLines.push(line);
5101
- } else {
5102
- moduleLevelChanges.push({
5103
- filePath: change.filePath,
5104
- relativePath: change.relativePath,
5105
- addedLines: [],
5106
- deletedLines: [line]
5107
- });
5108
- }
5109
- }
5110
- }
5111
-
5112
- // Detect new functions: all added lines are within a single function range
5113
- // and the function didn't exist before (approximation: all lines in the function are added)
5114
- for (const [key, data] of affectedSymbols) {
5115
- const { symbol, addedLines } = data;
5116
- const fnLineCount = symbol.endLine - symbol.startLine + 1;
5117
- if (addedLines.length >= fnLineCount * 0.8 && data.deletedLines.length === 0) {
5118
- newFunctions.push({
5119
- name: symbol.name,
5120
- filePath: change.filePath,
5121
- relativePath: change.relativePath,
5122
- startLine: symbol.startLine,
5123
- endLine: symbol.endLine,
5124
- signature: this.formatSignature(symbol)
5125
- });
5126
- affectedSymbols.delete(key);
5127
- }
5128
- }
5129
-
5130
- // Detect deleted functions: compare old file symbols with current by identity.
5131
- // Uses name+className counts to handle overloads (e.g. Java method overloading).
5132
- if (change.deletedLines.length > 0) {
5133
- const ref = staged ? 'HEAD' : base;
5134
- try {
5135
- const oldContent = execFileSync(
5136
- 'git', ['show', `${ref}:${change.gitRelativePath}`],
5137
- { cwd: this.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }
5138
- );
5139
- const fileLang = detectLanguage(change.filePath);
5140
- if (fileLang) {
5141
- const oldParsed = parse(oldContent, fileLang);
5142
- // Count current symbols by identity (name + className)
5143
- const currentCounts = new Map();
5144
- for (const s of fileEntry.symbols) {
5145
- if (NON_CALLABLE_TYPES.has(s.type)) continue;
5146
- const key = `${s.name}\0${s.className || ''}`;
5147
- currentCounts.set(key, (currentCounts.get(key) || 0) + 1);
5148
- }
5149
- // Count old symbols by identity and detect deletions
5150
- const oldCounts = new Map();
5151
- const oldSymbols = extractCallableSymbols(oldParsed);
5152
- for (const oldFn of oldSymbols) {
5153
- const key = `${oldFn.name}\0${oldFn.className || ''}`;
5154
- oldCounts.set(key, (oldCounts.get(key) || 0) + 1);
5155
- }
5156
- // For each identity, if old count > current count, the difference are deletions
5157
- for (const [key, oldCount] of oldCounts) {
5158
- const curCount = currentCounts.get(key) || 0;
5159
- if (oldCount > curCount) {
5160
- // Find the specific old symbols with this identity that were deleted
5161
- const matching = oldSymbols.filter(s => `${s.name}\0${s.className || ''}` === key);
5162
- // Report the extra ones (by startLine descending — later ones more likely deleted)
5163
- const toReport = matching.slice(curCount);
5164
- for (const oldFn of toReport) {
5165
- deletedFunctions.push({
5166
- name: oldFn.name,
5167
- filePath: change.filePath,
5168
- relativePath: change.relativePath,
5169
- startLine: oldFn.startLine
5170
- });
5171
- }
5172
- }
5173
- }
5174
- }
5175
- } catch (e) {
5176
- // File didn't exist in base, or git error — skip
5177
- }
5178
- }
5179
-
5180
- // For each affected function, find callers
5181
- for (const [, data] of affectedSymbols) {
5182
- const { symbol, addedLines: aLines, deletedLines: dLines } = data;
5183
-
5184
- // Get the specific definitions matching this symbol
5185
- const allDefs = this.symbols.get(symbol.name) || [];
5186
- const targetDefs = allDefs.filter(d => d.file === change.filePath && d.startLine === symbol.startLine);
5187
-
5188
- let callers = this.findCallers(symbol.name, {
5189
- targetDefinitions: targetDefs.length > 0 ? targetDefs : undefined,
5190
- includeMethods: true,
5191
- includeUncertain: false,
5192
- });
5193
-
5194
- // For Go/Java/Rust methods with a className, filter callers whose
5195
- // receiver clearly belongs to a different type (same logic as impact()).
5196
- const targetDef = targetDefs[0] || symbol;
5197
- if (targetDef.className && (lang === 'go' || lang === 'java' || lang === 'rust')) {
5198
- const targetClassName = targetDef.className;
5199
- // Pre-compute how many types share this method name
5200
- const methodDefs = this.symbols.get(symbol.name);
5201
- const classNames = new Set();
5202
- if (methodDefs) {
5203
- for (const d of methodDefs) {
5204
- if (d.className) classNames.add(d.className);
5205
- else if (d.receiver) classNames.add(d.receiver.replace(/^\*/, ''));
5206
- }
5207
- }
5208
- const isWidelyShared = classNames.size > 3;
5209
- callers = callers.filter(c => {
5210
- if (!c.isMethod) return true;
5211
- const r = c.receiver;
5212
- if (r && ['self', 'cls', 'this', 'super'].includes(r)) return true;
5213
- // No receiver (chained/complex expression): only include if method is
5214
- // unique or rare across types — otherwise too many false positives
5215
- if (!r) {
5216
- return classNames.size <= 1;
5217
- }
5218
- // Use receiverType from findCallers when available
5219
- if (c.receiverType) {
5220
- return c.receiverType === targetClassName ||
5221
- c.receiverType === targetDef.receiver?.replace(/^\*/, '');
5222
- }
5223
- // Unique method heuristic: if the method exists on exactly one class/type, include
5224
- if (classNames.size === 1 && classNames.has(targetClassName)) return true;
5225
- // For widely shared method names (Get, Set, Run, etc.), require same-package
5226
- // evidence when receiver type is unknown
5227
- if (isWidelyShared) {
5228
- const callerFile = c.file || '';
5229
- const targetDir = path.dirname(change.filePath);
5230
- return path.dirname(callerFile) === targetDir;
5231
- }
5232
- // Unknown receiver + multiple classes with this method → filter out
5233
- return false;
5234
- });
5235
- }
5236
-
5237
- for (const c of callers) {
5238
- callerFileSet.add(c.file);
5239
- }
5240
- totalCallSites += callers.length;
5241
-
5242
- functions.push({
5243
- name: symbol.name,
5244
- filePath: change.filePath,
5245
- relativePath: change.relativePath,
5246
- startLine: symbol.startLine,
5247
- endLine: symbol.endLine,
5248
- signature: this.formatSignature(symbol),
5249
- addedLines: aLines,
5250
- deletedLines: dLines,
5251
- callers: callers.map(c => ({
5252
- file: c.file,
5253
- relativePath: c.relativePath,
5254
- line: c.line,
5255
- callerName: c.callerName,
5256
- content: c.content.trim()
5257
- }))
5258
- });
5259
- }
5260
- }
5261
-
5262
- return {
5263
- base: staged ? '(staged)' : base,
5264
- functions,
5265
- moduleLevelChanges,
5266
- newFunctions,
5267
- deletedFunctions,
5268
- summary: {
5269
- modifiedFunctions: functions.length,
5270
- deletedFunctions: deletedFunctions.length,
5271
- newFunctions: newFunctions.length,
5272
- totalCallSites,
5273
- affectedFiles: callerFileSet.size
5274
- }
5275
- };
5276
- } finally { this._endOp(); }
5277
- }
5278
- }
5279
-
5280
- /**
5281
- * Extract all callable symbols (functions + class methods) from a parse result,
5282
- * matching how indexFile builds the symbol list. Methods get className added.
5283
- * @param {object} parsed - Result from parse()
5284
- * @returns {Array<{name, className, startLine}>}
5285
- */
5286
- function extractCallableSymbols(parsed) {
5287
- const symbols = [];
5288
- for (const fn of parsed.functions) {
5289
- symbols.push({ name: fn.name, className: fn.className || '', startLine: fn.startLine });
5290
- }
5291
- for (const cls of parsed.classes) {
5292
- if (cls.members) {
5293
- for (const m of cls.members) {
5294
- symbols.push({ name: m.name, className: cls.name, startLine: m.startLine });
5295
- }
5296
- }
5297
- }
5298
- return symbols;
5299
- }
5300
-
5301
- /**
5302
- * Unquote a git diff path: unescape C-style backslash sequences and strip tab metadata.
5303
- * Git quotes paths containing special chars as "a/path\"with\"quotes".
5304
- * @param {string} raw - Raw path string (may contain backslash escapes)
5305
- * @returns {string} Unquoted path
5306
- */
5307
- function unquoteDiffPath(raw) {
5308
- const ESCAPES = { '\\\\': '\\', '\\"': '"', '\\n': '\n', '\\t': '\t' };
5309
- return raw
5310
- .split('\t')[0]
5311
- .replace(/\\[\\"nt]/g, m => ESCAPES[m]);
2050
+ /** Diff-based impact analysis: find which functions changed and who calls them */
2051
+ diffImpact(options) { return analysisModule.diffImpact(this, options); }
5312
2052
  }
5313
2053
 
5314
- /**
5315
- * Parse unified diff output into structured change data
5316
- * @param {string} diffText - Output from `git diff --unified=0`
5317
- * @param {string} root - Project root directory
5318
- * @returns {Array<{ filePath, relativePath, addedLines, deletedLines }>}
5319
- */
5320
- function parseDiff(diffText, root) {
5321
- const changes = [];
5322
- let currentFile = null;
5323
- let pendingOldPath = null; // Track --- a/ path for deleted files
5324
-
5325
- for (const line of diffText.split('\n')) {
5326
- // Track old file path from --- header for deleted-file detection
5327
- // Handles both unquoted (--- a/path) and quoted (--- "a/path") formats
5328
- const oldMatch = line.match(/^--- (?:"a\/((?:[^"\\]|\\.)*)"|a\/(.+?))\s*$/);
5329
- if (oldMatch) {
5330
- const raw = oldMatch[1] !== undefined ? oldMatch[1] : oldMatch[2];
5331
- pendingOldPath = unquoteDiffPath(raw);
5332
- continue;
5333
- }
5334
-
5335
- // Match file header: +++ b/path or +++ "b/path" or +++ /dev/null
5336
- if (line.startsWith('+++ ')) {
5337
- let relativePath;
5338
- const isDevNull = line.startsWith('+++ /dev/null');
5339
- if (isDevNull) {
5340
- // File was deleted — use the --- a/ path
5341
- if (!pendingOldPath) continue;
5342
- relativePath = pendingOldPath;
5343
- } else {
5344
- const newMatch = line.match(/^\+\+\+ (?:"b\/((?:[^"\\]|\\.)*)"|b\/(.+?))\s*$/);
5345
- if (!newMatch) continue;
5346
- const raw = newMatch[1] !== undefined ? newMatch[1] : newMatch[2];
5347
- relativePath = unquoteDiffPath(raw);
5348
- }
5349
- pendingOldPath = null;
5350
- currentFile = {
5351
- filePath: path.join(root, relativePath),
5352
- relativePath,
5353
- addedLines: [],
5354
- deletedLines: [],
5355
- ...(isDevNull && { isDeleted: true })
5356
- };
5357
- changes.push(currentFile);
5358
- continue;
5359
- }
5360
-
5361
- // Match hunk header: @@ -old,count +new,count @@
5362
- if (line.startsWith('@@') && currentFile) {
5363
- const match = line.match(/@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
5364
- if (match) {
5365
- const oldStart = parseInt(match[1], 10);
5366
- const oldCount = parseInt(match[2] || '1', 10);
5367
- const newStart = parseInt(match[3], 10);
5368
- const newCount = parseInt(match[4] || '1', 10);
5369
-
5370
- // Deleted lines (from old file)
5371
- if (oldCount > 0) {
5372
- for (let i = 0; i < oldCount; i++) {
5373
- currentFile.deletedLines.push(oldStart + i);
5374
- }
5375
- }
5376
-
5377
- // Added lines (in new file)
5378
- if (newCount > 0) {
5379
- for (let i = 0; i < newCount; i++) {
5380
- currentFile.addedLines.push(newStart + i);
5381
- }
5382
- }
5383
- }
5384
- }
5385
- }
5386
-
5387
- return changes;
5388
- }
2054
+ const { parseDiff } = require('./analysis');
5389
2055
 
5390
2056
  module.exports = { ProjectIndex, parseDiff };