ucn 3.7.45 → 3.7.47

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');
@@ -157,7 +157,7 @@ class ProjectIndex {
157
157
 
158
158
  const globOpts = {
159
159
  root: this.root,
160
- maxFiles: options.maxFiles || 10000,
160
+ maxFiles: options.maxFiles || this.config.maxFiles || 50000,
161
161
  followSymlinks: options.followSymlinks
162
162
  };
163
163
 
@@ -170,10 +170,18 @@ class ProjectIndex {
170
170
 
171
171
  const files = expandGlob(pattern, globOpts);
172
172
 
173
+ // Track if files were truncated by maxFiles limit
174
+ if (files.length >= globOpts.maxFiles) {
175
+ this.truncated = { indexed: files.length, maxFiles: globOpts.maxFiles };
176
+ } else {
177
+ this.truncated = null;
178
+ }
179
+
173
180
  if (!quiet) {
174
181
  console.error(`Indexing ${files.length} files in ${this.root}...`);
175
182
  }
176
183
 
184
+ let deletedInRebuild = 0;
177
185
  if (options.forceRebuild) {
178
186
  // Incremental rebuild: only remove files that no longer exist on disk.
179
187
  // indexFile() already skips unchanged files and calls removeFileSymbols()
@@ -184,6 +192,7 @@ class ProjectIndex {
184
192
  this.removeFileSymbols(cachedPath);
185
193
  this.files.delete(cachedPath);
186
194
  this.callsCache.delete(cachedPath);
195
+ deletedInRebuild++;
187
196
  }
188
197
  }
189
198
  }
@@ -193,10 +202,11 @@ class ProjectIndex {
193
202
  this._attrTypeCache = null;
194
203
 
195
204
  let indexed = 0;
205
+ let changed = 0;
196
206
  if (!this.failedFiles) this.failedFiles = new Set();
197
207
  for (const file of files) {
198
208
  try {
199
- this.indexFile(file);
209
+ if (this.indexFile(file)) changed++;
200
210
  indexed++;
201
211
  this.failedFiles.delete(file); // Succeeded now, remove from failed
202
212
  } catch (e) {
@@ -207,8 +217,11 @@ class ProjectIndex {
207
217
  }
208
218
  }
209
219
 
210
- this.buildImportGraph();
211
- this.buildInheritanceGraph();
220
+ // Skip graph rebuild when incremental rebuild found no changes
221
+ if (changed > 0 || deletedInRebuild > 0 || !options.forceRebuild) {
222
+ this.buildImportGraph();
223
+ this.buildInheritanceGraph();
224
+ }
212
225
 
213
226
  this.buildTime = Date.now() - startTime;
214
227
 
@@ -234,14 +247,22 @@ class ProjectIndex {
234
247
  * Index a single file
235
248
  */
236
249
  indexFile(filePath) {
250
+ const stat = fs.statSync(filePath);
251
+ const existing = this.files.get(filePath);
252
+
253
+ // Fast path: skip read entirely when mtime+size both match
254
+ if (existing && existing.mtime === stat.mtimeMs && existing.size === stat.size) {
255
+ return false;
256
+ }
257
+
237
258
  const content = fs.readFileSync(filePath, 'utf-8');
238
259
  const hash = crypto.createHash('md5').update(content).digest('hex');
239
- const stat = fs.statSync(filePath);
240
260
 
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;
261
+ // Content-based skip: mtime changed but content didn't (touch, git checkout)
262
+ if (existing && existing.hash === hash) {
263
+ existing.mtime = stat.mtimeMs;
264
+ existing.size = stat.size;
265
+ return false;
245
266
  }
246
267
 
247
268
  if (existing) {
@@ -251,7 +272,11 @@ class ProjectIndex {
251
272
  const language = detectLanguage(filePath);
252
273
  if (!language) return;
253
274
 
254
- const parsed = parseFile(filePath);
275
+ // Parse content once — the tree-sitter cache in safeParse ensures the tree
276
+ // is shared across parse()/extractImports()/extractExports() (5→1 parse per file)
277
+ const parsed = parse(content, language);
278
+ parsed.filePath = filePath;
279
+ parsed.relativePath = filePath;
255
280
  const { imports, dynamicCount, importAliases } = extractImports(content, language);
256
281
  const { exports } = extractExports(content, language);
257
282
 
@@ -280,6 +305,7 @@ class ProjectIndex {
280
305
  mtime: stat.mtimeMs,
281
306
  size: stat.size,
282
307
  imports: imports.map(i => i.module),
308
+ importNames: imports.flatMap(i => i.names || []),
283
309
  exports: exports.map(e => e.name),
284
310
  exportDetails: exports,
285
311
  symbols: [],
@@ -313,6 +339,7 @@ class ProjectIndex {
313
339
  ...(item.receiver && { receiver: item.receiver }),
314
340
  ...(item.className && { className: item.className }),
315
341
  ...(item.memberType && { memberType: item.memberType }),
342
+ ...(item.fieldType && { fieldType: item.fieldType }),
316
343
  ...(item.decorators && item.decorators.length > 0 && { decorators: item.decorators }),
317
344
  ...(item.nameLine && { nameLine: item.nameLine })
318
345
  };
@@ -331,6 +358,12 @@ class ProjectIndex {
331
358
  };
332
359
 
333
360
  for (const fn of parsed.functions) {
361
+ // Go/Rust methods: set className from receiver for consistent method resolution.
362
+ // Go/Rust methods are standalone functions with receiver, not class members,
363
+ // so className is never set by the class member loop below.
364
+ if (fn.receiver && !fn.className) {
365
+ fn.className = fn.receiver.replace(/^\*/, '');
366
+ }
334
367
  addSymbol(fn, fn.isConstructor ? 'constructor' : 'function');
335
368
  }
336
369
 
@@ -349,6 +382,7 @@ class ProjectIndex {
349
382
  }
350
383
 
351
384
  this.files.set(filePath, fileEntry);
385
+ return true;
352
386
  }
353
387
 
354
388
  /**
@@ -382,7 +416,7 @@ class ProjectIndex {
382
416
  * Handles regular imports, static imports (strips member name), and wildcards (strips .*).
383
417
  * Progressively strips trailing segments to find the class file.
384
418
  */
385
- _resolveJavaPackageImport(importModule) {
419
+ _resolveJavaPackageImport(importModule, javaFileIndex) {
386
420
  const isWildcard = importModule.endsWith('.*');
387
421
  // Strip wildcard suffix (e.g., "com.pkg.Class.*" -> "com.pkg.Class")
388
422
  const mod = isWildcard ? importModule.slice(0, -2) : importModule;
@@ -390,11 +424,28 @@ class ProjectIndex {
390
424
 
391
425
  // Try progressively shorter paths: full path, then strip last segment, etc.
392
426
  // 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;
427
+ if (javaFileIndex) {
428
+ // Fast path: use pre-built filename→files index (O(candidates) vs O(all files))
429
+ for (let i = segments.length; i > 0; i--) {
430
+ const className = segments[i - 1];
431
+ const candidates = javaFileIndex.get(className);
432
+ if (candidates) {
433
+ const fileSuffix = '/' + segments.slice(0, i).join('/') + '.java';
434
+ for (const absPath of candidates) {
435
+ if (absPath.endsWith(fileSuffix)) {
436
+ return absPath;
437
+ }
438
+ }
439
+ }
440
+ }
441
+ } else {
442
+ // Fallback: scan all files (used by imports() method outside buildImportGraph)
443
+ for (let i = segments.length; i > 0; i--) {
444
+ const fileSuffix = '/' + segments.slice(0, i).join('/') + '.java';
445
+ for (const absPath of this.files.keys()) {
446
+ if (absPath.endsWith(fileSuffix)) {
447
+ return absPath;
448
+ }
398
449
  }
399
450
  }
400
451
  }
@@ -420,6 +471,22 @@ class ProjectIndex {
420
471
  this.importGraph.clear();
421
472
  this.exportGraph.clear();
422
473
 
474
+ // Pre-build directory→files map for Go package linking (O(1) lookup vs O(n) scan)
475
+ const dirToGoFiles = new Map();
476
+ // Pre-build filename→files map for Java import resolution (O(1) vs O(n) scan)
477
+ const javaFileIndex = new Map();
478
+ for (const [fp, fe] of this.files) {
479
+ if (fe.language === 'go') {
480
+ const dir = path.dirname(fp);
481
+ if (!dirToGoFiles.has(dir)) dirToGoFiles.set(dir, []);
482
+ dirToGoFiles.get(dir).push(fp);
483
+ } else if (fe.language === 'java') {
484
+ const name = path.basename(fp, '.java');
485
+ if (!javaFileIndex.has(name)) javaFileIndex.set(name, []);
486
+ javaFileIndex.get(name).push(fp);
487
+ }
488
+ }
489
+
423
490
  for (const [filePath, fileEntry] of this.files) {
424
491
  const importedFiles = [];
425
492
  const seenModules = new Set();
@@ -442,16 +509,32 @@ class ProjectIndex {
442
509
  // Java package imports: resolve by progressive suffix matching
443
510
  // Handles regular, static (com.pkg.Class.method), and wildcard (com.pkg.Class.*) imports
444
511
  if (!resolved && fileEntry.language === 'java' && !importModule.startsWith('.')) {
445
- resolved = this._resolveJavaPackageImport(importModule);
512
+ resolved = this._resolveJavaPackageImport(importModule, javaFileIndex);
446
513
  }
447
514
 
448
515
  if (resolved && this.files.has(resolved)) {
449
- importedFiles.push(resolved);
516
+ // For Go, a package import means all files in that directory are dependencies
517
+ // (Go packages span multiple files in the same directory)
518
+ const filesToLink = [resolved];
519
+ if (fileEntry.language === 'go') {
520
+ const pkgDir = path.dirname(resolved);
521
+ const dirFiles = dirToGoFiles.get(pkgDir) || [];
522
+ const importerIsTest = filePath.endsWith('_test.go');
523
+ for (const fp of dirFiles) {
524
+ if (fp !== resolved) {
525
+ if (!importerIsTest && fp.endsWith('_test.go')) continue;
526
+ filesToLink.push(fp);
527
+ }
528
+ }
529
+ }
450
530
 
451
- if (!this.exportGraph.has(resolved)) {
452
- this.exportGraph.set(resolved, []);
531
+ for (const linkedFile of filesToLink) {
532
+ importedFiles.push(linkedFile);
533
+ if (!this.exportGraph.has(linkedFile)) {
534
+ this.exportGraph.set(linkedFile, []);
535
+ }
536
+ this.exportGraph.get(linkedFile).push(filePath);
453
537
  }
454
- this.exportGraph.get(resolved).push(filePath);
455
538
  }
456
539
  }
457
540
 
@@ -754,14 +837,41 @@ class ProjectIndex {
754
837
  // Sort by score descending, then by index order for stability
755
838
  scored.sort((a, b) => b.score - a.score);
756
839
 
757
- // Tiebreaker: when top candidates have equal score, prefer by usage count
840
+ // Tiebreaker: when top candidates have equal score, prefer by import popularity
841
+ // (how many files import the candidate's file), then by usage count
758
842
  if (scored.length > 1 && scored[0].score === scored[1].score) {
759
843
  const tiedScore = scored[0].score;
760
844
  const tiedCandidates = scored.filter(s => s.score === tiedScore);
845
+
846
+ // Count how many files import each candidate's file (import popularity)
847
+ // For Go, count importers of any file in the same directory (same package)
761
848
  for (const candidate of tiedCandidates) {
849
+ let importerCount = 0;
850
+ for (const [, importedFiles] of this.importGraph) {
851
+ if (importedFiles.includes(candidate.def.file)) {
852
+ importerCount++;
853
+ }
854
+ }
855
+ // For Go, also count importers of sibling files (same package)
856
+ const candidateEntry = this.files.get(candidate.def.file);
857
+ if (candidateEntry?.language === 'go') {
858
+ const candidateDir = path.dirname(candidate.def.file);
859
+ for (const [, importedFiles] of this.importGraph) {
860
+ for (const imp of importedFiles) {
861
+ if (imp !== candidate.def.file && path.dirname(imp) === candidateDir) {
862
+ importerCount++;
863
+ break; // count each importer once
864
+ }
865
+ }
866
+ }
867
+ }
868
+ candidate.importerCount = importerCount;
762
869
  candidate.usageCount = this.countSymbolUsages(candidate.def).total;
763
870
  }
764
- tiedCandidates.sort((a, b) => b.usageCount - a.usageCount);
871
+ // Sort by import popularity first, then usage count
872
+ tiedCandidates.sort((a, b) =>
873
+ (b.importerCount - a.importerCount) || (b.usageCount - a.usageCount)
874
+ );
765
875
  // Rebuild scored array: sorted tied candidates first, then rest
766
876
  const rest = scored.filter(s => s.score !== tiedScore);
767
877
  scored.length = 0;
@@ -902,8 +1012,21 @@ class ProjectIndex {
902
1012
  // 1. The file where it's defined
903
1013
  // 2. Files that import from the definition file
904
1014
  // 3. Transitively: files that import from re-exporters of this symbol
1015
+ // 4. Go: all files in the same package directory (same-package references need no import)
905
1016
  const relevantFiles = new Set([defFile]);
906
1017
  const queue = [defFile];
1018
+
1019
+ // Go same-package: add all .go files in the same directory
1020
+ const defEntry = this.files.get(defFile);
1021
+ if (defEntry?.language === 'go') {
1022
+ const pkgDir = path.dirname(defFile);
1023
+ for (const fp of this.files.keys()) {
1024
+ if (fp !== defFile && fp.endsWith('.go') && path.dirname(fp) === pkgDir) {
1025
+ relevantFiles.add(fp);
1026
+ }
1027
+ }
1028
+ }
1029
+
907
1030
  while (queue.length > 0) {
908
1031
  const file = queue.pop();
909
1032
  const importers = this.exportGraph.get(file) || [];
@@ -1059,7 +1182,7 @@ class ProjectIndex {
1059
1182
  // Filters: namespace access to external packages (DropdownMenuPrimitive.Separator).
1060
1183
  if (u.receiver && !['self', 'this', 'cls', 'super'].includes(u.receiver) &&
1061
1184
  fileEntry.language !== 'go' && fileEntry.language !== 'java' && fileEntry.language !== 'rust') {
1062
- const hasMethodDef = allDefinitions.some(d => d.className);
1185
+ const hasMethodDef = definitions.some(d => d.className);
1063
1186
  if (!hasMethodDef && !importedFileHasDef()) {
1064
1187
  continue;
1065
1188
  }
@@ -1294,6 +1417,7 @@ class ProjectIndex {
1294
1417
  dynamicImports,
1295
1418
  uncertain: stats.uncertain,
1296
1419
  includeMethods: !!options.includeMethods,
1420
+ projectLanguage: this._getPredominantLanguage(),
1297
1421
  // Structural facts for reliability hints
1298
1422
  ...(def.isMethod && { isMethod: true }),
1299
1423
  ...(def.className && { className: def.className }),
@@ -1436,7 +1560,8 @@ class ProjectIndex {
1436
1560
  complete: stats.uncertain === 0 && dynamicImports === 0,
1437
1561
  skipped: 0,
1438
1562
  dynamicImports,
1439
- uncertain: stats.uncertain
1563
+ uncertain: stats.uncertain,
1564
+ projectLanguage: this._getPredominantLanguage()
1440
1565
  }
1441
1566
  };
1442
1567
  } finally { this._endOp(); }
@@ -1446,6 +1571,27 @@ class ProjectIndex {
1446
1571
  // HELPER METHODS
1447
1572
  // ========================================================================
1448
1573
 
1574
+ /**
1575
+ * Get the predominant language of the project (cached).
1576
+ * Returns 'go', 'javascript', etc. if >80% of files are that language.
1577
+ */
1578
+ _getPredominantLanguage() {
1579
+ if (this._predominantLang !== undefined) return this._predominantLang;
1580
+ const counts = {};
1581
+ for (const [, fe] of this.files) {
1582
+ counts[fe.language] = (counts[fe.language] || 0) + 1;
1583
+ }
1584
+ const total = this.files.size;
1585
+ for (const [lang, count] of Object.entries(counts)) {
1586
+ if (count / total > 0.8) {
1587
+ this._predominantLang = lang;
1588
+ return lang;
1589
+ }
1590
+ }
1591
+ this._predominantLang = null;
1592
+ return null;
1593
+ }
1594
+
1449
1595
  /**
1450
1596
  * Get line content from a file
1451
1597
  */
@@ -2054,11 +2200,23 @@ class ProjectIndex {
2054
2200
 
2055
2201
  let fileIterator;
2056
2202
  if (filePath) {
2203
+ // Try exact resolution first
2057
2204
  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]];
2205
+ if (typeof resolved === 'string') {
2206
+ const fileEntry = this.files.get(resolved);
2207
+ if (!fileEntry) return { error: 'file-not-found', filePath };
2208
+ fileIterator = [[resolved, fileEntry]];
2209
+ } else {
2210
+ // Fall back to pattern filter (substring match on relative path)
2211
+ const matches = [];
2212
+ for (const [absPath, fe] of this.files) {
2213
+ if (fe.relativePath.includes(filePath)) {
2214
+ matches.push([absPath, fe]);
2215
+ }
2216
+ }
2217
+ if (matches.length === 0) return { error: 'file-not-found', filePath };
2218
+ fileIterator = matches;
2219
+ }
2062
2220
  } else {
2063
2221
  fileIterator = this.files.entries();
2064
2222
  }
@@ -2392,6 +2550,8 @@ class ProjectIndex {
2392
2550
  let evalUsage = 0;
2393
2551
  let reflectionUsage = 0;
2394
2552
 
2553
+ const predominantLang = this._getPredominantLanguage();
2554
+
2395
2555
  for (const [filePath, fileEntry] of this.files) {
2396
2556
  // Skip node_modules - we don't care about their patterns
2397
2557
  if (filePath.includes('node_modules')) continue;
@@ -2399,14 +2559,16 @@ class ProjectIndex {
2399
2559
  try {
2400
2560
  const content = this._readFile(filePath);
2401
2561
 
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;
2562
+ if (fileEntry.language !== 'go') {
2563
+ // Dynamic imports: import(), require(variable), __import__
2564
+ dynamicImports += (content.match(/import\s*\([^'"]/g) || []).length;
2565
+ dynamicImports += (content.match(/require\s*\([^'"]/g) || []).length;
2566
+ dynamicImports += (content.match(/__import__\s*\(/g) || []).length;
2406
2567
 
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;
2568
+ // eval, Function constructor
2569
+ evalUsage += (content.match(/(^|[^a-zA-Z_])eval\s*\(/gm) || []).length;
2570
+ evalUsage += (content.match(/new\s+Function\s*\(/g) || []).length;
2571
+ }
2410
2572
 
2411
2573
  // Reflection: getattr, hasattr, Reflect
2412
2574
  reflectionUsage += (content.match(/\bgetattr\s*\(/g) || []).length;
@@ -2727,7 +2889,7 @@ class ProjectIndex {
2727
2889
  if (!def) {
2728
2890
  return null;
2729
2891
  }
2730
- const defIsMethod = def.isMethod || def.type === 'method' || def.className;
2892
+ const defIsMethod = def.isMethod || def.type === 'method' || def.className || def.receiver;
2731
2893
 
2732
2894
  // Use findCallers for className-scoped or method queries (sophisticated binding resolution)
2733
2895
  // Fall back to usages-based approach for simple function queries (backward compatible)
@@ -2740,10 +2902,11 @@ class ProjectIndex {
2740
2902
  targetDefinitions: [def],
2741
2903
  });
2742
2904
 
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) {
2905
+ // When the target definition has a className (including Go/Rust methods which
2906
+ // now get className from receiver), filter out method calls whose receiver
2907
+ // clearly belongs to a different type. This helps with common method names
2908
+ // like .close(), .get() etc. where many types have the same method.
2909
+ if (def.className) {
2747
2910
  const targetClassName = def.className;
2748
2911
  callerResults = callerResults.filter(c => {
2749
2912
  // Keep non-method calls and self/this/cls calls (already resolved by findCallers)
@@ -2804,20 +2967,22 @@ class ProjectIndex {
2804
2967
  }
2805
2968
  }
2806
2969
  }
2807
- // Unique method heuristic: if the called method exists on exactly one class
2970
+ // Unique method heuristic: if the called method exists on exactly one class/type
2808
2971
  // and it matches the target, include the call (no other class could match)
2809
2972
  const methodDefs = this.symbols.get(name);
2810
2973
  if (methodDefs) {
2811
2974
  const classNames = new Set();
2812
2975
  for (const d of methodDefs) {
2813
2976
  if (d.className) classNames.add(d.className);
2977
+ // Go/Rust: use receiver type as className equivalent
2978
+ else if (d.receiver) classNames.add(d.receiver.replace(/^\*/, ''));
2814
2979
  }
2815
2980
  if (classNames.size === 1 && classNames.has(targetClassName)) {
2816
2981
  return true;
2817
2982
  }
2818
2983
  }
2819
- // className explicitly set but receiver type unknown — filter it out.
2820
- // User asked for a specific class; unknown receivers are likely unrelated.
2984
+ // Type-scoped query but receiver type unknown — filter it out.
2985
+ // Unknown receivers are likely unrelated.
2821
2986
  return false;
2822
2987
  });
2823
2988
  }
@@ -2874,11 +3039,27 @@ class ProjectIndex {
2874
3039
 
2875
3040
  // Analyze each call site, filtering out method calls for non-method definitions
2876
3041
  callSites = [];
3042
+ const defFileEntry = this.files.get(def.file);
3043
+ const defLang = defFileEntry?.language;
3044
+ const targetDir = defLang === 'go' ? path.basename(path.dirname(def.file)) : null;
2877
3045
  for (const call of calls) {
2878
3046
  const analysis = this.analyzeCallSite(call, name);
2879
3047
  // Skip method calls (obj.parse()) when target is a standalone function (parse())
3048
+ // For Go, allow calls where receiver matches the package directory name
3049
+ // (e.g., controller.FilterActive() where file is in pkg/controller/)
2880
3050
  if (analysis.isMethodCall && !defIsMethod) {
2881
- continue;
3051
+ if (targetDir) {
3052
+ // Get receiver from parsed calls cache
3053
+ const parsedCalls = this.getCachedCalls(call.file);
3054
+ const matchedCall = parsedCalls?.find(c => c.name === name && c.line === call.line);
3055
+ if (matchedCall?.receiver === targetDir) {
3056
+ // Receiver matches package directory — keep it
3057
+ } else {
3058
+ continue;
3059
+ }
3060
+ } else {
3061
+ continue;
3062
+ }
2882
3063
  }
2883
3064
  callSites.push({
2884
3065
  file: call.relativePath,
@@ -3041,8 +3222,12 @@ class ProjectIndex {
3041
3222
  const isMethod = !!(primary.isMethod || primary.type === 'method' || primary.className);
3042
3223
  const includeMethods = options.includeMethods ?? isMethod;
3043
3224
 
3044
- // Get usage counts by type
3045
- const usages = this.usages(symbolName, { codeOnly: true });
3225
+ // Get usage counts by type (exclude test files by default, matching usages command behavior)
3226
+ const usageOpts = { codeOnly: true };
3227
+ if (!options.includeTests) {
3228
+ usageOpts.exclude = addTestExclusions(options.exclude);
3229
+ }
3230
+ const usages = this.usages(symbolName, usageOpts);
3046
3231
  const usagesByType = {
3047
3232
  definitions: usages.filter(u => u.isDefinition).length,
3048
3233
  calls: usages.filter(u => u.usageType === 'call').length,
@@ -3056,7 +3241,9 @@ class ProjectIndex {
3056
3241
  let allCallers = null;
3057
3242
  let allCallees = null;
3058
3243
  if (primary.type === 'function' || primary.params !== undefined) {
3059
- allCallers = this.findCallers(symbolName, { includeMethods, includeUncertain: options.includeUncertain, targetDefinitions: [primary] });
3244
+ // Use maxResults to limit file iteration (with buffer for exclude filtering)
3245
+ const callerCap = maxCallers === Infinity ? undefined : maxCallers * 3;
3246
+ allCallers = this.findCallers(symbolName, { includeMethods, includeUncertain: options.includeUncertain, targetDefinitions: [primary], maxResults: callerCap });
3060
3247
  // Apply exclude filter before slicing
3061
3248
  if (options.exclude && options.exclude.length > 0) {
3062
3249
  allCallers = allCallers.filter(c => this.matchesFilters(c.relativePath, { exclude: options.exclude }));
@@ -3203,6 +3390,14 @@ class ProjectIndex {
3203
3390
  }
3204
3391
 
3205
3392
  for (const [filePath, fileEntry] of this.files) {
3393
+ // Apply --file filter
3394
+ if (options.file) {
3395
+ const fp = fileEntry.relativePath;
3396
+ if (!fp.includes(options.file) && !fp.endsWith(options.file)) {
3397
+ filesSkipped++;
3398
+ continue;
3399
+ }
3400
+ }
3206
3401
  // Apply exclude/in filters
3207
3402
  if ((options.exclude && options.exclude.length > 0) || options.in) {
3208
3403
  if (!this.matchesFilters(fileEntry.relativePath, { exclude: options.exclude, in: options.in })) {
@@ -3349,7 +3544,8 @@ class ProjectIndex {
3349
3544
  symbols: totalSymbols, // Total symbol count, not unique names
3350
3545
  buildTime: this.buildTime,
3351
3546
  byLanguage: {},
3352
- byType: {}
3547
+ byType: {},
3548
+ ...(this.truncated && { truncated: this.truncated })
3353
3549
  };
3354
3550
 
3355
3551
  for (const [filePath, fileEntry] of this.files) {
@@ -3520,12 +3716,18 @@ class ProjectIndex {
3520
3716
  displayFiles = files.slice(0, top);
3521
3717
  }
3522
3718
 
3719
+ // Count files with no symbols (generated/empty files)
3720
+ const emptyFiles = files.filter(f => f.functions === 0 && f.classes === 0 && f.state === 0).length;
3721
+
3523
3722
  return {
3524
3723
  meta: {
3525
3724
  complete: totalDynamic === 0,
3526
3725
  skipped: 0,
3527
3726
  dynamicImports: totalDynamic,
3528
- uncertain: 0
3727
+ uncertain: 0,
3728
+ projectLanguage: this._getPredominantLanguage(),
3729
+ ...(fileFilter && { filteredBy: options.file, matchedFiles: files.length }),
3730
+ ...(emptyFiles > 0 && fileFilter && { emptyFiles })
3529
3731
  },
3530
3732
  totals: {
3531
3733
  files: files.length,
@@ -3555,6 +3757,9 @@ class ProjectIndex {
3555
3757
  /** Load index from cache file */
3556
3758
  loadCache(cachePath) { return indexCache.loadCache(this, cachePath); }
3557
3759
 
3760
+ /** Load callsCache from separate file on demand (called by findCallers/findCallees) */
3761
+ loadCallsCache() { return indexCache.loadCallsCache(this); }
3762
+
3558
3763
  /** Check if cache is stale (any files changed or new files added) */
3559
3764
  isCacheStale() { return indexCache.isCacheStale(this); }
3560
3765
 
@@ -3875,7 +4080,40 @@ class ProjectIndex {
3875
4080
  const allDefs = this.symbols.get(symbol.name) || [];
3876
4081
  const targetDefs = allDefs.filter(d => d.file === change.filePath && d.startLine === symbol.startLine);
3877
4082
 
3878
- const callers = this.findCallers(symbol.name, { targetDefinitions: targetDefs.length > 0 ? targetDefs : undefined });
4083
+ let callers = this.findCallers(symbol.name, {
4084
+ targetDefinitions: targetDefs.length > 0 ? targetDefs : undefined,
4085
+ includeMethods: true,
4086
+ includeUncertain: false,
4087
+ });
4088
+
4089
+ // For Go/Java/Rust methods with a className, filter callers whose
4090
+ // receiver clearly belongs to a different type (same logic as impact()).
4091
+ const targetDef = targetDefs[0] || symbol;
4092
+ if (targetDef.className && (lang === 'go' || lang === 'java' || lang === 'rust')) {
4093
+ const targetClassName = targetDef.className;
4094
+ callers = callers.filter(c => {
4095
+ if (!c.isMethod) return true;
4096
+ const r = c.receiver;
4097
+ if (!r || ['self', 'cls', 'this', 'super'].includes(r)) return true;
4098
+ // Use receiverType from findCallers when available
4099
+ if (c.receiverType) {
4100
+ return c.receiverType === targetClassName ||
4101
+ c.receiverType === targetDef.receiver?.replace(/^\*/, '');
4102
+ }
4103
+ // Unique method heuristic: if the method exists on exactly one class/type, include
4104
+ const methodDefs = this.symbols.get(symbol.name);
4105
+ if (methodDefs) {
4106
+ const classNames = new Set();
4107
+ for (const d of methodDefs) {
4108
+ if (d.className) classNames.add(d.className);
4109
+ else if (d.receiver) classNames.add(d.receiver.replace(/^\*/, ''));
4110
+ }
4111
+ if (classNames.size === 1 && classNames.has(targetClassName)) return true;
4112
+ }
4113
+ // Unknown receiver + multiple classes with this method → filter out
4114
+ return false;
4115
+ });
4116
+ }
3879
4117
 
3880
4118
  for (const c of callers) {
3881
4119
  callerFileSet.add(c.file);
package/core/registry.js CHANGED
@@ -70,6 +70,7 @@ const PARAM_MAP = {
70
70
  rename_to: 'renameTo',
71
71
  default_value: 'defaultValue',
72
72
  top_level: 'topLevel',
73
+ max_files: 'maxFiles',
73
74
  };
74
75
 
75
76
  // ============================================================================
package/core/shared.js CHANGED
@@ -48,6 +48,6 @@ function escapeRegExp(text) {
48
48
  }
49
49
 
50
50
  // Symbol types that are not callable (used to filter class/struct/type declarations from call analysis)
51
- const NON_CALLABLE_TYPES = new Set(['class', 'struct', 'interface', 'type', 'enum', 'trait', 'state', 'impl']);
51
+ const NON_CALLABLE_TYPES = new Set(['class', 'struct', 'interface', 'type', 'enum', 'trait', 'state', 'impl', 'field']);
52
52
 
53
53
  module.exports = { pickBestDefinition, addTestExclusions, escapeRegExp, NON_CALLABLE_TYPES };