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/.claude/skills/ucn/SKILL.md +6 -4
- package/README.md +23 -24
- package/cli/index.js +14 -6
- package/core/cache.js +176 -51
- package/core/callers.js +315 -51
- package/core/deadcode.js +42 -16
- package/core/discovery.js +1 -1
- package/core/execute.js +148 -11
- package/core/output.js +26 -4
- package/core/project.js +290 -52
- package/core/registry.js +1 -0
- package/core/shared.js +1 -1
- package/core/stacktrace.js +31 -2
- package/core/verify.js +11 -0
- package/languages/go.js +331 -23
- package/languages/index.js +20 -1
- package/languages/java.js +109 -4
- package/languages/rust.js +93 -4
- package/mcp/server.js +33 -16
- package/package.json +11 -10
package/core/callers.js
CHANGED
|
@@ -12,6 +12,22 @@ const { detectLanguage, getParser, getLanguageModule } = require('../languages')
|
|
|
12
12
|
const { isTestFile } = require('./discovery');
|
|
13
13
|
const { NON_CALLABLE_TYPES } = require('./shared');
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Extract a single line from content without splitting the entire string.
|
|
17
|
+
* @param {string} content - Full file content
|
|
18
|
+
* @param {number} lineNum - 1-indexed line number
|
|
19
|
+
* @returns {string} The line content
|
|
20
|
+
*/
|
|
21
|
+
function getLine(content, lineNum) {
|
|
22
|
+
let start = 0;
|
|
23
|
+
for (let i = 1; i < lineNum; i++) {
|
|
24
|
+
start = content.indexOf('\n', start) + 1;
|
|
25
|
+
if (start === 0) return ''; // past end
|
|
26
|
+
}
|
|
27
|
+
const end = content.indexOf('\n', start);
|
|
28
|
+
return end === -1 ? content.slice(start) : content.slice(start, end);
|
|
29
|
+
}
|
|
30
|
+
|
|
15
31
|
/**
|
|
16
32
|
* Get cached call sites for a file, with mtime/hash validation
|
|
17
33
|
* Uses mtime for fast cache validation, falls back to hash if mtime matches but content changed
|
|
@@ -63,7 +79,16 @@ function getCachedCalls(index, filePath, options = {}) {
|
|
|
63
79
|
if (!langModule.findCallsInCode) return null;
|
|
64
80
|
|
|
65
81
|
const parser = getParser(language);
|
|
66
|
-
|
|
82
|
+
// Pass import alias names to Go parser for package vs method call disambiguation
|
|
83
|
+
// importNames contains resolved alias names (e.g., 'utilversion' for renamed imports)
|
|
84
|
+
const callOpts = {};
|
|
85
|
+
if (language === 'go') {
|
|
86
|
+
const fileEntry = index.files.get(filePath);
|
|
87
|
+
if (fileEntry?.importNames) {
|
|
88
|
+
callOpts.imports = fileEntry.importNames;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const calls = langModule.findCallsInCode(content, parser, callOpts);
|
|
67
92
|
|
|
68
93
|
index.callsCache.set(filePath, {
|
|
69
94
|
mtime,
|
|
@@ -92,6 +117,9 @@ function getCachedCalls(index, filePath, options = {}) {
|
|
|
92
117
|
function findCallers(index, name, options = {}) {
|
|
93
118
|
index._beginOp();
|
|
94
119
|
try {
|
|
120
|
+
// Lazy-load callsCache from disk if not already populated
|
|
121
|
+
if (index.loadCallsCache) index.loadCallsCache();
|
|
122
|
+
|
|
95
123
|
const callers = [];
|
|
96
124
|
const stats = options.stats;
|
|
97
125
|
|
|
@@ -102,13 +130,18 @@ function findCallers(index, name, options = {}) {
|
|
|
102
130
|
definitionLines.add(`${def.file}:${def.startLine}`);
|
|
103
131
|
}
|
|
104
132
|
|
|
133
|
+
// Phase 1: Find matching calls without reading file content.
|
|
134
|
+
// Collect pending callers keyed by file — content is read only in Phase 2.
|
|
135
|
+
const pendingByFile = new Map(); // filePath -> [{ call, fileEntry, callerSymbol, isMethod, isFunctionReference, receiver }]
|
|
136
|
+
let pendingCount = 0;
|
|
137
|
+
const maxResults = options.maxResults;
|
|
138
|
+
|
|
105
139
|
for (const [filePath, fileEntry] of index.files) {
|
|
140
|
+
// Early exit when maxResults is reached
|
|
141
|
+
if (maxResults && pendingCount >= maxResults) break;
|
|
106
142
|
try {
|
|
107
|
-
const
|
|
108
|
-
if (!
|
|
109
|
-
|
|
110
|
-
const { calls, content } = result;
|
|
111
|
-
const lines = content.split('\n');
|
|
143
|
+
const calls = getCachedCalls(index, filePath);
|
|
144
|
+
if (!calls) continue;
|
|
112
145
|
|
|
113
146
|
for (const call of calls) {
|
|
114
147
|
// Skip if not matching our target name (also check alias resolution)
|
|
@@ -120,31 +153,53 @@ function findCallers(index, name, options = {}) {
|
|
|
120
153
|
if (call.isPotentialCallback) {
|
|
121
154
|
const syms = definitions;
|
|
122
155
|
if (!syms || syms.length === 0) continue;
|
|
156
|
+
|
|
157
|
+
// Go unexported visibility: lowercase functions are package-private.
|
|
158
|
+
// Only allow callers from the same package directory.
|
|
159
|
+
if (fileEntry.language === 'go' && /^[a-z]/.test(name)) {
|
|
160
|
+
const targetDefs = options.targetDefinitions || definitions;
|
|
161
|
+
const targetPkgDirs = new Set(
|
|
162
|
+
targetDefs.filter(d => d.file).map(d => path.dirname(d.file))
|
|
163
|
+
);
|
|
164
|
+
if (targetPkgDirs.size > 0 && !targetPkgDirs.has(path.dirname(filePath))) {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Go/Java/Rust receiver disambiguation for callbacks (e.g. dc.worker)
|
|
170
|
+
if (call.isMethod && call.receiver &&
|
|
171
|
+
(fileEntry.language === 'go' || fileEntry.language === 'java' || fileEntry.language === 'rust')) {
|
|
172
|
+
const targetDefs = options.targetDefinitions || definitions;
|
|
173
|
+
const targetTypes = new Set();
|
|
174
|
+
for (const td of targetDefs) {
|
|
175
|
+
if (td.className) targetTypes.add(td.className);
|
|
176
|
+
if (td.receiver) targetTypes.add(td.receiver.replace(/^\*/, ''));
|
|
177
|
+
}
|
|
178
|
+
if (targetTypes.size > 0 && call.receiverType) {
|
|
179
|
+
if (!targetTypes.has(call.receiverType)) continue;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
123
183
|
// Find the enclosing function
|
|
124
184
|
const callerSymbol = index.findEnclosingFunction(filePath, call.line, true);
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
content: lines[call.line - 1] || '',
|
|
130
|
-
callerName: callerSymbol ? callerSymbol.name : null,
|
|
131
|
-
callerFile: callerSymbol ? filePath : null,
|
|
132
|
-
callerStartLine: callerSymbol ? callerSymbol.startLine : null,
|
|
133
|
-
callerEndLine: callerSymbol ? callerSymbol.endLine : null,
|
|
134
|
-
isMethod: false,
|
|
135
|
-
isFunctionReference: true
|
|
185
|
+
if (!pendingByFile.has(filePath)) pendingByFile.set(filePath, []);
|
|
186
|
+
pendingByFile.get(filePath).push({
|
|
187
|
+
call, fileEntry, callerSymbol,
|
|
188
|
+
isMethod: false, isFunctionReference: true, receiver: undefined
|
|
136
189
|
});
|
|
190
|
+
pendingCount++;
|
|
137
191
|
continue;
|
|
138
192
|
}
|
|
139
193
|
|
|
140
194
|
// Resolve binding within this file (without mutating cached call objects)
|
|
141
195
|
let bindingId = call.bindingId;
|
|
142
196
|
let isUncertain = call.uncertain;
|
|
143
|
-
// Skip binding resolution for
|
|
197
|
+
// Skip binding resolution for calls with non-self/this/cls receivers:
|
|
144
198
|
// e.g., analyzer.analyze_instrument() should NOT resolve to a local
|
|
145
199
|
// standalone function def `analyze_instrument` — they're different symbols.
|
|
200
|
+
// Also skip for Go package-qualified calls (isMethod:false but has receiver like 'cli')
|
|
146
201
|
const selfReceivers = new Set(['self', 'cls', 'this', 'super']);
|
|
147
|
-
const skipLocalBinding = call.
|
|
202
|
+
const skipLocalBinding = call.receiver && !selfReceivers.has(call.receiver);
|
|
148
203
|
if (!bindingId && !skipLocalBinding) {
|
|
149
204
|
let bindings = (fileEntry.bindings || []).filter(b => b.name === call.name);
|
|
150
205
|
// For Go, also check sibling files in same directory (same package scope)
|
|
@@ -306,34 +361,78 @@ function findCallers(index, name, options = {}) {
|
|
|
306
361
|
continue;
|
|
307
362
|
}
|
|
308
363
|
|
|
364
|
+
// Go unexported visibility: lowercase functions are package-private.
|
|
365
|
+
// Only allow callers from the same package directory.
|
|
366
|
+
if (fileEntry.language === 'go' && /^[a-z]/.test(name)) {
|
|
367
|
+
const targetPkgDirs = new Set(
|
|
368
|
+
targetDefs.filter(d => d.file).map(d => path.dirname(d.file))
|
|
369
|
+
);
|
|
370
|
+
if (targetPkgDirs.size > 0 && !targetPkgDirs.has(path.dirname(filePath))) {
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Go/Java/Rust: method vs non-method cross-matching filter.
|
|
376
|
+
// Prevents t.Errorf() (method call) from matching standalone func Errorf,
|
|
377
|
+
// and cli.Run() (package call, isMethod:false) from matching DeploymentController.Run.
|
|
378
|
+
if (!bindingId && !resolvedBySameClass &&
|
|
379
|
+
(fileEntry.language === 'go' || fileEntry.language === 'java' || fileEntry.language === 'rust')) {
|
|
380
|
+
const targetHasClass = targetDefs.some(d => d.className);
|
|
381
|
+
if (call.isMethod && !targetHasClass) {
|
|
382
|
+
// Method call but target is a standalone function — skip
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
if (!call.isMethod && targetHasClass) {
|
|
386
|
+
// Non-method call but target is a class method — skip
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
309
391
|
// Java/Go/Rust receiver-class disambiguation:
|
|
310
|
-
// When
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
// This prevents false positives like reporting obj.save() as a caller of
|
|
314
|
-
// TargetClass.save() when obj is clearly a different type.
|
|
392
|
+
// When the target definition has a class/receiver type, filter callers
|
|
393
|
+
// whose receiverType is known to be a different type.
|
|
394
|
+
// Go uses inferred receiverType; Java/Rust fall back to variable name matching.
|
|
315
395
|
if (call.isMethod && call.receiver && !resolvedBySameClass && !bindingId &&
|
|
316
|
-
options.targetDefinitions && definitions.length > 1 &&
|
|
317
396
|
(fileEntry.language === 'java' || fileEntry.language === 'go' || fileEntry.language === 'rust')) {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
const
|
|
329
|
-
if (
|
|
330
|
-
//
|
|
397
|
+
// Build target type set from both className (Java) and receiver (Go/Rust)
|
|
398
|
+
const targetTypes = new Set();
|
|
399
|
+
for (const td of targetDefs) {
|
|
400
|
+
if (td.className) targetTypes.add(td.className);
|
|
401
|
+
if (td.receiver) targetTypes.add(td.receiver.replace(/^\*/, ''));
|
|
402
|
+
}
|
|
403
|
+
if (targetTypes.size > 0) {
|
|
404
|
+
// Use inferred receiverType when available (Go/Java/Rust parameter type tracking)
|
|
405
|
+
const knownType = call.receiverType;
|
|
406
|
+
if (knownType) {
|
|
407
|
+
const matchesTarget = targetTypes.has(knownType);
|
|
408
|
+
if (!matchesTarget) {
|
|
409
|
+
// Known type doesn't match target — skip directly
|
|
331
410
|
isUncertain = true;
|
|
332
411
|
if (!options.includeUncertain) {
|
|
333
412
|
if (stats) stats.uncertain = (stats.uncertain || 0) + 1;
|
|
334
413
|
continue;
|
|
335
414
|
}
|
|
336
415
|
}
|
|
416
|
+
} else if (options.targetDefinitions && definitions.length > 1) {
|
|
417
|
+
// No inferred type — fall back to receiver variable name matching
|
|
418
|
+
// only when we have multiple definitions to disambiguate
|
|
419
|
+
const receiverLower = call.receiver.toLowerCase();
|
|
420
|
+
const matchesTarget = [...targetTypes].some(cn => cn.toLowerCase() === receiverLower);
|
|
421
|
+
if (!matchesTarget) {
|
|
422
|
+
const nonTargetClasses = new Set();
|
|
423
|
+
for (const d of definitions) {
|
|
424
|
+
const t = d.className || (d.receiver && d.receiver.replace(/^\*/, ''));
|
|
425
|
+
if (t && !targetTypes.has(t)) nonTargetClasses.add(t);
|
|
426
|
+
}
|
|
427
|
+
const matchesOther = [...nonTargetClasses].some(cn => cn.toLowerCase() === receiverLower);
|
|
428
|
+
if (matchesOther) {
|
|
429
|
+
isUncertain = true;
|
|
430
|
+
if (!options.includeUncertain) {
|
|
431
|
+
if (stats) stats.uncertain = (stats.uncertain || 0) + 1;
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
337
436
|
}
|
|
338
437
|
}
|
|
339
438
|
}
|
|
@@ -341,22 +440,43 @@ function findCallers(index, name, options = {}) {
|
|
|
341
440
|
// Find the enclosing function (get full symbol info)
|
|
342
441
|
const callerSymbol = index.findEnclosingFunction(filePath, call.line, true);
|
|
343
442
|
|
|
443
|
+
if (!pendingByFile.has(filePath)) pendingByFile.set(filePath, []);
|
|
444
|
+
pendingByFile.get(filePath).push({
|
|
445
|
+
call, fileEntry, callerSymbol,
|
|
446
|
+
isMethod: call.isMethod || false, isFunctionReference: false,
|
|
447
|
+
receiver: call.receiver,
|
|
448
|
+
receiverType: call.receiverType
|
|
449
|
+
});
|
|
450
|
+
pendingCount++;
|
|
451
|
+
}
|
|
452
|
+
} catch (e) {
|
|
453
|
+
// Expected: minified files exceed tree-sitter buffer, binary files fail to parse.
|
|
454
|
+
// These are not actionable errors — silently skip.
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Phase 2: Read content only for files with matching calls (eliminates ~98% of file reads)
|
|
459
|
+
for (const [filePath, pending] of pendingByFile) {
|
|
460
|
+
try {
|
|
461
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
462
|
+
for (const { call, fileEntry, callerSymbol, isMethod, isFunctionReference, receiver, receiverType } of pending) {
|
|
344
463
|
callers.push({
|
|
345
464
|
file: filePath,
|
|
346
465
|
relativePath: fileEntry.relativePath,
|
|
347
466
|
line: call.line,
|
|
348
|
-
content:
|
|
467
|
+
content: getLine(content, call.line),
|
|
349
468
|
callerName: callerSymbol ? callerSymbol.name : null,
|
|
350
469
|
callerFile: callerSymbol ? filePath : null,
|
|
351
470
|
callerStartLine: callerSymbol ? callerSymbol.startLine : null,
|
|
352
471
|
callerEndLine: callerSymbol ? callerSymbol.endLine : null,
|
|
353
|
-
isMethod
|
|
354
|
-
|
|
472
|
+
isMethod,
|
|
473
|
+
...(isFunctionReference && { isFunctionReference: true }),
|
|
474
|
+
...(receiver !== undefined && { receiver }),
|
|
475
|
+
...(receiverType && { receiverType })
|
|
355
476
|
});
|
|
356
477
|
}
|
|
357
478
|
} catch (e) {
|
|
358
|
-
//
|
|
359
|
-
// These are not actionable errors — silently skip.
|
|
479
|
+
// File may have been deleted between Phase 1 and Phase 2
|
|
360
480
|
}
|
|
361
481
|
}
|
|
362
482
|
|
|
@@ -374,6 +494,9 @@ function findCallers(index, name, options = {}) {
|
|
|
374
494
|
function findCallees(index, def, options = {}) {
|
|
375
495
|
index._beginOp();
|
|
376
496
|
try {
|
|
497
|
+
// Lazy-load callsCache from disk if not already populated
|
|
498
|
+
if (index.loadCallsCache) index.loadCallsCache();
|
|
499
|
+
|
|
377
500
|
try {
|
|
378
501
|
// Get all calls from the file's cache (now includes enclosingFunction)
|
|
379
502
|
const calls = getCachedCalls(index, def.file);
|
|
@@ -403,6 +526,8 @@ function findCallees(index, def, options = {}) {
|
|
|
403
526
|
let localTypes = null;
|
|
404
527
|
if (language === 'python' || language === 'javascript') {
|
|
405
528
|
localTypes = _buildLocalTypeMap(index, def, calls);
|
|
529
|
+
} else if (language === 'go' || language === 'java' || language === 'rust') {
|
|
530
|
+
localTypes = _buildTypedLocalTypeMap(index, def, calls);
|
|
406
531
|
}
|
|
407
532
|
|
|
408
533
|
for (const call of calls) {
|
|
@@ -434,11 +559,17 @@ function findCallees(index, def, options = {}) {
|
|
|
434
559
|
} else if (localTypes && localTypes.has(call.receiver)) {
|
|
435
560
|
// Resolve method calls on locally-constructed objects:
|
|
436
561
|
// bt = Backtester(...); bt.run_backtest() → Backtester.run_backtest
|
|
437
|
-
|
|
562
|
+
// Go: f.Run() where f is *Framework → Framework.Run (receiver match)
|
|
563
|
+
const typeName = localTypes.get(call.receiver);
|
|
438
564
|
const symbols = index.symbols.get(call.name);
|
|
439
|
-
const
|
|
565
|
+
const isCallable = (s) => !NON_CALLABLE_TYPES.has(s.type) ||
|
|
566
|
+
(s.type === 'field' && s.fieldType && /^func\b/.test(s.fieldType));
|
|
567
|
+
const match = symbols?.find(s =>
|
|
568
|
+
isCallable(s) && (
|
|
569
|
+
s.className === typeName ||
|
|
570
|
+
(s.receiver && s.receiver.replace(/^\*/, '') === typeName)));
|
|
440
571
|
if (match) {
|
|
441
|
-
const key = match.bindingId || `${
|
|
572
|
+
const key = match.bindingId || `${typeName}.${call.name}`;
|
|
442
573
|
const existing = callees.get(key);
|
|
443
574
|
if (existing) {
|
|
444
575
|
existing.count += 1;
|
|
@@ -447,6 +578,64 @@ function findCallees(index, def, options = {}) {
|
|
|
447
578
|
}
|
|
448
579
|
}
|
|
449
580
|
continue;
|
|
581
|
+
} else if (call.receiverType && (language === 'go' || language === 'java' || language === 'rust')) {
|
|
582
|
+
// Use parser-inferred receiverType for method resolution
|
|
583
|
+
// e.g., f.RunFilter() where f is *Framework → resolve to Framework.RunFilter
|
|
584
|
+
const typeName = call.receiverType;
|
|
585
|
+
const symbols = index.symbols.get(call.name);
|
|
586
|
+
const isCallableRT = (s) => !NON_CALLABLE_TYPES.has(s.type) ||
|
|
587
|
+
(s.type === 'field' && s.fieldType && /^func\b/.test(s.fieldType));
|
|
588
|
+
const match = symbols?.find(s =>
|
|
589
|
+
isCallableRT(s) && (
|
|
590
|
+
(s.receiver && s.receiver.replace(/^\*/, '') === typeName) ||
|
|
591
|
+
s.className === typeName));
|
|
592
|
+
if (match) {
|
|
593
|
+
const key = match.bindingId || `${typeName}.${call.name}`;
|
|
594
|
+
const existing = callees.get(key);
|
|
595
|
+
if (existing) {
|
|
596
|
+
existing.count += 1;
|
|
597
|
+
} else {
|
|
598
|
+
callees.set(key, { name: call.name, bindingId: match.bindingId, count: 1 });
|
|
599
|
+
}
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
// No match found with inferred type — fall through to include as unresolved
|
|
603
|
+
} else if (language === 'go' && call.receiver) {
|
|
604
|
+
// Go package-qualified calls: klog.Infof(), wait.UntilWithContext()
|
|
605
|
+
// Check if receiver is an import alias and resolve to correct package
|
|
606
|
+
const goImports = fileEntry?.imports || [];
|
|
607
|
+
// Find import whose package name matches the receiver
|
|
608
|
+
const importModule = goImports.find(mod => {
|
|
609
|
+
const pkgName = mod.split('/').pop();
|
|
610
|
+
return pkgName === call.receiver;
|
|
611
|
+
});
|
|
612
|
+
if (importModule) {
|
|
613
|
+
// Receiver is an import alias — resolve to definitions from that package
|
|
614
|
+
const symbols = index.symbols.get(call.name);
|
|
615
|
+
if (symbols) {
|
|
616
|
+
// Match by checking if the definition's directory path matches the import path suffix
|
|
617
|
+
const importParts = importModule.split('/');
|
|
618
|
+
const match = symbols.find(s => {
|
|
619
|
+
const sDir = path.dirname(s.relativePath || path.relative(index.root, s.file));
|
|
620
|
+
// Try matching progressively shorter suffixes of the import path
|
|
621
|
+
for (let i = importParts.length - 1; i >= 0; i--) {
|
|
622
|
+
const suffix = importParts.slice(i).join('/');
|
|
623
|
+
if (sDir === suffix || sDir.endsWith('/' + suffix)) return true;
|
|
624
|
+
}
|
|
625
|
+
return false;
|
|
626
|
+
});
|
|
627
|
+
if (match) {
|
|
628
|
+
const key = match.bindingId || `${call.receiver}.${call.name}`;
|
|
629
|
+
const existing = callees.get(key);
|
|
630
|
+
if (existing) {
|
|
631
|
+
existing.count += 1;
|
|
632
|
+
} else {
|
|
633
|
+
callees.set(key, { name: call.name, bindingId: match.bindingId, count: 1 });
|
|
634
|
+
}
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
450
639
|
} else if (language !== 'go' && language !== 'java' && language !== 'rust' && !options.includeMethods) {
|
|
451
640
|
continue;
|
|
452
641
|
}
|
|
@@ -544,7 +733,23 @@ function findCallees(index, def, options = {}) {
|
|
|
544
733
|
// (cross-type ambiguity, e.g. TypeA.Length vs TypeB.Length).
|
|
545
734
|
const defs = index.symbols.get(call.name);
|
|
546
735
|
if (defs && defs.length > 1) {
|
|
547
|
-
|
|
736
|
+
// Go: if receiverType is known, check if it matches exactly one def
|
|
737
|
+
// This resolves ambiguity like Framework.Run vs Scheduler.Run
|
|
738
|
+
const rType = call.receiverType || localTypes?.get(call.receiver);
|
|
739
|
+
if (rType && (language === 'go' || language === 'java' || language === 'rust')) {
|
|
740
|
+
const matchingDef = defs.find(d =>
|
|
741
|
+
d.className === rType ||
|
|
742
|
+
(d.receiver && d.receiver.replace(/^\*/, '') === rType));
|
|
743
|
+
if (matchingDef) {
|
|
744
|
+
// Resolved to specific type — not uncertain
|
|
745
|
+
calleeKey = matchingDef.bindingId || `${rType}.${call.name}`;
|
|
746
|
+
bindingResolved = matchingDef.bindingId;
|
|
747
|
+
} else {
|
|
748
|
+
isUncertain = true;
|
|
749
|
+
}
|
|
750
|
+
} else {
|
|
751
|
+
isUncertain = true;
|
|
752
|
+
}
|
|
548
753
|
}
|
|
549
754
|
}
|
|
550
755
|
}
|
|
@@ -769,10 +974,10 @@ function findCallees(index, def, options = {}) {
|
|
|
769
974
|
callee = sameDir;
|
|
770
975
|
} else {
|
|
771
976
|
// Priority 2.5: Imported file — check if the caller's file imports
|
|
772
|
-
// from any of the candidate callee files
|
|
773
|
-
const
|
|
774
|
-
const
|
|
775
|
-
const importedCallee = symbols.find(s =>
|
|
977
|
+
// from any of the candidate callee files (using importGraph)
|
|
978
|
+
const callerImportedFiles = index.importGraph.get(def.file) || [];
|
|
979
|
+
const importedFileSet = new Set(callerImportedFiles);
|
|
980
|
+
const importedCallee = symbols.find(s => importedFileSet.has(s.file));
|
|
776
981
|
if (importedCallee) {
|
|
777
982
|
callee = importedCallee;
|
|
778
983
|
} else if (defReceiver) {
|
|
@@ -835,6 +1040,18 @@ function findCallees(index, def, options = {}) {
|
|
|
835
1040
|
}
|
|
836
1041
|
}
|
|
837
1042
|
|
|
1043
|
+
// Skip non-callable types (interface, struct, type) as callees.
|
|
1044
|
+
// These appear when local variables shadow symbol names
|
|
1045
|
+
// (e.g., `for _, handler := range handlers { handler(r) }` —
|
|
1046
|
+
// handler is a local var, not the handler interface type).
|
|
1047
|
+
// Exception: function-typed fields (e.g., syncHandler func(...))
|
|
1048
|
+
// are callable via Go dependency injection patterns.
|
|
1049
|
+
if (!bindingId && NON_CALLABLE_TYPES.has(callee.type)) {
|
|
1050
|
+
const isFuncField = callee.type === 'field' && callee.fieldType &&
|
|
1051
|
+
/^func\b/.test(callee.fieldType);
|
|
1052
|
+
if (!isFuncField) continue;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
838
1055
|
// Skip test-file callees when caller is production code and
|
|
839
1056
|
// there's no binding (import) evidence linking them
|
|
840
1057
|
if (!callerIsTest && !bindingId) {
|
|
@@ -950,6 +1167,53 @@ function _buildLocalTypeMap(index, def, calls) {
|
|
|
950
1167
|
return localTypes.size > 0 ? localTypes : null;
|
|
951
1168
|
}
|
|
952
1169
|
|
|
1170
|
+
/**
|
|
1171
|
+
* Build a local variable type map for typed languages (Go, Java, Rust)
|
|
1172
|
+
* using parser-inferred receiverType from call objects.
|
|
1173
|
+
* Go also resolves New*() constructor patterns.
|
|
1174
|
+
* @param {object} index - ProjectIndex instance
|
|
1175
|
+
* @param {object} def - Function definition with file, startLine, endLine
|
|
1176
|
+
* @param {Array} calls - Cached call sites for the file
|
|
1177
|
+
*/
|
|
1178
|
+
function _buildTypedLocalTypeMap(index, def, calls) {
|
|
1179
|
+
const localTypes = new Map();
|
|
1180
|
+
|
|
1181
|
+
for (const call of calls) {
|
|
1182
|
+
if (call.line < def.startLine || call.line > def.endLine) continue;
|
|
1183
|
+
|
|
1184
|
+
// Collect receiverType from method calls (inferred by parser from params/receivers)
|
|
1185
|
+
if (call.isMethod && call.receiver && call.receiverType) {
|
|
1186
|
+
localTypes.set(call.receiver, call.receiverType);
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// Collect types from constructor calls: x := NewFoo() → x maps to Foo
|
|
1190
|
+
// Handles: x := NewFoo(), x, err := NewFoo(), x := pkg.NewFoo(), x, err := pkg.NewFoo()
|
|
1191
|
+
const newName = call.isMethod ? call.name : call.name;
|
|
1192
|
+
if (/^New[A-Z]/.test(newName) && !call.isPotentialCallback) {
|
|
1193
|
+
let content;
|
|
1194
|
+
try {
|
|
1195
|
+
content = index._readFile(def.file);
|
|
1196
|
+
} catch { continue; }
|
|
1197
|
+
const lines = content.split('\n');
|
|
1198
|
+
const sourceLine = lines[call.line - 1];
|
|
1199
|
+
if (!sourceLine) continue;
|
|
1200
|
+
// Match: x := [pkg.]NewFoo( or x, err := [pkg.]NewFoo( or x, _ := [pkg.]NewFoo(
|
|
1201
|
+
const assignMatch = sourceLine.match(
|
|
1202
|
+
/(\w+)(?:\s*,\s*\w+)?\s*:=\s*(?:\w+\.)?(\w+)\s*\(/
|
|
1203
|
+
);
|
|
1204
|
+
if (assignMatch && /^New[A-Z]/.test(assignMatch[2])) {
|
|
1205
|
+
// NewFoo → Foo, NewFooBar → FooBar
|
|
1206
|
+
const typeName = assignMatch[2].slice(3);
|
|
1207
|
+
if (typeName && /^[A-Z]/.test(typeName)) {
|
|
1208
|
+
localTypes.set(assignMatch[1], typeName);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
return localTypes.size > 0 ? localTypes : null;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
953
1217
|
/**
|
|
954
1218
|
* Check if a function is used as a callback anywhere in the codebase
|
|
955
1219
|
* @param {object} index - ProjectIndex instance
|
package/core/deadcode.js
CHANGED
|
@@ -9,12 +9,13 @@ const { detectLanguage, getParser, getLanguageModule, safeParse } = require('../
|
|
|
9
9
|
const { isTestFile } = require('./discovery');
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* Build a usage index for
|
|
12
|
+
* Build a usage index for identifiers in the codebase (optimized for deadcode)
|
|
13
13
|
* Scans all files ONCE and builds a reverse index: name -> [usages]
|
|
14
14
|
* @param {object} index - ProjectIndex instance
|
|
15
|
+
* @param {Set<string>} [filterNames] - If provided, only track these names (reduces memory)
|
|
15
16
|
* @returns {Map<string, Array>} Usage index
|
|
16
17
|
*/
|
|
17
|
-
function buildUsageIndex(index) {
|
|
18
|
+
function buildUsageIndex(index, filterNames) {
|
|
18
19
|
const usageIndex = new Map(); // name -> [{file, line}]
|
|
19
20
|
|
|
20
21
|
for (const [filePath, fileEntry] of index.files) {
|
|
@@ -72,6 +73,8 @@ function buildUsageIndex(index) {
|
|
|
72
73
|
}
|
|
73
74
|
}
|
|
74
75
|
// Member expression property: obj.Separator — not a standalone reference
|
|
76
|
+
// EXCEPTION: If the selector/member expression is part of a call_expression,
|
|
77
|
+
// this IS a method call (e.g., dc.syncDeployment()) and should count as usage.
|
|
75
78
|
if (parentType === 'member_expression' ||
|
|
76
79
|
parentType === 'field_expression' ||
|
|
77
80
|
parentType === 'member_access_expression' ||
|
|
@@ -82,15 +85,31 @@ function buildUsageIndex(index) {
|
|
|
82
85
|
// by checking if it's NOT the object (left side)
|
|
83
86
|
const firstChild = node.parent.child(0);
|
|
84
87
|
if (firstChild !== node) {
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
88
|
+
// Check if this member expression is part of a call or
|
|
89
|
+
// used as a function reference (callback argument)
|
|
90
|
+
const grandparent = node.parent.parent;
|
|
91
|
+
const isCall = grandparent &&
|
|
92
|
+
(grandparent.type === 'call_expression' ||
|
|
93
|
+
grandparent.type === 'argument_list' ||
|
|
94
|
+
grandparent.type === 'arguments');
|
|
95
|
+
if (!isCall) {
|
|
96
|
+
// Pure field access — skip for deadcode counting
|
|
97
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
98
|
+
traverse(node.child(i));
|
|
99
|
+
}
|
|
100
|
+
return;
|
|
88
101
|
}
|
|
89
|
-
|
|
102
|
+
// Method call or callback — fall through to count as usage
|
|
90
103
|
}
|
|
91
104
|
}
|
|
92
105
|
}
|
|
93
106
|
const name = node.text;
|
|
107
|
+
if (filterNames && !filterNames.has(name)) {
|
|
108
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
109
|
+
traverse(node.child(i));
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
94
113
|
if (!usageIndex.has(name)) {
|
|
95
114
|
usageIndex.set(name, []);
|
|
96
115
|
}
|
|
@@ -144,19 +163,21 @@ function deadcode(index, options = {}) {
|
|
|
144
163
|
let excludedDecorated = 0;
|
|
145
164
|
let excludedExported = 0;
|
|
146
165
|
|
|
147
|
-
//
|
|
148
|
-
const
|
|
166
|
+
// Collect callable symbol names to reduce usage index scope
|
|
167
|
+
const callableTypes = ['function', 'method', 'static', 'public', 'abstract', 'constructor'];
|
|
168
|
+
const callableNames = new Set();
|
|
169
|
+
for (const [symbolName, symbols] of index.symbols) {
|
|
170
|
+
if (symbols.some(s => callableTypes.includes(s.type))) {
|
|
171
|
+
callableNames.add(symbolName);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Build usage index once (instead of per-symbol), filtered to callable names only
|
|
176
|
+
const usageIndex = buildUsageIndex(index, callableNames);
|
|
149
177
|
|
|
150
178
|
for (const [name, symbols] of index.symbols) {
|
|
151
179
|
for (const symbol of symbols) {
|
|
152
|
-
// Skip non-function/class types
|
|
153
|
-
// Include various method types from different languages:
|
|
154
|
-
// - function: standalone functions
|
|
155
|
-
// - class, struct, interface: type definitions (skip them in deadcode)
|
|
156
|
-
// - method: class methods
|
|
157
|
-
// - static, public, abstract: Java method modifiers used as types
|
|
158
|
-
// - constructor: constructors
|
|
159
|
-
const callableTypes = ['function', 'method', 'static', 'public', 'abstract', 'constructor'];
|
|
180
|
+
// Skip non-function/class types (callableTypes defined above)
|
|
160
181
|
if (!callableTypes.includes(symbol.type)) {
|
|
161
182
|
continue;
|
|
162
183
|
}
|
|
@@ -174,6 +195,11 @@ function deadcode(index, options = {}) {
|
|
|
174
195
|
continue;
|
|
175
196
|
}
|
|
176
197
|
|
|
198
|
+
// Apply file filter (scopes deadcode to matching files)
|
|
199
|
+
if (options.file && !symbol.relativePath.includes(options.file)) {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
177
203
|
// Apply exclude and in filters
|
|
178
204
|
if ((options.exclude && options.exclude.length > 0) || options.in) {
|
|
179
205
|
if (!index.matchesFilters(symbol.relativePath, { exclude: options.exclude, in: options.in })) {
|
package/core/discovery.js
CHANGED
|
@@ -191,7 +191,7 @@ function expandGlob(pattern, options = {}) {
|
|
|
191
191
|
const root = path.resolve(options.root || process.cwd());
|
|
192
192
|
const ignores = options.ignores || DEFAULT_IGNORES;
|
|
193
193
|
const maxDepth = options.maxDepth || 20;
|
|
194
|
-
const maxFiles = options.maxFiles ||
|
|
194
|
+
const maxFiles = options.maxFiles || 50000;
|
|
195
195
|
const followSymlinks = options.followSymlinks !== false; // default true
|
|
196
196
|
|
|
197
197
|
// Handle home directory expansion
|