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/.claude/skills/ucn/SKILL.md +34 -4
- package/README.md +16 -13
- package/cli/index.js +112 -15
- package/core/cache.js +176 -51
- package/core/callers.js +350 -52
- package/core/deadcode.js +143 -17
- package/core/discovery.js +1 -1
- package/core/execute.js +245 -11
- package/core/output.js +423 -4
- package/core/project.js +1204 -94
- package/core/registry.js +18 -7
- package/core/shared.js +1 -1
- package/core/stacktrace.js +31 -2
- package/core/verify.js +11 -0
- package/languages/go.js +338 -24
- package/languages/index.js +20 -1
- package/languages/java.js +145 -6
- package/languages/javascript.js +199 -8
- package/languages/python.js +8 -2
- package/languages/rust.js +168 -8
- package/mcp/server.js +79 -17
- package/package.json +1 -1
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 ||
|
|
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
|
-
|
|
211
|
-
|
|
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
|
-
//
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
for (
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
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
|
-
|
|
452
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
910
|
-
for (const importer of
|
|
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
|
-
|
|
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 =
|
|
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
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
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
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
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
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
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
|
|
2744
|
-
//
|
|
2745
|
-
//
|
|
2746
|
-
|
|
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
|
-
//
|
|
2820
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
2844
|
-
|
|
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
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
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
|
-
|
|
2865
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3046
|
-
const
|
|
3047
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|