ucn 3.7.46 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/core/project.js CHANGED
@@ -14,7 +14,7 @@ const { extractImports, extractExports, resolveImport } = require('./imports');
14
14
  const { parse, parseFile, cleanHtmlScriptTags } = require('./parser');
15
15
  const { detectLanguage, getParser, getLanguageModule, safeParse } = require('../languages');
16
16
  const { getTokenTypeAtPosition } = require('../languages/utils');
17
- const { escapeRegExp, NON_CALLABLE_TYPES } = require('./shared');
17
+ const { escapeRegExp, NON_CALLABLE_TYPES, addTestExclusions } = require('./shared');
18
18
  const stacktrace = require('./stacktrace');
19
19
  const indexCache = require('./cache');
20
20
  const deadcodeModule = require('./deadcode');
@@ -24,6 +24,29 @@ const callersModule = require('./callers');
24
24
  // Lazy-initialized per-language keyword sets (populated on first isKeyword call)
25
25
  let LANGUAGE_KEYWORDS = null;
26
26
 
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
+
27
50
  /**
28
51
  * ProjectIndex - Manages symbol table for a project
29
52
  */
@@ -47,6 +70,7 @@ class ProjectIndex {
47
70
  this.failedFiles = new Set(); // files that failed to index (e.g. large minified bundles)
48
71
  this._opContentCache = null; // per-operation file content cache (Map<filePath, string>)
49
72
  this._opUsagesCache = null; // per-operation findUsagesInCode cache (Map<"file:name", usages[]>)
73
+ this.calleeIndex = null; // name -> Set<filePath> — inverted call index (built lazily)
50
74
  }
51
75
 
52
76
  /**
@@ -157,7 +181,7 @@ class ProjectIndex {
157
181
 
158
182
  const globOpts = {
159
183
  root: this.root,
160
- maxFiles: options.maxFiles || 10000,
184
+ maxFiles: options.maxFiles || this.config.maxFiles || 50000,
161
185
  followSymlinks: options.followSymlinks
162
186
  };
163
187
 
@@ -170,10 +194,18 @@ class ProjectIndex {
170
194
 
171
195
  const files = expandGlob(pattern, globOpts);
172
196
 
197
+ // Track if files were truncated by maxFiles limit
198
+ if (files.length >= globOpts.maxFiles) {
199
+ this.truncated = { indexed: files.length, maxFiles: globOpts.maxFiles };
200
+ } else {
201
+ this.truncated = null;
202
+ }
203
+
173
204
  if (!quiet) {
174
205
  console.error(`Indexing ${files.length} files in ${this.root}...`);
175
206
  }
176
207
 
208
+ let deletedInRebuild = 0;
177
209
  if (options.forceRebuild) {
178
210
  // Incremental rebuild: only remove files that no longer exist on disk.
179
211
  // indexFile() already skips unchanged files and calls removeFileSymbols()
@@ -184,6 +216,7 @@ class ProjectIndex {
184
216
  this.removeFileSymbols(cachedPath);
185
217
  this.files.delete(cachedPath);
186
218
  this.callsCache.delete(cachedPath);
219
+ deletedInRebuild++;
187
220
  }
188
221
  }
189
222
  }
@@ -193,10 +226,11 @@ class ProjectIndex {
193
226
  this._attrTypeCache = null;
194
227
 
195
228
  let indexed = 0;
229
+ let changed = 0;
196
230
  if (!this.failedFiles) this.failedFiles = new Set();
197
231
  for (const file of files) {
198
232
  try {
199
- this.indexFile(file);
233
+ if (this.indexFile(file)) changed++;
200
234
  indexed++;
201
235
  this.failedFiles.delete(file); // Succeeded now, remove from failed
202
236
  } catch (e) {
@@ -207,8 +241,11 @@ class ProjectIndex {
207
241
  }
208
242
  }
209
243
 
210
- this.buildImportGraph();
211
- this.buildInheritanceGraph();
244
+ // Skip graph rebuild when incremental rebuild found no changes
245
+ if (changed > 0 || deletedInRebuild > 0 || !options.forceRebuild) {
246
+ this.buildImportGraph();
247
+ this.buildInheritanceGraph();
248
+ }
212
249
 
213
250
  this.buildTime = Date.now() - startTime;
214
251
 
@@ -234,14 +271,22 @@ class ProjectIndex {
234
271
  * Index a single file
235
272
  */
236
273
  indexFile(filePath) {
274
+ const stat = fs.statSync(filePath);
275
+ const existing = this.files.get(filePath);
276
+
277
+ // Fast path: skip read entirely when mtime+size both match
278
+ if (existing && existing.mtime === stat.mtimeMs && existing.size === stat.size) {
279
+ return false;
280
+ }
281
+
237
282
  const content = fs.readFileSync(filePath, 'utf-8');
238
283
  const hash = crypto.createHash('md5').update(content).digest('hex');
239
- const stat = fs.statSync(filePath);
240
284
 
241
- // Check if already indexed and unchanged
242
- const existing = this.files.get(filePath);
243
- if (existing && existing.hash === hash && existing.mtime === stat.mtimeMs) {
244
- return;
285
+ // Content-based skip: mtime changed but content didn't (touch, git checkout)
286
+ if (existing && existing.hash === hash) {
287
+ existing.mtime = stat.mtimeMs;
288
+ existing.size = stat.size;
289
+ return false;
245
290
  }
246
291
 
247
292
  if (existing) {
@@ -251,7 +296,11 @@ class ProjectIndex {
251
296
  const language = detectLanguage(filePath);
252
297
  if (!language) return;
253
298
 
254
- const parsed = parseFile(filePath);
299
+ // Parse content once — the tree-sitter cache in safeParse ensures the tree
300
+ // is shared across parse()/extractImports()/extractExports() (5→1 parse per file)
301
+ const parsed = parse(content, language);
302
+ parsed.filePath = filePath;
303
+ parsed.relativePath = filePath;
255
304
  const { imports, dynamicCount, importAliases } = extractImports(content, language);
256
305
  const { exports } = extractExports(content, language);
257
306
 
@@ -280,6 +329,7 @@ class ProjectIndex {
280
329
  mtime: stat.mtimeMs,
281
330
  size: stat.size,
282
331
  imports: imports.map(i => i.module),
332
+ importNames: imports.flatMap(i => i.names || []),
283
333
  exports: exports.map(e => e.name),
284
334
  exportDetails: exports,
285
335
  symbols: [],
@@ -313,6 +363,7 @@ class ProjectIndex {
313
363
  ...(item.receiver && { receiver: item.receiver }),
314
364
  ...(item.className && { className: item.className }),
315
365
  ...(item.memberType && { memberType: item.memberType }),
366
+ ...(item.fieldType && { fieldType: item.fieldType }),
316
367
  ...(item.decorators && item.decorators.length > 0 && { decorators: item.decorators }),
317
368
  ...(item.nameLine && { nameLine: item.nameLine })
318
369
  };
@@ -331,6 +382,12 @@ class ProjectIndex {
331
382
  };
332
383
 
333
384
  for (const fn of parsed.functions) {
385
+ // Go/Rust methods: set className from receiver for consistent method resolution.
386
+ // Go/Rust methods are standalone functions with receiver, not class members,
387
+ // so className is never set by the class member loop below.
388
+ if (fn.receiver && !fn.className) {
389
+ fn.className = fn.receiver.replace(/^\*/, '');
390
+ }
334
391
  addSymbol(fn, fn.isConstructor ? 'constructor' : 'function');
335
392
  }
336
393
 
@@ -349,6 +406,7 @@ class ProjectIndex {
349
406
  }
350
407
 
351
408
  this.files.set(filePath, fileEntry);
409
+ return true;
352
410
  }
353
411
 
354
412
  /**
@@ -373,16 +431,69 @@ class ProjectIndex {
373
431
  // Invalidate cached call data for this file
374
432
  this.callsCache.delete(filePath);
375
433
 
434
+ // Invalidate callee index (will be rebuilt lazily)
435
+ this.calleeIndex = null;
436
+
376
437
  // Invalidate attribute type cache for this file
377
438
  if (this._attrTypeCache) this._attrTypeCache.delete(filePath);
378
439
  }
379
440
 
441
+ /**
442
+ * Build inverted call index: callee name -> Set<filePath>.
443
+ * Built lazily on first findCallers call, from the calls cache.
444
+ * Enables O(relevant files) lookup instead of O(all files) scan.
445
+ */
446
+ buildCalleeIndex() {
447
+ const { getCachedCalls } = require('./callers');
448
+ this.calleeIndex = new Map();
449
+
450
+ for (const [filePath] of this.files) {
451
+ const calls = getCachedCalls(this, filePath);
452
+ if (!calls) continue;
453
+ for (const call of calls) {
454
+ const name = call.name;
455
+ if (!this.calleeIndex.has(name)) {
456
+ this.calleeIndex.set(name, new Set());
457
+ }
458
+ this.calleeIndex.get(name).add(filePath);
459
+ // Also index resolvedName and resolvedNames for alias resolution
460
+ if (call.resolvedName && call.resolvedName !== name) {
461
+ if (!this.calleeIndex.has(call.resolvedName)) {
462
+ this.calleeIndex.set(call.resolvedName, new Set());
463
+ }
464
+ this.calleeIndex.get(call.resolvedName).add(filePath);
465
+ }
466
+ if (call.resolvedNames) {
467
+ for (const rn of call.resolvedNames) {
468
+ if (rn !== name) {
469
+ if (!this.calleeIndex.has(rn)) {
470
+ this.calleeIndex.set(rn, new Set());
471
+ }
472
+ this.calleeIndex.get(rn).add(filePath);
473
+ }
474
+ }
475
+ }
476
+ }
477
+ }
478
+ }
479
+
480
+ /**
481
+ * Get the set of files that contain calls to a given name.
482
+ * Returns null if callee index is not available (falls back to full scan).
483
+ */
484
+ getCalleeFiles(name) {
485
+ if (!this.calleeIndex) {
486
+ this.buildCalleeIndex();
487
+ }
488
+ return this.calleeIndex.get(name) || null;
489
+ }
490
+
380
491
  /**
381
492
  * Resolve a Java package import to a project file.
382
493
  * Handles regular imports, static imports (strips member name), and wildcards (strips .*).
383
494
  * Progressively strips trailing segments to find the class file.
384
495
  */
385
- _resolveJavaPackageImport(importModule) {
496
+ _resolveJavaPackageImport(importModule, javaFileIndex) {
386
497
  const isWildcard = importModule.endsWith('.*');
387
498
  // Strip wildcard suffix (e.g., "com.pkg.Class.*" -> "com.pkg.Class")
388
499
  const mod = isWildcard ? importModule.slice(0, -2) : importModule;
@@ -390,11 +501,28 @@ class ProjectIndex {
390
501
 
391
502
  // Try progressively shorter paths: full path, then strip last segment, etc.
392
503
  // This handles static imports where path includes member name after class
393
- for (let i = segments.length; i > 0; i--) {
394
- const fileSuffix = '/' + segments.slice(0, i).join('/') + '.java';
395
- for (const absPath of this.files.keys()) {
396
- if (absPath.endsWith(fileSuffix)) {
397
- return absPath;
504
+ if (javaFileIndex) {
505
+ // Fast path: use pre-built filename→files index (O(candidates) vs O(all files))
506
+ for (let i = segments.length; i > 0; i--) {
507
+ const className = segments[i - 1];
508
+ const candidates = javaFileIndex.get(className);
509
+ if (candidates) {
510
+ const fileSuffix = '/' + segments.slice(0, i).join('/') + '.java';
511
+ for (const absPath of candidates) {
512
+ if (absPath.endsWith(fileSuffix)) {
513
+ return absPath;
514
+ }
515
+ }
516
+ }
517
+ }
518
+ } else {
519
+ // Fallback: scan all files (used by imports() method outside buildImportGraph)
520
+ for (let i = segments.length; i > 0; i--) {
521
+ const fileSuffix = '/' + segments.slice(0, i).join('/') + '.java';
522
+ for (const absPath of this.files.keys()) {
523
+ if (absPath.endsWith(fileSuffix)) {
524
+ return absPath;
525
+ }
398
526
  }
399
527
  }
400
528
  }
@@ -420,6 +548,22 @@ class ProjectIndex {
420
548
  this.importGraph.clear();
421
549
  this.exportGraph.clear();
422
550
 
551
+ // Pre-build directory→files map for Go package linking (O(1) lookup vs O(n) scan)
552
+ const dirToGoFiles = new Map();
553
+ // Pre-build filename→files map for Java import resolution (O(1) vs O(n) scan)
554
+ const javaFileIndex = new Map();
555
+ for (const [fp, fe] of this.files) {
556
+ if (fe.language === 'go') {
557
+ const dir = path.dirname(fp);
558
+ if (!dirToGoFiles.has(dir)) dirToGoFiles.set(dir, []);
559
+ dirToGoFiles.get(dir).push(fp);
560
+ } else if (fe.language === 'java') {
561
+ const name = path.basename(fp, '.java');
562
+ if (!javaFileIndex.has(name)) javaFileIndex.set(name, []);
563
+ javaFileIndex.get(name).push(fp);
564
+ }
565
+ }
566
+
423
567
  for (const [filePath, fileEntry] of this.files) {
424
568
  const importedFiles = [];
425
569
  const seenModules = new Set();
@@ -442,16 +586,32 @@ class ProjectIndex {
442
586
  // Java package imports: resolve by progressive suffix matching
443
587
  // Handles regular, static (com.pkg.Class.method), and wildcard (com.pkg.Class.*) imports
444
588
  if (!resolved && fileEntry.language === 'java' && !importModule.startsWith('.')) {
445
- resolved = this._resolveJavaPackageImport(importModule);
589
+ resolved = this._resolveJavaPackageImport(importModule, javaFileIndex);
446
590
  }
447
591
 
448
592
  if (resolved && this.files.has(resolved)) {
449
- importedFiles.push(resolved);
593
+ // For Go, a package import means all files in that directory are dependencies
594
+ // (Go packages span multiple files in the same directory)
595
+ const filesToLink = [resolved];
596
+ if (fileEntry.language === 'go') {
597
+ const pkgDir = path.dirname(resolved);
598
+ const dirFiles = dirToGoFiles.get(pkgDir) || [];
599
+ const importerIsTest = filePath.endsWith('_test.go');
600
+ for (const fp of dirFiles) {
601
+ if (fp !== resolved) {
602
+ if (!importerIsTest && fp.endsWith('_test.go')) continue;
603
+ filesToLink.push(fp);
604
+ }
605
+ }
606
+ }
450
607
 
451
- if (!this.exportGraph.has(resolved)) {
452
- this.exportGraph.set(resolved, []);
608
+ for (const linkedFile of filesToLink) {
609
+ importedFiles.push(linkedFile);
610
+ if (!this.exportGraph.has(linkedFile)) {
611
+ this.exportGraph.set(linkedFile, []);
612
+ }
613
+ this.exportGraph.get(linkedFile).push(filePath);
453
614
  }
454
- this.exportGraph.get(resolved).push(filePath);
455
615
  }
456
616
  }
457
617
 
@@ -470,7 +630,7 @@ class ProjectIndex {
470
630
  const classNames = new Set();
471
631
  for (const [, fileEntry] of this.files) {
472
632
  for (const symbol of fileEntry.symbols) {
473
- if (['class', 'interface', 'struct', 'trait'].includes(symbol.type)) {
633
+ if (['class', 'interface', 'struct', 'trait', 'record'].includes(symbol.type)) {
474
634
  classNames.add(symbol.name);
475
635
  }
476
636
  }
@@ -478,7 +638,7 @@ class ProjectIndex {
478
638
 
479
639
  for (const [filePath, fileEntry] of this.files) {
480
640
  for (const symbol of fileEntry.symbols) {
481
- if (!['class', 'interface', 'struct', 'trait'].includes(symbol.type)) {
641
+ if (!['class', 'interface', 'struct', 'trait', 'record'].includes(symbol.type)) {
482
642
  continue;
483
643
  }
484
644
 
@@ -754,14 +914,39 @@ class ProjectIndex {
754
914
  // Sort by score descending, then by index order for stability
755
915
  scored.sort((a, b) => b.score - a.score);
756
916
 
757
- // Tiebreaker: when top candidates have equal score, prefer by usage count
917
+ // Tiebreaker: when top candidates have equal score, prefer by import popularity
918
+ // (how many files import the candidate's file), then by usage count
758
919
  if (scored.length > 1 && scored[0].score === scored[1].score) {
759
920
  const tiedScore = scored[0].score;
760
921
  const tiedCandidates = scored.filter(s => s.score === tiedScore);
922
+
923
+ // Count how many files import each candidate's file (import popularity)
924
+ // For Go, count importers of any file in the same directory (same package)
761
925
  for (const candidate of tiedCandidates) {
762
- candidate.usageCount = this.countSymbolUsages(candidate.def).total;
926
+ let importerCount = 0;
927
+ for (const [, importedFiles] of this.importGraph) {
928
+ if (importedFiles.includes(candidate.def.file)) {
929
+ importerCount++;
930
+ }
931
+ }
932
+ // For Go, also count importers of sibling files (same package)
933
+ const candidateEntry = this.files.get(candidate.def.file);
934
+ if (candidateEntry?.language === 'go') {
935
+ const candidateDir = path.dirname(candidate.def.file);
936
+ for (const [, importedFiles] of this.importGraph) {
937
+ for (const imp of importedFiles) {
938
+ if (imp !== candidate.def.file && path.dirname(imp) === candidateDir) {
939
+ importerCount++;
940
+ break; // count each importer once
941
+ }
942
+ }
943
+ }
944
+ }
945
+ candidate.importerCount = importerCount;
763
946
  }
764
- tiedCandidates.sort((a, b) => b.usageCount - a.usageCount);
947
+ // Sort by import popularity (cheap no file reads needed)
948
+ // Skip usage count (expensive) — import popularity is a strong enough signal
949
+ tiedCandidates.sort((a, b) => b.importerCount - a.importerCount);
765
950
  // Rebuild scored array: sorted tied candidates first, then rest
766
951
  const rest = scored.filter(s => s.score !== tiedScore);
767
952
  scored.length = 0;
@@ -892,9 +1077,68 @@ class ProjectIndex {
892
1077
  * @param {object} symbol - Symbol with file, name, etc.
893
1078
  * @returns {object} { total, calls, definitions, imports, references }
894
1079
  */
895
- countSymbolUsages(symbol) {
1080
+ countSymbolUsages(symbol, options = {}) {
896
1081
  const name = symbol.name;
897
1082
  const defFile = symbol.file;
1083
+
1084
+ // Fast path: use callee index + import graph for counting (no file reads)
1085
+ // This is an approximation — counts files containing calls, not individual call sites.
1086
+ // Use options.detailed = true for exact per-call-site counting via AST.
1087
+ if (!options.detailed) {
1088
+ // Ensure callee index is built (lazy, reused across operations)
1089
+ if (!this.calleeIndex) this.buildCalleeIndex();
1090
+ const hasFilters = options.exclude && options.exclude.length > 0;
1091
+
1092
+ // Count calls from callee index (files containing calls to this name)
1093
+ const calleeFiles = this.calleeIndex.get(name);
1094
+ let calls = 0;
1095
+ if (calleeFiles) {
1096
+ // Count actual call entries from calls cache for accuracy
1097
+ const { getCachedCalls } = require('./callers');
1098
+ for (const fp of calleeFiles) {
1099
+ // Apply exclude filters
1100
+ if (hasFilters) {
1101
+ const fe = this.files.get(fp);
1102
+ if (fe && !this.matchesFilters(fe.relativePath, { exclude: options.exclude })) continue;
1103
+ }
1104
+ const fileCalls = getCachedCalls(this, fp);
1105
+ if (!fileCalls) continue;
1106
+ for (const c of fileCalls) {
1107
+ if (c.name === name || c.resolvedName === name ||
1108
+ (c.resolvedNames && c.resolvedNames.includes(name))) {
1109
+ calls++;
1110
+ }
1111
+ }
1112
+ }
1113
+ }
1114
+
1115
+ // Count definitions from symbol table
1116
+ const defs = this.symbols.get(name) || [];
1117
+ let definitions = defs.length;
1118
+ if (hasFilters) {
1119
+ definitions = defs.filter(d =>
1120
+ this.matchesFilters(d.relativePath, { exclude: options.exclude })
1121
+ ).length;
1122
+ }
1123
+
1124
+ // Count imports from import graph (files that import from defFile and use this name)
1125
+ let imports = 0;
1126
+ const importers = this.exportGraph.get(defFile) || [];
1127
+ for (const importer of importers) {
1128
+ const fe = this.files.get(importer);
1129
+ if (!fe) continue;
1130
+ if (hasFilters && !this.matchesFilters(fe.relativePath, { exclude: options.exclude })) continue;
1131
+ // Check if this file's importNames reference our symbol
1132
+ if (fe.importNames && fe.importNames.includes(name)) {
1133
+ imports++;
1134
+ }
1135
+ }
1136
+
1137
+ const total = calls + definitions + imports;
1138
+ return { total, calls, definitions, imports, references: 0 };
1139
+ }
1140
+
1141
+ // Detailed path: full AST-based counting (original algorithm)
898
1142
  // Note: no 'g' flag - we only need to test for presence per line
899
1143
  const regex = new RegExp('\\b' + escapeRegExp(name) + '\\b');
900
1144
 
@@ -902,12 +1146,25 @@ class ProjectIndex {
902
1146
  // 1. The file where it's defined
903
1147
  // 2. Files that import from the definition file
904
1148
  // 3. Transitively: files that import from re-exporters of this symbol
1149
+ // 4. Go: all files in the same package directory (same-package references need no import)
905
1150
  const relevantFiles = new Set([defFile]);
906
1151
  const queue = [defFile];
1152
+
1153
+ // Go same-package: add all .go files in the same directory
1154
+ const defEntry = this.files.get(defFile);
1155
+ if (defEntry?.language === 'go') {
1156
+ const pkgDir = path.dirname(defFile);
1157
+ for (const fp of this.files.keys()) {
1158
+ if (fp !== defFile && fp.endsWith('.go') && path.dirname(fp) === pkgDir) {
1159
+ relevantFiles.add(fp);
1160
+ }
1161
+ }
1162
+ }
1163
+
907
1164
  while (queue.length > 0) {
908
1165
  const file = queue.pop();
909
- const importers = this.exportGraph.get(file) || [];
910
- for (const importer of importers) {
1166
+ const importersArr = this.exportGraph.get(file) || [];
1167
+ for (const importer of importersArr) {
911
1168
  if (!relevantFiles.has(importer)) {
912
1169
  relevantFiles.add(importer);
913
1170
  // If this importer re-exports the symbol, follow its importers too
@@ -939,8 +1196,12 @@ class ProjectIndex {
939
1196
  let imports = 0;
940
1197
  let references = 0;
941
1198
 
1199
+ const hasExclude = options.exclude && options.exclude.length > 0;
942
1200
  for (const filePath of relevantFiles) {
943
- if (!this.files.has(filePath)) continue;
1201
+ const fileEntry = this.files.get(filePath);
1202
+ if (!fileEntry) continue;
1203
+ // Apply exclude filters (e.g., test file exclusion)
1204
+ if (hasExclude && !this.matchesFilters(fileEntry.relativePath, { exclude: options.exclude })) continue;
944
1205
 
945
1206
  try {
946
1207
  // Try AST-based counting first (with per-operation cache)
@@ -1059,7 +1320,7 @@ class ProjectIndex {
1059
1320
  // Filters: namespace access to external packages (DropdownMenuPrimitive.Separator).
1060
1321
  if (u.receiver && !['self', 'this', 'cls', 'super'].includes(u.receiver) &&
1061
1322
  fileEntry.language !== 'go' && fileEntry.language !== 'java' && fileEntry.language !== 'rust') {
1062
- const hasMethodDef = allDefinitions.some(d => d.className);
1323
+ const hasMethodDef = definitions.some(d => d.className);
1063
1324
  if (!hasMethodDef && !importedFileHasDef()) {
1064
1325
  continue;
1065
1326
  }
@@ -1294,6 +1555,7 @@ class ProjectIndex {
1294
1555
  dynamicImports,
1295
1556
  uncertain: stats.uncertain,
1296
1557
  includeMethods: !!options.includeMethods,
1558
+ projectLanguage: this._getPredominantLanguage(),
1297
1559
  // Structural facts for reliability hints
1298
1560
  ...(def.isMethod && { isMethod: true }),
1299
1561
  ...(def.className && { className: def.className }),
@@ -1436,7 +1698,8 @@ class ProjectIndex {
1436
1698
  complete: stats.uncertain === 0 && dynamicImports === 0,
1437
1699
  skipped: 0,
1438
1700
  dynamicImports,
1439
- uncertain: stats.uncertain
1701
+ uncertain: stats.uncertain,
1702
+ projectLanguage: this._getPredominantLanguage()
1440
1703
  }
1441
1704
  };
1442
1705
  } finally { this._endOp(); }
@@ -1446,6 +1709,27 @@ class ProjectIndex {
1446
1709
  // HELPER METHODS
1447
1710
  // ========================================================================
1448
1711
 
1712
+ /**
1713
+ * Get the predominant language of the project (cached).
1714
+ * Returns 'go', 'javascript', etc. if >80% of files are that language.
1715
+ */
1716
+ _getPredominantLanguage() {
1717
+ if (this._predominantLang !== undefined) return this._predominantLang;
1718
+ const counts = {};
1719
+ for (const [, fe] of this.files) {
1720
+ counts[fe.language] = (counts[fe.language] || 0) + 1;
1721
+ }
1722
+ const total = this.files.size;
1723
+ for (const [lang, count] of Object.entries(counts)) {
1724
+ if (count / total > 0.8) {
1725
+ this._predominantLang = lang;
1726
+ return lang;
1727
+ }
1728
+ }
1729
+ this._predominantLang = null;
1730
+ return null;
1731
+ }
1732
+
1449
1733
  /**
1450
1734
  * Get line content from a file
1451
1735
  */
@@ -2054,11 +2338,23 @@ class ProjectIndex {
2054
2338
 
2055
2339
  let fileIterator;
2056
2340
  if (filePath) {
2341
+ // Try exact resolution first
2057
2342
  const resolved = this.resolveFilePathForQuery(filePath);
2058
- if (typeof resolved !== 'string') return resolved;
2059
- const fileEntry = this.files.get(resolved);
2060
- if (!fileEntry) return { error: 'file-not-found', filePath };
2061
- fileIterator = [[resolved, fileEntry]];
2343
+ if (typeof resolved === 'string') {
2344
+ const fileEntry = this.files.get(resolved);
2345
+ if (!fileEntry) return { error: 'file-not-found', filePath };
2346
+ fileIterator = [[resolved, fileEntry]];
2347
+ } else {
2348
+ // Fall back to pattern filter (substring match on relative path)
2349
+ const matches = [];
2350
+ for (const [absPath, fe] of this.files) {
2351
+ if (fe.relativePath.includes(filePath)) {
2352
+ matches.push([absPath, fe]);
2353
+ }
2354
+ }
2355
+ if (matches.length === 0) return { error: 'file-not-found', filePath };
2356
+ fileIterator = matches;
2357
+ }
2062
2358
  } else {
2063
2359
  fileIterator = this.files.entries();
2064
2360
  }
@@ -2376,6 +2672,97 @@ class ProjectIndex {
2376
2672
  };
2377
2673
  }
2378
2674
 
2675
+ /**
2676
+ * Detect circular dependencies in the import graph.
2677
+ * Uses DFS with 3-color marking to find all cycles.
2678
+ * @param {object} options - { file, exclude }
2679
+ * @returns {object} - { cycles, totalFiles, summary }
2680
+ */
2681
+ circularDeps(options = {}) {
2682
+ this._beginOp();
2683
+ try {
2684
+ const exclude = options.exclude || [];
2685
+ const fileFilter = options.file || null;
2686
+
2687
+ const WHITE = 0, GRAY = 1, BLACK = 2;
2688
+ const color = new Map();
2689
+ const cycles = [];
2690
+ const stack = [];
2691
+
2692
+ const shouldSkip = (file) => {
2693
+ if (!this.files.has(file)) return true;
2694
+ if (exclude.length > 0) {
2695
+ const entry = this.files.get(file);
2696
+ if (entry && !this.matchesFilters(entry.relativePath, { exclude })) return true;
2697
+ }
2698
+ return false;
2699
+ };
2700
+
2701
+ const dfs = (file) => {
2702
+ color.set(file, GRAY);
2703
+ stack.push(file);
2704
+
2705
+ const neighbors = [...new Set(this.importGraph.get(file) || [])];
2706
+
2707
+ for (const neighbor of neighbors) {
2708
+ if (shouldSkip(neighbor)) continue;
2709
+ const nc = color.get(neighbor) || WHITE;
2710
+ if (nc === GRAY) {
2711
+ const idx = stack.indexOf(neighbor);
2712
+ cycles.push(stack.slice(idx));
2713
+ } else if (nc === WHITE) {
2714
+ dfs(neighbor);
2715
+ }
2716
+ }
2717
+
2718
+ stack.pop();
2719
+ color.set(file, BLACK);
2720
+ };
2721
+
2722
+ for (const file of this.files.keys()) {
2723
+ if ((color.get(file) || WHITE) === WHITE && !shouldSkip(file)) {
2724
+ dfs(file);
2725
+ }
2726
+ }
2727
+
2728
+ // Convert to relative paths and deduplicate
2729
+ const seen = new Set();
2730
+ const uniqueCycles = [];
2731
+ for (const cycle of cycles) {
2732
+ const relCycle = cycle.map(f => this.files.get(f)?.relativePath || path.relative(this.root, f));
2733
+ // Normalize: rotate so lexicographically smallest file is first
2734
+ const sorted = relCycle.slice().sort();
2735
+ const minIdx = relCycle.indexOf(sorted[0]);
2736
+ const rotated = [...relCycle.slice(minIdx), ...relCycle.slice(0, minIdx)];
2737
+ const key = rotated.join('\0');
2738
+ if (!seen.has(key)) {
2739
+ seen.add(key);
2740
+ uniqueCycles.push({ files: rotated, length: rotated.length });
2741
+ }
2742
+ }
2743
+
2744
+ // Filter by file pattern
2745
+ let result = uniqueCycles;
2746
+ if (fileFilter) {
2747
+ result = uniqueCycles.filter(c => c.files.some(f => f.includes(fileFilter)));
2748
+ }
2749
+
2750
+ result.sort((a, b) => a.length - b.length || a.files[0].localeCompare(b.files[0]));
2751
+
2752
+ return {
2753
+ cycles: result,
2754
+ totalFiles: this.files.size,
2755
+ fileFilter: fileFilter || undefined,
2756
+ summary: {
2757
+ totalCycles: result.length,
2758
+ filesInCycles: new Set(result.flatMap(c => c.files)).size,
2759
+ }
2760
+ };
2761
+ } finally {
2762
+ this._endOp();
2763
+ }
2764
+ }
2765
+
2379
2766
  /**
2380
2767
  * Detect patterns that may cause incomplete results
2381
2768
  * Returns warnings about dynamic code patterns
@@ -2392,6 +2779,8 @@ class ProjectIndex {
2392
2779
  let evalUsage = 0;
2393
2780
  let reflectionUsage = 0;
2394
2781
 
2782
+ const predominantLang = this._getPredominantLanguage();
2783
+
2395
2784
  for (const [filePath, fileEntry] of this.files) {
2396
2785
  // Skip node_modules - we don't care about their patterns
2397
2786
  if (filePath.includes('node_modules')) continue;
@@ -2399,14 +2788,16 @@ class ProjectIndex {
2399
2788
  try {
2400
2789
  const content = this._readFile(filePath);
2401
2790
 
2402
- // Dynamic imports: import(), require(variable), __import__
2403
- dynamicImports += (content.match(/import\s*\([^'"]/g) || []).length;
2404
- dynamicImports += (content.match(/require\s*\([^'"]/g) || []).length;
2405
- dynamicImports += (content.match(/__import__\s*\(/g) || []).length;
2791
+ if (fileEntry.language !== 'go') {
2792
+ // Dynamic imports: import(), require(variable), __import__
2793
+ dynamicImports += (content.match(/import\s*\([^'"]/g) || []).length;
2794
+ dynamicImports += (content.match(/require\s*\([^'"]/g) || []).length;
2795
+ dynamicImports += (content.match(/__import__\s*\(/g) || []).length;
2406
2796
 
2407
- // eval, Function constructor
2408
- evalUsage += (content.match(/(^|[^a-zA-Z_])eval\s*\(/gm) || []).length;
2409
- evalUsage += (content.match(/new\s+Function\s*\(/g) || []).length;
2797
+ // eval, Function constructor
2798
+ evalUsage += (content.match(/(^|[^a-zA-Z_])eval\s*\(/gm) || []).length;
2799
+ evalUsage += (content.match(/new\s+Function\s*\(/g) || []).length;
2800
+ }
2410
2801
 
2411
2802
  // Reflection: getattr, hasattr, Reflect
2412
2803
  reflectionUsage += (content.match(/\bgetattr\s*\(/g) || []).length;
@@ -2727,7 +3118,7 @@ class ProjectIndex {
2727
3118
  if (!def) {
2728
3119
  return null;
2729
3120
  }
2730
- const defIsMethod = def.isMethod || def.type === 'method' || def.className;
3121
+ const defIsMethod = def.isMethod || def.type === 'method' || def.className || def.receiver;
2731
3122
 
2732
3123
  // Use findCallers for className-scoped or method queries (sophisticated binding resolution)
2733
3124
  // Fall back to usages-based approach for simple function queries (backward compatible)
@@ -2740,10 +3131,11 @@ class ProjectIndex {
2740
3131
  targetDefinitions: [def],
2741
3132
  });
2742
3133
 
2743
- // When className is explicitly provided, filter out method calls whose
2744
- // receiver clearly belongs to a different type. This helps with common
2745
- // method names like .close(), .get() etc. where many objects have the same method.
2746
- if (options.className && def.className) {
3134
+ // When the target definition has a className (including Go/Rust methods which
3135
+ // now get className from receiver), filter out method calls whose receiver
3136
+ // clearly belongs to a different type. This helps with common method names
3137
+ // like .close(), .get() etc. where many types have the same method.
3138
+ if (def.className) {
2747
3139
  const targetClassName = def.className;
2748
3140
  callerResults = callerResults.filter(c => {
2749
3141
  // Keep non-method calls and self/this/cls calls (already resolved by findCallers)
@@ -2804,20 +3196,22 @@ class ProjectIndex {
2804
3196
  }
2805
3197
  }
2806
3198
  }
2807
- // Unique method heuristic: if the called method exists on exactly one class
3199
+ // Unique method heuristic: if the called method exists on exactly one class/type
2808
3200
  // and it matches the target, include the call (no other class could match)
2809
3201
  const methodDefs = this.symbols.get(name);
2810
3202
  if (methodDefs) {
2811
3203
  const classNames = new Set();
2812
3204
  for (const d of methodDefs) {
2813
3205
  if (d.className) classNames.add(d.className);
3206
+ // Go/Rust: use receiver type as className equivalent
3207
+ else if (d.receiver) classNames.add(d.receiver.replace(/^\*/, ''));
2814
3208
  }
2815
3209
  if (classNames.size === 1 && classNames.has(targetClassName)) {
2816
3210
  return true;
2817
3211
  }
2818
3212
  }
2819
- // className explicitly set but receiver type unknown — filter it out.
2820
- // User asked for a specific class; unknown receivers are likely unrelated.
3213
+ // Type-scoped query but receiver type unknown — filter it out.
3214
+ // Unknown receivers are likely unrelated.
2821
3215
  return false;
2822
3216
  });
2823
3217
  }
@@ -2838,53 +3232,74 @@ class ProjectIndex {
2838
3232
  }
2839
3233
  this._clearTreeCache();
2840
3234
  } else {
2841
- const usages = this.usages(name, { codeOnly: true });
3235
+ // Use findCallers (benefits from callee index) instead of usages() for speed
3236
+ const callerResults = this.findCallers(name, {
3237
+ includeMethods: false,
3238
+ includeUncertain: false,
3239
+ targetDefinitions: [def],
3240
+ });
2842
3241
  const targetBindingId = def.bindingId;
2843
- const calls = usages.filter(u => {
2844
- if (u.usageType !== 'call' || u.isDefinition) return false;
3242
+ // Convert findCallers results to the format expected by analyzeCallSite
3243
+ const calls = callerResults.map(c => ({
3244
+ file: c.file,
3245
+ relativePath: c.relativePath,
3246
+ line: c.line,
3247
+ content: c.content,
3248
+ usageType: 'call',
3249
+ callerName: c.callerName,
3250
+ }));
3251
+ // Keep the same binding filter for backward compat (findCallers already handles this,
3252
+ // but cross-check with usages-based binding filter for safety)
3253
+ const filteredCalls = calls.filter(u => {
2845
3254
  const fileEntry = this.files.get(u.file);
2846
- if (fileEntry) {
2847
- // Filter by binding: skip calls from files that define their own version of this function
2848
- // For Go, also check sibling files in same directory (same package scope)
2849
- if (targetBindingId) {
2850
- let localBindings = (fileEntry.bindings || []).filter(b => b.name === name);
2851
- if (localBindings.length === 0 && fileEntry.language === 'go') {
2852
- const dir = path.dirname(u.file);
2853
- for (const [fp, fe] of this.files) {
2854
- if (fp !== u.file && path.dirname(fp) === dir) {
2855
- const sibling = (fe.bindings || []).filter(b => b.name === name);
2856
- localBindings = localBindings.concat(sibling);
2857
- }
3255
+ if (fileEntry && targetBindingId) {
3256
+ let localBindings = (fileEntry.bindings || []).filter(b => b.name === name);
3257
+ if (localBindings.length === 0 && fileEntry.language === 'go') {
3258
+ const dir = path.dirname(u.file);
3259
+ for (const [fp, fe] of this.files) {
3260
+ if (fp !== u.file && path.dirname(fp) === dir) {
3261
+ const sibling = (fe.bindings || []).filter(b => b.name === name);
3262
+ localBindings = localBindings.concat(sibling);
2858
3263
  }
2859
3264
  }
2860
- if (localBindings.length > 0 && !localBindings.some(b => b.id === targetBindingId)) {
2861
- return false; // This file/package has its own definition — call is to that, not our target
2862
- }
2863
3265
  }
2864
- // Cross-reference with findCallsInCode to filter local closures and built-ins
2865
- // (findCallsInCode has scope-aware filtering that findUsagesInCode lacks)
2866
- const parsedCalls = this.getCachedCalls(u.file);
2867
- if (parsedCalls && Array.isArray(parsedCalls)) {
2868
- const hasCall = parsedCalls.some(c => c.name === name && c.line === u.line);
2869
- if (!hasCall) return false;
3266
+ if (localBindings.length > 0 && !localBindings.some(b => b.id === targetBindingId)) {
3267
+ return false;
2870
3268
  }
2871
3269
  }
2872
3270
  return true;
2873
3271
  });
3272
+ // (findCallers already handles binding resolution and scope-aware filtering)
2874
3273
 
2875
3274
  // Analyze each call site, filtering out method calls for non-method definitions
2876
3275
  callSites = [];
2877
- for (const call of calls) {
3276
+ const defFileEntry = this.files.get(def.file);
3277
+ const defLang = defFileEntry?.language;
3278
+ const targetDir = defLang === 'go' ? path.basename(path.dirname(def.file)) : null;
3279
+ for (const call of filteredCalls) {
2878
3280
  const analysis = this.analyzeCallSite(call, name);
2879
3281
  // Skip method calls (obj.parse()) when target is a standalone function (parse())
3282
+ // For Go, allow calls where receiver matches the package directory name
3283
+ // (e.g., controller.FilterActive() where file is in pkg/controller/)
2880
3284
  if (analysis.isMethodCall && !defIsMethod) {
2881
- continue;
3285
+ if (targetDir) {
3286
+ // Get receiver from parsed calls cache
3287
+ const parsedCalls = this.getCachedCalls(call.file);
3288
+ const matchedCall = parsedCalls?.find(c => c.name === name && c.line === call.line);
3289
+ if (matchedCall?.receiver === targetDir) {
3290
+ // Receiver matches package directory — keep it
3291
+ } else {
3292
+ continue;
3293
+ }
3294
+ } else {
3295
+ continue;
3296
+ }
2882
3297
  }
2883
3298
  callSites.push({
2884
3299
  file: call.relativePath,
2885
3300
  line: call.line,
2886
3301
  expression: call.content.trim(),
2887
- callerName: this.findEnclosingFunction(call.file, call.line),
3302
+ callerName: call.callerName || this.findEnclosingFunction(call.file, call.line),
2888
3303
  ...analysis
2889
3304
  });
2890
3305
  }
@@ -2953,6 +3368,450 @@ class ProjectIndex {
2953
3368
  } finally { this._endOp(); }
2954
3369
  }
2955
3370
 
3371
+ /**
3372
+ * Transitive blast radius — walk UP the caller chain recursively.
3373
+ * Answers: "What breaks transitively if I change this function?"
3374
+ *
3375
+ * @param {string} name - Function name
3376
+ * @param {object} options - { depth, file, className, all, exclude, includeMethods, includeUncertain }
3377
+ * @returns {object|null} Blast radius tree with summary
3378
+ */
3379
+ blast(name, options = {}) {
3380
+ this._beginOp();
3381
+ try {
3382
+ const maxDepth = Math.max(0, options.depth ?? 3);
3383
+ const maxChildren = options.all ? Infinity : 10;
3384
+ const includeMethods = options.includeMethods ?? true;
3385
+ const includeUncertain = options.includeUncertain || false;
3386
+ const exclude = options.exclude || [];
3387
+
3388
+ const { def, definitions, warnings } = this.resolveSymbol(name, { file: options.file, className: options.className });
3389
+ if (!def) return null;
3390
+
3391
+ const visited = new Set();
3392
+ const affectedFunctions = new Set();
3393
+ const affectedFiles = new Set();
3394
+ let maxDepthReached = 0;
3395
+
3396
+ const buildCallerTree = (funcDef, currentDepth) => {
3397
+ const key = `${funcDef.file}:${funcDef.startLine}`;
3398
+ if (currentDepth > maxDepth) return null;
3399
+ if (visited.has(key)) {
3400
+ return {
3401
+ name: funcDef.name,
3402
+ file: funcDef.relativePath,
3403
+ line: funcDef.startLine,
3404
+ type: funcDef.type || 'function',
3405
+ children: [],
3406
+ alreadyShown: true
3407
+ };
3408
+ }
3409
+ visited.add(key);
3410
+
3411
+ if (currentDepth > maxDepthReached) maxDepthReached = currentDepth;
3412
+ if (currentDepth > 0) {
3413
+ affectedFunctions.add(key);
3414
+ affectedFiles.add(funcDef.file);
3415
+ }
3416
+
3417
+ const node = {
3418
+ name: funcDef.name,
3419
+ file: funcDef.relativePath,
3420
+ line: funcDef.startLine,
3421
+ type: funcDef.type || 'function',
3422
+ children: []
3423
+ };
3424
+
3425
+ if (currentDepth < maxDepth) {
3426
+ const callers = this.findCallers(funcDef.name, {
3427
+ includeMethods,
3428
+ includeUncertain,
3429
+ targetDefinitions: funcDef.bindingId ? [funcDef] : undefined,
3430
+ });
3431
+
3432
+ // Deduplicate callers by enclosing function (multiple call sites → one tree node)
3433
+ const uniqueCallers = new Map();
3434
+ for (const c of callers) {
3435
+ if (!c.callerName) continue; // skip module-level code
3436
+ // Apply exclude filter
3437
+ if (exclude.length > 0 && !this.matchesFilters(c.relativePath, { exclude })) continue;
3438
+ const callerKey = c.callerStartLine
3439
+ ? `${c.callerFile}:${c.callerStartLine}`
3440
+ : `${c.callerFile}:${c.callerName}`;
3441
+ if (!uniqueCallers.has(callerKey)) {
3442
+ uniqueCallers.set(callerKey, {
3443
+ name: c.callerName,
3444
+ file: c.callerFile,
3445
+ relativePath: c.relativePath,
3446
+ startLine: c.callerStartLine,
3447
+ endLine: c.callerEndLine,
3448
+ callSites: 1
3449
+ });
3450
+ } else {
3451
+ uniqueCallers.get(callerKey).callSites++;
3452
+ }
3453
+ }
3454
+
3455
+ // Resolve definitions and build child nodes
3456
+ const callerEntries = [];
3457
+ for (const [, caller] of uniqueCallers) {
3458
+ // Look up actual definition from symbol table
3459
+ const defs = this.symbols.get(caller.name);
3460
+ let callerDef = defs?.find(d => d.file === caller.file && d.startLine === caller.startLine);
3461
+
3462
+ if (!callerDef) {
3463
+ // Pseudo-definition for callers not in symbol table
3464
+ callerDef = {
3465
+ name: caller.name,
3466
+ file: caller.file,
3467
+ relativePath: caller.relativePath,
3468
+ startLine: caller.startLine,
3469
+ endLine: caller.endLine,
3470
+ type: 'function'
3471
+ };
3472
+ }
3473
+
3474
+ callerEntries.push({ def: callerDef, callSites: caller.callSites });
3475
+ }
3476
+
3477
+ // Stable sort by file + line
3478
+ callerEntries.sort((a, b) =>
3479
+ a.def.file.localeCompare(b.def.file) || a.def.startLine - b.def.startLine
3480
+ );
3481
+
3482
+ for (const { def: cDef, callSites } of callerEntries.slice(0, maxChildren)) {
3483
+ const childTree = buildCallerTree(cDef, currentDepth + 1);
3484
+ if (childTree) {
3485
+ childTree.callSites = callSites;
3486
+ node.children.push(childTree);
3487
+ }
3488
+ }
3489
+
3490
+ if (callerEntries.length > maxChildren) {
3491
+ node.truncatedChildren = callerEntries.length - maxChildren;
3492
+ // Count truncated callers in summary
3493
+ for (const { def: cDef } of callerEntries.slice(maxChildren)) {
3494
+ const key = `${cDef.file}:${cDef.startLine}`;
3495
+ if (!visited.has(key)) {
3496
+ affectedFunctions.add(key);
3497
+ affectedFiles.add(cDef.file);
3498
+ }
3499
+ }
3500
+ }
3501
+ }
3502
+
3503
+ return node;
3504
+ };
3505
+
3506
+ const tree = buildCallerTree(def, 0);
3507
+
3508
+ // Smart hints
3509
+ if (tree && tree.children.length === 0) {
3510
+ if (maxDepth === 0) {
3511
+ warnings.push({ message: 'depth=0: showing root function only. Increase depth to see callers.' });
3512
+ } else if (definitions.length > 1 && !options.file) {
3513
+ warnings.push({
3514
+ 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.`
3515
+ });
3516
+ }
3517
+ }
3518
+
3519
+ return {
3520
+ root: name,
3521
+ file: def.relativePath,
3522
+ line: def.startLine,
3523
+ maxDepth,
3524
+ includeMethods,
3525
+ tree,
3526
+ summary: {
3527
+ totalAffected: affectedFunctions.size,
3528
+ totalFiles: affectedFiles.size,
3529
+ maxDepthReached
3530
+ },
3531
+ warnings: warnings.length > 0 ? warnings : undefined
3532
+ };
3533
+ } finally { this._endOp(); }
3534
+ }
3535
+
3536
+ /**
3537
+ * Reverse trace: walk UP the caller chain to entry points.
3538
+ * Like blast but focused on "how does execution reach this function?"
3539
+ * Marks leaf nodes (functions with no callers) as entry points.
3540
+ */
3541
+ reverseTrace(name, options = {}) {
3542
+ this._beginOp();
3543
+ try {
3544
+ const maxDepth = Math.max(0, options.depth ?? 5);
3545
+ const maxChildren = options.all ? Infinity : 10;
3546
+ const includeMethods = options.includeMethods ?? true;
3547
+ const includeUncertain = options.includeUncertain || false;
3548
+ const exclude = options.exclude || [];
3549
+
3550
+ const { def, definitions, warnings } = this.resolveSymbol(name, { file: options.file, className: options.className });
3551
+ if (!def) return null;
3552
+
3553
+ const visited = new Set();
3554
+ const entryPoints = [];
3555
+ let maxDepthReached = 0;
3556
+
3557
+ const buildCallerTree = (funcDef, currentDepth) => {
3558
+ const key = `${funcDef.file}:${funcDef.startLine}`;
3559
+ if (currentDepth > maxDepth) return null;
3560
+ if (visited.has(key)) {
3561
+ return {
3562
+ name: funcDef.name,
3563
+ file: funcDef.relativePath,
3564
+ line: funcDef.startLine,
3565
+ type: funcDef.type || 'function',
3566
+ children: [],
3567
+ alreadyShown: true
3568
+ };
3569
+ }
3570
+ visited.add(key);
3571
+ if (currentDepth > maxDepthReached) maxDepthReached = currentDepth;
3572
+
3573
+ const node = {
3574
+ name: funcDef.name,
3575
+ file: funcDef.relativePath,
3576
+ line: funcDef.startLine,
3577
+ type: funcDef.type || 'function',
3578
+ children: []
3579
+ };
3580
+
3581
+ if (currentDepth < maxDepth) {
3582
+ const callers = this.findCallers(funcDef.name, {
3583
+ includeMethods,
3584
+ includeUncertain,
3585
+ targetDefinitions: funcDef.bindingId ? [funcDef] : undefined,
3586
+ });
3587
+
3588
+ // Deduplicate callers by enclosing function
3589
+ const uniqueCallers = new Map();
3590
+ for (const c of callers) {
3591
+ if (!c.callerName) continue;
3592
+ if (exclude.length > 0 && !this.matchesFilters(c.relativePath, { exclude })) continue;
3593
+ const callerKey = c.callerStartLine
3594
+ ? `${c.callerFile}:${c.callerStartLine}`
3595
+ : `${c.callerFile}:${c.callerName}`;
3596
+ if (!uniqueCallers.has(callerKey)) {
3597
+ uniqueCallers.set(callerKey, {
3598
+ name: c.callerName,
3599
+ file: c.callerFile,
3600
+ relativePath: c.relativePath,
3601
+ startLine: c.callerStartLine,
3602
+ endLine: c.callerEndLine,
3603
+ callSites: 1
3604
+ });
3605
+ } else {
3606
+ uniqueCallers.get(callerKey).callSites++;
3607
+ }
3608
+ }
3609
+
3610
+ // Resolve definitions and build child nodes
3611
+ const callerEntries = [];
3612
+ for (const [, caller] of uniqueCallers) {
3613
+ const defs = this.symbols.get(caller.name);
3614
+ let callerDef = defs?.find(d => d.file === caller.file && d.startLine === caller.startLine);
3615
+ if (!callerDef) {
3616
+ callerDef = {
3617
+ name: caller.name,
3618
+ file: caller.file,
3619
+ relativePath: caller.relativePath,
3620
+ startLine: caller.startLine,
3621
+ endLine: caller.endLine,
3622
+ type: 'function'
3623
+ };
3624
+ }
3625
+ callerEntries.push({ def: callerDef, callSites: caller.callSites });
3626
+ }
3627
+
3628
+ callerEntries.sort((a, b) =>
3629
+ a.def.file.localeCompare(b.def.file) || a.def.startLine - b.def.startLine
3630
+ );
3631
+
3632
+ for (const { def: cDef, callSites } of callerEntries.slice(0, maxChildren)) {
3633
+ const childTree = buildCallerTree(cDef, currentDepth + 1);
3634
+ if (childTree) {
3635
+ childTree.callSites = callSites;
3636
+ node.children.push(childTree);
3637
+ }
3638
+ }
3639
+
3640
+ if (callerEntries.length > maxChildren) {
3641
+ node.truncatedChildren = callerEntries.length - maxChildren;
3642
+ // Count entry points in truncated branches so summary is accurate
3643
+ for (const { def: cDef } of callerEntries.slice(maxChildren)) {
3644
+ const key = `${cDef.file}:${cDef.startLine}`;
3645
+ if (!visited.has(key)) {
3646
+ const cCallers = this.findCallers(cDef.name, {
3647
+ includeMethods, includeUncertain,
3648
+ targetDefinitions: cDef.bindingId ? [cDef] : undefined,
3649
+ });
3650
+ if (cCallers.length === 0) {
3651
+ entryPoints.push({ name: cDef.name, file: cDef.relativePath || path.relative(this.root, cDef.file), line: cDef.startLine });
3652
+ }
3653
+ }
3654
+ }
3655
+ }
3656
+
3657
+ // Mark as entry point if no callers found (and not at depth limit)
3658
+ if (uniqueCallers.size === 0 && currentDepth > 0) {
3659
+ node.entryPoint = true;
3660
+ entryPoints.push({ name: funcDef.name, file: funcDef.relativePath, line: funcDef.startLine });
3661
+ }
3662
+ }
3663
+
3664
+ return node;
3665
+ };
3666
+
3667
+ const tree = buildCallerTree(def, 0);
3668
+
3669
+ // Also mark root as entry point if it has no callers
3670
+ if (tree && tree.children.length === 0 && maxDepth > 0) {
3671
+ tree.entryPoint = true;
3672
+ entryPoints.push({ name: def.name, file: def.relativePath, line: def.startLine });
3673
+ }
3674
+
3675
+ // Smart hints
3676
+ if (tree && tree.children.length === 0) {
3677
+ if (maxDepth === 0) {
3678
+ warnings.push({ message: 'depth=0: showing root function only. Increase depth to see callers.' });
3679
+ } else if (definitions.length > 1 && !options.file) {
3680
+ warnings.push({
3681
+ 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.`
3682
+ });
3683
+ }
3684
+ }
3685
+
3686
+ return {
3687
+ root: name,
3688
+ file: def.relativePath,
3689
+ line: def.startLine,
3690
+ maxDepth,
3691
+ includeMethods,
3692
+ tree,
3693
+ entryPoints,
3694
+ summary: {
3695
+ totalEntryPoints: entryPoints.length,
3696
+ totalFunctions: visited.size - 1, // exclude root
3697
+ maxDepthReached
3698
+ },
3699
+ warnings: warnings.length > 0 ? warnings : undefined
3700
+ };
3701
+ } finally { this._endOp(); }
3702
+ }
3703
+
3704
+ /**
3705
+ * Find tests affected by a change to the given function.
3706
+ * Composes blast() (transitive callers) with test file scanning.
3707
+ */
3708
+ affectedTests(name, options = {}) {
3709
+ this._beginOp();
3710
+ try {
3711
+ // Step 1: Get all transitively affected functions via blast
3712
+ const blastResult = this.blast(name, {
3713
+ depth: options.depth ?? 3,
3714
+ file: options.file,
3715
+ className: options.className,
3716
+ all: true,
3717
+ exclude: options.exclude,
3718
+ includeMethods: options.includeMethods,
3719
+ includeUncertain: options.includeUncertain,
3720
+ });
3721
+ if (!blastResult) return null;
3722
+
3723
+ // Step 2: Collect all affected function names from the tree
3724
+ const affectedNames = new Set();
3725
+ affectedNames.add(name);
3726
+ const collectNames = (node) => {
3727
+ if (!node) return;
3728
+ affectedNames.add(node.name);
3729
+ for (const child of node.children || []) collectNames(child);
3730
+ };
3731
+ collectNames(blastResult.tree);
3732
+
3733
+ // Step 3: Build regex patterns for all names
3734
+ const namePatterns = new Map();
3735
+ for (const n of affectedNames) {
3736
+ const escaped = escapeRegExp(n);
3737
+ namePatterns.set(n, {
3738
+ regex: new RegExp('\\b' + escaped + '\\b'),
3739
+ callPattern: new RegExp(escaped + '\\s*\\('),
3740
+ });
3741
+ }
3742
+
3743
+ // Step 4: Scan test files once for all affected names
3744
+ const exclude = options.exclude;
3745
+ const excludeArr = exclude ? (Array.isArray(exclude) ? exclude : [exclude]) : [];
3746
+ const results = [];
3747
+ for (const [filePath, fileEntry] of this.files) {
3748
+ if (!isTestFile(fileEntry.relativePath, fileEntry.language)) continue;
3749
+ if (excludeArr.length > 0 && !this.matchesFilters(fileEntry.relativePath, { exclude: excludeArr })) continue;
3750
+ try {
3751
+ const content = this._readFile(filePath);
3752
+ const lines = content.split('\n');
3753
+ const fileMatches = new Map();
3754
+
3755
+ lines.forEach((line, idx) => {
3756
+ for (const [funcName, patterns] of namePatterns) {
3757
+ if (patterns.regex.test(line)) {
3758
+ let matchType = 'reference';
3759
+ if (/\b(describe|it|test|spec)\s*\(/.test(line)) {
3760
+ matchType = 'test-case';
3761
+ } else if (/\b(import|require|from)\b/.test(line)) {
3762
+ matchType = 'import';
3763
+ } else if (patterns.callPattern.test(line)) {
3764
+ matchType = 'call';
3765
+ }
3766
+ if (!fileMatches.has(funcName)) fileMatches.set(funcName, []);
3767
+ fileMatches.get(funcName).push({
3768
+ line: idx + 1, content: line.trim(),
3769
+ matchType, functionName: funcName
3770
+ });
3771
+ }
3772
+ }
3773
+ });
3774
+
3775
+ if (fileMatches.size > 0) {
3776
+ const coveredFunctions = [...fileMatches.keys()];
3777
+ const allMatches = [];
3778
+ for (const matches of fileMatches.values()) allMatches.push(...matches);
3779
+ allMatches.sort((a, b) => a.line - b.line);
3780
+ results.push({
3781
+ file: fileEntry.relativePath,
3782
+ coveredFunctions,
3783
+ matchCount: allMatches.length,
3784
+ matches: allMatches
3785
+ });
3786
+ }
3787
+ } catch (e) { /* skip unreadable */ }
3788
+ }
3789
+
3790
+ // Sort by coverage breadth then alphabetically
3791
+ results.sort((a, b) => b.coveredFunctions.length - a.coveredFunctions.length || a.file.localeCompare(b.file));
3792
+
3793
+ // Compute coverage stats
3794
+ const coveredSet = new Set();
3795
+ for (const r of results) for (const f of r.coveredFunctions) coveredSet.add(f);
3796
+ const uncovered = [...affectedNames].filter(n => !coveredSet.has(n));
3797
+
3798
+ return {
3799
+ root: blastResult.root, file: blastResult.file, line: blastResult.line,
3800
+ depth: blastResult.maxDepth,
3801
+ affectedFunctions: [...affectedNames],
3802
+ testFiles: results,
3803
+ summary: {
3804
+ totalAffected: affectedNames.size,
3805
+ totalTestFiles: results.length,
3806
+ coveredFunctions: coveredSet.size,
3807
+ uncoveredCount: uncovered.length,
3808
+ },
3809
+ uncovered,
3810
+ warnings: blastResult.warnings,
3811
+ };
3812
+ } finally { this._endOp(); }
3813
+ }
3814
+
2956
3815
  /** Plan a refactoring operation */
2957
3816
  plan(name, options) { return verifyModule.plan(this, name, options); }
2958
3817
 
@@ -3041,14 +3900,10 @@ class ProjectIndex {
3041
3900
  const isMethod = !!(primary.isMethod || primary.type === 'method' || primary.className);
3042
3901
  const includeMethods = options.includeMethods ?? isMethod;
3043
3902
 
3044
- // Get usage counts by type
3045
- const usages = this.usages(symbolName, { codeOnly: true });
3046
- const usagesByType = {
3047
- definitions: usages.filter(u => u.isDefinition).length,
3048
- calls: usages.filter(u => u.usageType === 'call').length,
3049
- imports: usages.filter(u => u.usageType === 'import').length,
3050
- references: usages.filter(u => u.usageType === 'reference').length
3051
- };
3903
+ // Get usage counts by type (fast path uses callee index, no file reads)
3904
+ // Exclude test files by default (matching usages command behavior)
3905
+ const countExclude = !options.includeTests ? addTestExclusions(options.exclude) : options.exclude;
3906
+ const usagesByType = this.countSymbolUsages(primary, { exclude: countExclude });
3052
3907
 
3053
3908
  // Get callers and callees (only for functions)
3054
3909
  let callers = [];
@@ -3056,7 +3911,9 @@ class ProjectIndex {
3056
3911
  let allCallers = null;
3057
3912
  let allCallees = null;
3058
3913
  if (primary.type === 'function' || primary.params !== undefined) {
3059
- allCallers = this.findCallers(symbolName, { includeMethods, includeUncertain: options.includeUncertain, targetDefinitions: [primary] });
3914
+ // Use maxResults to limit file iteration (with buffer for exclude filtering)
3915
+ const callerCap = maxCallers === Infinity ? undefined : maxCallers * 3;
3916
+ allCallers = this.findCallers(symbolName, { includeMethods, includeUncertain: options.includeUncertain, targetDefinitions: [primary], maxResults: callerCap });
3060
3917
  // Apply exclude filter before slicing
3061
3918
  if (options.exclude && options.exclude.length > 0) {
3062
3919
  allCallers = allCallers.filter(c => this.matchesFilters(c.relativePath, { exclude: options.exclude }));
@@ -3203,6 +4060,14 @@ class ProjectIndex {
3203
4060
  }
3204
4061
 
3205
4062
  for (const [filePath, fileEntry] of this.files) {
4063
+ // Apply --file filter
4064
+ if (options.file) {
4065
+ const fp = fileEntry.relativePath;
4066
+ if (!fp.includes(options.file) && !fp.endsWith(options.file)) {
4067
+ filesSkipped++;
4068
+ continue;
4069
+ }
4070
+ }
3206
4071
  // Apply exclude/in filters
3207
4072
  if ((options.exclude && options.exclude.length > 0) || options.in) {
3208
4073
  if (!this.matchesFilters(fileEntry.relativePath, { exclude: options.exclude, in: options.in })) {
@@ -3329,6 +4194,206 @@ class ProjectIndex {
3329
4194
  } finally { this._endOp(); }
3330
4195
  }
3331
4196
 
4197
+ /**
4198
+ * Structural search — query the symbol table and call index, not raw text.
4199
+ * Answers questions like "functions taking Request param", "all db.* calls",
4200
+ * "exported async functions", "decorated route handlers".
4201
+ *
4202
+ * @param {object} options
4203
+ * @param {string} [options.term] - Name filter (glob: * and ? supported)
4204
+ * @param {string} [options.type] - Symbol kind: function, class, call, method, type
4205
+ * @param {string} [options.param] - Parameter name or type substring
4206
+ * @param {string} [options.receiver] - Call receiver pattern (for type=call)
4207
+ * @param {string} [options.returns] - Return type substring
4208
+ * @param {string} [options.decorator] - Decorator/annotation name substring
4209
+ * @param {boolean} [options.exported] - Only exported symbols
4210
+ * @param {boolean} [options.unused] - Only symbols with zero callers
4211
+ * @param {string[]} [options.exclude] - Exclude file patterns
4212
+ * @param {string} [options.in] - Restrict to subdirectory
4213
+ * @param {string} [options.file] - File pattern filter
4214
+ * @param {number} [options.top] - Limit results
4215
+ * @returns {{ results: Array, meta: object }}
4216
+ */
4217
+ structuralSearch(options = {}) {
4218
+ this._beginOp();
4219
+ try {
4220
+ const { term, param, receiver, returns: returnType, decorator, exported, unused } = options;
4221
+ // Auto-infer type: --receiver implies type=call
4222
+ const type = options.type || (receiver ? 'call' : undefined);
4223
+ const results = [];
4224
+
4225
+ // Validate type if provided
4226
+ if (type && !STRUCTURAL_TYPES.has(type)) {
4227
+ return {
4228
+ results: [],
4229
+ meta: {
4230
+ mode: 'structural',
4231
+ query: { type },
4232
+ totalMatched: 0,
4233
+ shown: 0,
4234
+ error: `Invalid type "${type}". Valid types: ${[...STRUCTURAL_TYPES].join(', ')}`,
4235
+ }
4236
+ };
4237
+ }
4238
+
4239
+ // Build glob-style name matcher from term
4240
+ const nameMatcher = term ? buildGlobMatcher(term, options.caseSensitive) : null;
4241
+
4242
+ // Helper: check if file passes filters
4243
+ const passesFileFilter = (fileEntry) => {
4244
+ if (!fileEntry) return false;
4245
+ if (options.file) {
4246
+ const rp = fileEntry.relativePath;
4247
+ if (!rp.includes(options.file) && !rp.endsWith(options.file)) return false;
4248
+ }
4249
+ if ((options.exclude && options.exclude.length > 0) || options.in) {
4250
+ if (!this.matchesFilters(fileEntry.relativePath, { exclude: options.exclude, in: options.in })) return false;
4251
+ }
4252
+ return true;
4253
+ };
4254
+
4255
+ if (type === 'call') {
4256
+ // Search call sites from callee index
4257
+ const { getCachedCalls } = require('./callers');
4258
+ const seenFiles = new Set();
4259
+
4260
+ // If term is given, only scan files that might contain that call
4261
+ if (term && !term.includes('*') && !term.includes('?')) {
4262
+ // Exact or substring — use callee index for fast lookup
4263
+ this.buildCalleeIndex();
4264
+ const files = this.calleeIndex.get(term);
4265
+ if (files) for (const f of files) seenFiles.add(f);
4266
+ } else {
4267
+ // Scan all files
4268
+ for (const fp of this.files.keys()) seenFiles.add(fp);
4269
+ }
4270
+
4271
+ for (const filePath of seenFiles) {
4272
+ const fileEntry = this.files.get(filePath);
4273
+ if (!passesFileFilter(fileEntry)) continue;
4274
+ const calls = getCachedCalls(this, filePath);
4275
+ if (!calls) continue;
4276
+ for (const call of calls) {
4277
+ if (nameMatcher && !nameMatcher(call.name)) continue;
4278
+ if (receiver) {
4279
+ if (!call.receiver) continue;
4280
+ if (!matchesSubstring(call.receiver, receiver, options.caseSensitive)) continue;
4281
+ }
4282
+ results.push({
4283
+ kind: 'call',
4284
+ name: call.receiver ? `${call.receiver}.${call.name}` : call.name,
4285
+ file: fileEntry.relativePath,
4286
+ line: call.line,
4287
+ receiver: call.receiver || null,
4288
+ isMethod: call.isMethod || false,
4289
+ });
4290
+ }
4291
+ }
4292
+ } else {
4293
+ // Search symbols (functions, classes, methods, types)
4294
+ const functionTypes = new Set(['function', 'constructor', 'method', 'arrow', 'static', 'classmethod']);
4295
+ const classTypes = new Set(['class', 'struct', 'interface', 'impl', 'trait']);
4296
+ const typeTypes = new Set(['type', 'enum', 'interface', 'trait']);
4297
+ const methodTypes = new Set(['method', 'constructor']);
4298
+
4299
+ for (const [symbolName, definitions] of this.symbols) {
4300
+ if (nameMatcher && !nameMatcher(symbolName)) continue;
4301
+
4302
+ for (const def of definitions) {
4303
+ // Type filter
4304
+ if (type === 'function' && !functionTypes.has(def.type)) continue;
4305
+ if (type === 'class' && !classTypes.has(def.type)) continue;
4306
+ if (type === 'method' && !methodTypes.has(def.type) && !def.isMethod) continue;
4307
+ if (type === 'type' && !typeTypes.has(def.type)) continue;
4308
+
4309
+ // File filters
4310
+ const fileEntry = this.files.get(def.file);
4311
+ if (!passesFileFilter(fileEntry)) continue;
4312
+
4313
+ // Param filter: match param name or type
4314
+ if (param) {
4315
+ const cs = options.caseSensitive;
4316
+ const ps = def.paramsStructured || [];
4317
+ const paramStr = def.params || '';
4318
+ const hasMatch = ps.some(p =>
4319
+ matchesSubstring(p.name, param, cs) ||
4320
+ (p.type && matchesSubstring(p.type, param, cs))
4321
+ ) || matchesSubstring(paramStr, param, cs);
4322
+ if (!hasMatch) continue;
4323
+ }
4324
+
4325
+ // Return type filter
4326
+ if (returnType) {
4327
+ if (!def.returnType || !matchesSubstring(def.returnType, returnType, options.caseSensitive)) continue;
4328
+ }
4329
+
4330
+ // Decorator filter: checks decorators (Python), modifiers (Java annotations stored lowercase)
4331
+ if (decorator) {
4332
+ const cs = options.caseSensitive;
4333
+ const hasDecorator = (def.decorators && def.decorators.some(d => matchesSubstring(d, decorator, cs))) ||
4334
+ (def.modifiers && def.modifiers.some(m => matchesSubstring(m, decorator, cs)));
4335
+ if (!hasDecorator) continue;
4336
+ }
4337
+
4338
+ // Exported filter
4339
+ if (exported) {
4340
+ const mods = def.modifiers || [];
4341
+ const isExp = (fileEntry && fileEntry.exports.includes(symbolName)) ||
4342
+ mods.includes('export') || mods.includes('public') ||
4343
+ mods.some(m => m.startsWith('pub')) ||
4344
+ (fileEntry && fileEntry.language === 'go' && /^[A-Z]/.test(symbolName));
4345
+ if (!isExp) continue;
4346
+ }
4347
+
4348
+ // Unused filter (expensive — last check)
4349
+ if (unused) {
4350
+ this.buildCalleeIndex();
4351
+ if (this.calleeIndex.has(symbolName)) continue;
4352
+ }
4353
+
4354
+ // Merge decorators from both Python-style decorators and Java-style modifiers
4355
+ const allDecorators = def.decorators || null;
4356
+
4357
+ results.push({
4358
+ kind: def.type,
4359
+ name: symbolName,
4360
+ file: def.relativePath,
4361
+ line: def.startLine,
4362
+ params: def.params || null,
4363
+ returnType: def.returnType || null,
4364
+ decorators: allDecorators,
4365
+ className: def.className || null,
4366
+ exported: exported ? true : undefined,
4367
+ });
4368
+ }
4369
+ }
4370
+ }
4371
+
4372
+ // Sort by file, then line
4373
+ results.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
4374
+
4375
+ // Apply top limit
4376
+ const total = results.length;
4377
+ const top = options.top;
4378
+ if (top && top > 0 && results.length > top) {
4379
+ results.length = top;
4380
+ }
4381
+
4382
+ return {
4383
+ results,
4384
+ meta: {
4385
+ mode: 'structural',
4386
+ query: Object.fromEntries(Object.entries({
4387
+ type: type || 'any', term, param, receiver, returns: returnType,
4388
+ decorator, exported: exported || undefined, unused: unused || undefined,
4389
+ }).filter(([, v]) => v !== undefined && v !== null)),
4390
+ totalMatched: total,
4391
+ shown: results.length,
4392
+ }
4393
+ };
4394
+ } finally { this._endOp(); }
4395
+ }
4396
+
3332
4397
  // ========================================================================
3333
4398
  // PROJECT INFO
3334
4399
  // ========================================================================
@@ -3349,7 +4414,8 @@ class ProjectIndex {
3349
4414
  symbols: totalSymbols, // Total symbol count, not unique names
3350
4415
  buildTime: this.buildTime,
3351
4416
  byLanguage: {},
3352
- byType: {}
4417
+ byType: {},
4418
+ ...(this.truncated && { truncated: this.truncated })
3353
4419
  };
3354
4420
 
3355
4421
  for (const [filePath, fileEntry] of this.files) {
@@ -3377,7 +4443,8 @@ class ProjectIndex {
3377
4443
  for (const [name, symbols] of this.symbols) {
3378
4444
  for (const sym of symbols) {
3379
4445
  if (sym.type === 'function' || sym.type === 'method' || sym.type === 'static' ||
3380
- sym.type === 'constructor' || sym.type === 'public' || sym.type === 'abstract') {
4446
+ sym.type === 'constructor' || sym.type === 'public' || sym.type === 'abstract' ||
4447
+ sym.type === 'classmethod') {
3381
4448
  const lineCount = sym.endLine - sym.startLine + 1;
3382
4449
  const relativePath = sym.relativePath || (sym.file ? path.relative(this.root, sym.file) : '');
3383
4450
  functions.push({
@@ -3442,10 +4509,11 @@ class ProjectIndex {
3442
4509
  if (fileFilter && !fileFilter.has(filePath)) continue;
3443
4510
  let functions = fileEntry.symbols.filter(s =>
3444
4511
  s.type === 'function' || s.type === 'method' || s.type === 'static' ||
3445
- s.type === 'constructor' || s.type === 'public' || s.type === 'abstract'
4512
+ s.type === 'constructor' || s.type === 'public' || s.type === 'abstract' ||
4513
+ s.type === 'classmethod'
3446
4514
  );
3447
4515
  const classes = fileEntry.symbols.filter(s =>
3448
- ['class', 'interface', 'type', 'enum', 'struct', 'trait', 'impl'].includes(s.type)
4516
+ ['class', 'interface', 'type', 'enum', 'struct', 'trait', 'impl', 'record', 'namespace'].includes(s.type)
3449
4517
  );
3450
4518
  const state = fileEntry.symbols.filter(s => s.type === 'state');
3451
4519
 
@@ -3520,12 +4588,18 @@ class ProjectIndex {
3520
4588
  displayFiles = files.slice(0, top);
3521
4589
  }
3522
4590
 
4591
+ // Count files with no symbols (generated/empty files)
4592
+ const emptyFiles = files.filter(f => f.functions === 0 && f.classes === 0 && f.state === 0).length;
4593
+
3523
4594
  return {
3524
4595
  meta: {
3525
4596
  complete: totalDynamic === 0,
3526
4597
  skipped: 0,
3527
4598
  dynamicImports: totalDynamic,
3528
- uncertain: 0
4599
+ uncertain: 0,
4600
+ projectLanguage: this._getPredominantLanguage(),
4601
+ ...(fileFilter && { filteredBy: options.file, matchedFiles: files.length }),
4602
+ ...(emptyFiles > 0 && fileFilter && { emptyFiles })
3529
4603
  },
3530
4604
  totals: {
3531
4605
  files: files.length,
@@ -3555,6 +4629,9 @@ class ProjectIndex {
3555
4629
  /** Load index from cache file */
3556
4630
  loadCache(cachePath) { return indexCache.loadCache(this, cachePath); }
3557
4631
 
4632
+ /** Load callsCache from separate file on demand (called by findCallers/findCallees) */
4633
+ loadCallsCache() { return indexCache.loadCallsCache(this); }
4634
+
3558
4635
  /** Check if cache is stale (any files changed or new files added) */
3559
4636
  isCacheStale() { return indexCache.isCacheStale(this); }
3560
4637
 
@@ -3875,7 +4952,40 @@ class ProjectIndex {
3875
4952
  const allDefs = this.symbols.get(symbol.name) || [];
3876
4953
  const targetDefs = allDefs.filter(d => d.file === change.filePath && d.startLine === symbol.startLine);
3877
4954
 
3878
- const callers = this.findCallers(symbol.name, { targetDefinitions: targetDefs.length > 0 ? targetDefs : undefined });
4955
+ let callers = this.findCallers(symbol.name, {
4956
+ targetDefinitions: targetDefs.length > 0 ? targetDefs : undefined,
4957
+ includeMethods: true,
4958
+ includeUncertain: false,
4959
+ });
4960
+
4961
+ // For Go/Java/Rust methods with a className, filter callers whose
4962
+ // receiver clearly belongs to a different type (same logic as impact()).
4963
+ const targetDef = targetDefs[0] || symbol;
4964
+ if (targetDef.className && (lang === 'go' || lang === 'java' || lang === 'rust')) {
4965
+ const targetClassName = targetDef.className;
4966
+ callers = callers.filter(c => {
4967
+ if (!c.isMethod) return true;
4968
+ const r = c.receiver;
4969
+ if (!r || ['self', 'cls', 'this', 'super'].includes(r)) return true;
4970
+ // Use receiverType from findCallers when available
4971
+ if (c.receiverType) {
4972
+ return c.receiverType === targetClassName ||
4973
+ c.receiverType === targetDef.receiver?.replace(/^\*/, '');
4974
+ }
4975
+ // Unique method heuristic: if the method exists on exactly one class/type, include
4976
+ const methodDefs = this.symbols.get(symbol.name);
4977
+ if (methodDefs) {
4978
+ const classNames = new Set();
4979
+ for (const d of methodDefs) {
4980
+ if (d.className) classNames.add(d.className);
4981
+ else if (d.receiver) classNames.add(d.receiver.replace(/^\*/, ''));
4982
+ }
4983
+ if (classNames.size === 1 && classNames.has(targetClassName)) return true;
4984
+ }
4985
+ // Unknown receiver + multiple classes with this method → filter out
4986
+ return false;
4987
+ });
4988
+ }
3879
4989
 
3880
4990
  for (const c of callers) {
3881
4991
  callerFileSet.add(c.file);