ucn 3.7.23 → 3.7.25
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/cli/index.js +285 -1054
- package/core/cache.js +193 -0
- package/core/callers.js +817 -0
- package/core/deadcode.js +320 -0
- package/core/discovery.js +1 -1
- package/core/execute.js +207 -10
- package/core/expand-cache.js +16 -5
- package/core/imports.js +21 -15
- package/core/output.js +380 -38
- package/core/project.js +365 -2259
- package/core/shared.js +11 -1
- package/core/stacktrace.js +313 -0
- package/core/verify.js +533 -0
- package/languages/go.js +57 -21
- package/languages/html.js +14 -3
- package/languages/java.js +4 -2
- package/languages/javascript.js +36 -9
- package/languages/rust.js +49 -17
- package/mcp/server.js +39 -172
- package/package.json +1 -1
package/core/callers.js
ADDED
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core/callers.js - Call graph resolution (callers, callees, callbacks)
|
|
3
|
+
*
|
|
4
|
+
* Extracted from project.js. All functions take an `index` (ProjectIndex)
|
|
5
|
+
* as the first argument instead of using `this`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const crypto = require('crypto');
|
|
11
|
+
const { detectLanguage, getParser, getLanguageModule } = require('../languages');
|
|
12
|
+
const { isTestFile } = require('./discovery');
|
|
13
|
+
const { NON_CALLABLE_TYPES } = require('./shared');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Get cached call sites for a file, with mtime/hash validation
|
|
17
|
+
* Uses mtime for fast cache validation, falls back to hash if mtime matches but content changed
|
|
18
|
+
* @param {object} index - ProjectIndex instance
|
|
19
|
+
* @param {string} filePath - Path to the file
|
|
20
|
+
* @param {object} [options] - Options
|
|
21
|
+
* @param {boolean} [options.includeContent] - Also return file content (avoids double read)
|
|
22
|
+
* @returns {Array|null|{calls: Array, content: string}} Array of calls, or object with content if requested
|
|
23
|
+
*/
|
|
24
|
+
function getCachedCalls(index, filePath, options = {}) {
|
|
25
|
+
try {
|
|
26
|
+
const cached = index.callsCache.get(filePath);
|
|
27
|
+
|
|
28
|
+
// Fast path: check mtime first (stat is much faster than read+hash)
|
|
29
|
+
const stat = fs.statSync(filePath);
|
|
30
|
+
const mtime = stat.mtimeMs;
|
|
31
|
+
|
|
32
|
+
if (cached && cached.mtime === mtime) {
|
|
33
|
+
// mtime matches - cache is likely valid
|
|
34
|
+
if (options.includeContent) {
|
|
35
|
+
// Need content, read if not cached
|
|
36
|
+
const content = cached.content || index._readFile(filePath);
|
|
37
|
+
return { calls: cached.calls, content };
|
|
38
|
+
}
|
|
39
|
+
return cached.calls;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// mtime changed or no cache - need to read and possibly reparse
|
|
43
|
+
const content = index._readFile(filePath);
|
|
44
|
+
const hash = crypto.createHash('md5').update(content).digest('hex');
|
|
45
|
+
|
|
46
|
+
// Check if content actually changed (mtime can change without content change)
|
|
47
|
+
if (cached && cached.hash === hash) {
|
|
48
|
+
// Content unchanged, just update mtime
|
|
49
|
+
cached.mtime = mtime;
|
|
50
|
+
cached.content = options.includeContent ? content : undefined;
|
|
51
|
+
index.callsCacheDirty = true;
|
|
52
|
+
if (options.includeContent) {
|
|
53
|
+
return { calls: cached.calls, content };
|
|
54
|
+
}
|
|
55
|
+
return cached.calls;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Content changed - need to reparse
|
|
59
|
+
const language = detectLanguage(filePath);
|
|
60
|
+
if (!language) return null;
|
|
61
|
+
|
|
62
|
+
const langModule = getLanguageModule(language);
|
|
63
|
+
if (!langModule.findCallsInCode) return null;
|
|
64
|
+
|
|
65
|
+
const parser = getParser(language);
|
|
66
|
+
const calls = langModule.findCallsInCode(content, parser);
|
|
67
|
+
|
|
68
|
+
index.callsCache.set(filePath, {
|
|
69
|
+
mtime,
|
|
70
|
+
hash,
|
|
71
|
+
calls,
|
|
72
|
+
content: options.includeContent ? content : undefined
|
|
73
|
+
});
|
|
74
|
+
index.callsCacheDirty = true;
|
|
75
|
+
|
|
76
|
+
if (options.includeContent) {
|
|
77
|
+
return { calls, content };
|
|
78
|
+
}
|
|
79
|
+
return calls;
|
|
80
|
+
} catch (e) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Find all callers of a function using AST-based detection
|
|
87
|
+
* @param {object} index - ProjectIndex instance
|
|
88
|
+
* @param {string} name - Function name to find callers for
|
|
89
|
+
* @param {object} [options] - Options
|
|
90
|
+
* @param {boolean} [options.includeMethods] - Include method calls (default: false)
|
|
91
|
+
*/
|
|
92
|
+
function findCallers(index, name, options = {}) {
|
|
93
|
+
index._beginOp();
|
|
94
|
+
try {
|
|
95
|
+
const callers = [];
|
|
96
|
+
const stats = options.stats;
|
|
97
|
+
|
|
98
|
+
// Get definition lines to exclude them
|
|
99
|
+
const definitions = index.symbols.get(name) || [];
|
|
100
|
+
const definitionLines = new Set();
|
|
101
|
+
for (const def of definitions) {
|
|
102
|
+
definitionLines.add(`${def.file}:${def.startLine}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
for (const [filePath, fileEntry] of index.files) {
|
|
106
|
+
try {
|
|
107
|
+
const result = getCachedCalls(index, filePath, { includeContent: true });
|
|
108
|
+
if (!result) continue;
|
|
109
|
+
|
|
110
|
+
const { calls, content } = result;
|
|
111
|
+
const lines = content.split('\n');
|
|
112
|
+
|
|
113
|
+
for (const call of calls) {
|
|
114
|
+
// Skip if not matching our target name (also check alias resolution)
|
|
115
|
+
if (call.name !== name && call.resolvedName !== name &&
|
|
116
|
+
!(call.resolvedNames && call.resolvedNames.includes(name))) continue;
|
|
117
|
+
|
|
118
|
+
// For potential callbacks (function passed as arg), validate against symbol table
|
|
119
|
+
// and skip complex binding resolution — just check the name exists
|
|
120
|
+
if (call.isPotentialCallback) {
|
|
121
|
+
const syms = definitions;
|
|
122
|
+
if (!syms || syms.length === 0) continue;
|
|
123
|
+
// Find the enclosing function
|
|
124
|
+
const callerSymbol = index.findEnclosingFunction(filePath, call.line, true);
|
|
125
|
+
callers.push({
|
|
126
|
+
file: filePath,
|
|
127
|
+
relativePath: fileEntry.relativePath,
|
|
128
|
+
line: call.line,
|
|
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
|
|
136
|
+
});
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Resolve binding within this file (without mutating cached call objects)
|
|
141
|
+
let bindingId = call.bindingId;
|
|
142
|
+
let isUncertain = call.uncertain;
|
|
143
|
+
if (!bindingId) {
|
|
144
|
+
let bindings = (fileEntry.bindings || []).filter(b => b.name === call.name);
|
|
145
|
+
// For Go, also check sibling files in same directory (same package scope)
|
|
146
|
+
if (bindings.length === 0 && fileEntry.language === 'go') {
|
|
147
|
+
const dir = path.dirname(filePath);
|
|
148
|
+
for (const [fp, fe] of index.files) {
|
|
149
|
+
if (fp !== filePath && path.dirname(fp) === dir) {
|
|
150
|
+
const sibling = (fe.bindings || []).filter(b => b.name === call.name);
|
|
151
|
+
bindings = bindings.concat(sibling);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (bindings.length === 1) {
|
|
156
|
+
bindingId = bindings[0].id;
|
|
157
|
+
} else if (bindings.length > 1 && !call.isMethod) {
|
|
158
|
+
// For implicit same-class calls (Java: execute() means this.execute()),
|
|
159
|
+
// try to resolve via caller's className before marking uncertain
|
|
160
|
+
const callerSym = index.findEnclosingFunction(filePath, call.line, true);
|
|
161
|
+
if (callerSym?.className) {
|
|
162
|
+
const callSymbols = index.symbols.get(call.name);
|
|
163
|
+
const sameClassSym = callSymbols?.find(s => s.className === callerSym.className);
|
|
164
|
+
if (sameClassSym) {
|
|
165
|
+
const matchingBinding = bindings.find(b => b.startLine === sameClassSym.startLine);
|
|
166
|
+
bindingId = matchingBinding?.id || sameClassSym.bindingId;
|
|
167
|
+
} else {
|
|
168
|
+
isUncertain = true;
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
// Scope-based disambiguation for shadowed functions:
|
|
172
|
+
// When multiple bindings exist, use indent level to determine
|
|
173
|
+
// which binding is in scope at the call site
|
|
174
|
+
const defs = index.symbols.get(call.name);
|
|
175
|
+
let resolved = false;
|
|
176
|
+
if (defs) {
|
|
177
|
+
// Sort bindings by indent desc (most nested first)
|
|
178
|
+
const scopedBindings = bindings.map(b => {
|
|
179
|
+
const sym = defs.find(s => s.startLine === b.startLine && s.file === filePath);
|
|
180
|
+
return { ...b, indent: sym?.indent ?? 0, endLine: sym?.endLine ?? b.startLine };
|
|
181
|
+
}).sort((a, b) => b.indent - a.indent);
|
|
182
|
+
|
|
183
|
+
for (const sb of scopedBindings) {
|
|
184
|
+
if (sb.indent === 0) {
|
|
185
|
+
// Module-level binding — always in scope, use as fallback
|
|
186
|
+
bindingId = sb.id;
|
|
187
|
+
resolved = true;
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
// Nested binding — check if call is inside its enclosing function
|
|
191
|
+
const enclosing = index.findEnclosingFunction(filePath, sb.startLine, true);
|
|
192
|
+
if (enclosing && call.line >= enclosing.startLine && call.line <= enclosing.endLine) {
|
|
193
|
+
// Call is inside the same function as this binding
|
|
194
|
+
bindingId = sb.id;
|
|
195
|
+
resolved = true;
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (!resolved) isUncertain = true;
|
|
201
|
+
}
|
|
202
|
+
} else if (bindings.length > 1 && call.isMethod) {
|
|
203
|
+
// Multiple method bindings (e.g. Go String() on Reader vs Writer):
|
|
204
|
+
// Don't mark uncertain — include them even if conflated.
|
|
205
|
+
// Better to over-report than lose all callers.
|
|
206
|
+
}
|
|
207
|
+
// Method call with no binding for the method name (JS/TS/Python only):
|
|
208
|
+
// Mark uncertain unless receiver has binding evidence in file scope.
|
|
209
|
+
// Go/Java/Rust excluded: callers are used for impact analysis where
|
|
210
|
+
// over-reporting is preferred to losing callers. These languages' nominal
|
|
211
|
+
// type systems also make method links more reliable.
|
|
212
|
+
if (bindings.length === 0 && call.isMethod &&
|
|
213
|
+
fileEntry.language !== 'go' && fileEntry.language !== 'java' && fileEntry.language !== 'rust') {
|
|
214
|
+
const hasReceiverEvidence = call.receiver &&
|
|
215
|
+
(fileEntry.bindings || []).some(b => b.name === call.receiver);
|
|
216
|
+
if (!hasReceiverEvidence) {
|
|
217
|
+
isUncertain = true;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Smart method call handling — do this BEFORE uncertain check so
|
|
223
|
+
// self/this.method() calls can be resolved by same-class matching
|
|
224
|
+
// even when binding is ambiguous (e.g. method exists in multiple classes)
|
|
225
|
+
let resolvedBySameClass = false;
|
|
226
|
+
if (call.isMethod) {
|
|
227
|
+
if (call.selfAttribute && fileEntry.language === 'python') {
|
|
228
|
+
// self.attr.method() — resolve via attribute type inference
|
|
229
|
+
const callerSymbol = index.findEnclosingFunction(filePath, call.line, true);
|
|
230
|
+
if (!callerSymbol?.className) continue;
|
|
231
|
+
const attrTypes = getInstanceAttributeTypes(index, filePath, callerSymbol.className);
|
|
232
|
+
if (!attrTypes) continue;
|
|
233
|
+
const targetClass = attrTypes.get(call.selfAttribute);
|
|
234
|
+
if (!targetClass) continue;
|
|
235
|
+
// Check if any definition of searched function belongs to targetClass
|
|
236
|
+
const matchesDef = definitions.some(d => d.className === targetClass);
|
|
237
|
+
if (!matchesDef) continue;
|
|
238
|
+
resolvedBySameClass = true;
|
|
239
|
+
// Falls through to add as caller
|
|
240
|
+
} else if (['self', 'cls', 'this', 'super'].includes(call.receiver)) {
|
|
241
|
+
// self/this/super.method() — resolve to same-class or parent method
|
|
242
|
+
const callerSymbol = index.findEnclosingFunction(filePath, call.line, true);
|
|
243
|
+
if (!callerSymbol?.className) continue;
|
|
244
|
+
// For super(), skip same-class — only check parent chain
|
|
245
|
+
let matchesDef = call.receiver === 'super'
|
|
246
|
+
? false
|
|
247
|
+
: definitions.some(d => d.className === callerSymbol.className);
|
|
248
|
+
// Walk inheritance chain using BFS if not found in same class
|
|
249
|
+
if (!matchesDef) {
|
|
250
|
+
const visited = new Set([callerSymbol.className]);
|
|
251
|
+
const callerFile = callerSymbol.file || filePath;
|
|
252
|
+
const startParents = index._getInheritanceParents(callerSymbol.className, callerFile) || [];
|
|
253
|
+
const queue = startParents.map(p => ({ name: p, contextFile: callerFile }));
|
|
254
|
+
while (queue.length > 0 && !matchesDef) {
|
|
255
|
+
const { name: current, contextFile } = queue.shift();
|
|
256
|
+
if (visited.has(current)) continue;
|
|
257
|
+
visited.add(current);
|
|
258
|
+
matchesDef = definitions.some(d => d.className === current);
|
|
259
|
+
if (!matchesDef) {
|
|
260
|
+
const resolvedFile = index._resolveClassFile(current, contextFile);
|
|
261
|
+
const grandparents = index._getInheritanceParents(current, resolvedFile) || [];
|
|
262
|
+
for (const gp of grandparents) {
|
|
263
|
+
if (!visited.has(gp)) queue.push({ name: gp, contextFile: resolvedFile });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (!matchesDef) continue;
|
|
269
|
+
resolvedBySameClass = true;
|
|
270
|
+
// Falls through to add as caller
|
|
271
|
+
} else {
|
|
272
|
+
// Go doesn't use this/self/cls - always include Go method calls
|
|
273
|
+
// Java method calls are always obj.method() - include by default
|
|
274
|
+
// Rust Type::method() calls - include by default (associated functions)
|
|
275
|
+
// For other languages, skip method calls unless explicitly requested
|
|
276
|
+
if (fileEntry.language !== 'go' && fileEntry.language !== 'java' && fileEntry.language !== 'rust' && !options.includeMethods) continue;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Skip uncertain calls unless resolved by same-class matching or explicitly requested
|
|
281
|
+
if (isUncertain && !resolvedBySameClass && !options.includeUncertain) {
|
|
282
|
+
if (stats) stats.uncertain = (stats.uncertain || 0) + 1;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Skip definition lines
|
|
287
|
+
if (definitionLines.has(`${filePath}:${call.line}`)) continue;
|
|
288
|
+
|
|
289
|
+
// If we have a binding id on definition, require match when available
|
|
290
|
+
// When targetDefinitions is provided, only those definitions' bindings are valid targets
|
|
291
|
+
const targetDefs = options.targetDefinitions || definitions;
|
|
292
|
+
const targetBindingIds = new Set(targetDefs.map(d => d.bindingId).filter(Boolean));
|
|
293
|
+
if (targetBindingIds.size > 0 && bindingId && !targetBindingIds.has(bindingId)) {
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Java/Go/Rust receiver-class disambiguation:
|
|
298
|
+
// When targetDefinitions narrows to specific class(es) and the call has a
|
|
299
|
+
// receiver (e.g. javascriptFileService.createDataFile()), check if the
|
|
300
|
+
// receiver name better matches a non-target class definition.
|
|
301
|
+
// This prevents false positives like reporting obj.save() as a caller of
|
|
302
|
+
// TargetClass.save() when obj is clearly a different type.
|
|
303
|
+
if (call.isMethod && call.receiver && !resolvedBySameClass && !bindingId &&
|
|
304
|
+
options.targetDefinitions && definitions.length > 1 &&
|
|
305
|
+
(fileEntry.language === 'java' || fileEntry.language === 'go' || fileEntry.language === 'rust')) {
|
|
306
|
+
const targetClassNames = new Set(targetDefs.map(d => d.className).filter(Boolean));
|
|
307
|
+
if (targetClassNames.size > 0) {
|
|
308
|
+
const receiverLower = call.receiver.toLowerCase();
|
|
309
|
+
// Check if receiver matches any target class (camelCase convention)
|
|
310
|
+
const matchesTarget = [...targetClassNames].some(cn => cn.toLowerCase() === receiverLower);
|
|
311
|
+
if (!matchesTarget) {
|
|
312
|
+
// Check if receiver matches a non-target class instead
|
|
313
|
+
const nonTargetClasses = definitions
|
|
314
|
+
.filter(d => d.className && !targetClassNames.has(d.className))
|
|
315
|
+
.map(d => d.className);
|
|
316
|
+
const matchesOther = nonTargetClasses.some(cn => cn.toLowerCase() === receiverLower);
|
|
317
|
+
if (matchesOther) {
|
|
318
|
+
// Receiver clearly belongs to a different class
|
|
319
|
+
isUncertain = true;
|
|
320
|
+
if (!options.includeUncertain) {
|
|
321
|
+
if (stats) stats.uncertain = (stats.uncertain || 0) + 1;
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Find the enclosing function (get full symbol info)
|
|
330
|
+
const callerSymbol = index.findEnclosingFunction(filePath, call.line, true);
|
|
331
|
+
|
|
332
|
+
callers.push({
|
|
333
|
+
file: filePath,
|
|
334
|
+
relativePath: fileEntry.relativePath,
|
|
335
|
+
line: call.line,
|
|
336
|
+
content: lines[call.line - 1] || '',
|
|
337
|
+
callerName: callerSymbol ? callerSymbol.name : null,
|
|
338
|
+
callerFile: callerSymbol ? filePath : null,
|
|
339
|
+
callerStartLine: callerSymbol ? callerSymbol.startLine : null,
|
|
340
|
+
callerEndLine: callerSymbol ? callerSymbol.endLine : null,
|
|
341
|
+
isMethod: call.isMethod || false,
|
|
342
|
+
receiver: call.receiver
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
} catch (e) {
|
|
346
|
+
// Expected: minified files exceed tree-sitter buffer, binary files fail to parse.
|
|
347
|
+
// These are not actionable errors — silently skip.
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return callers;
|
|
352
|
+
} finally { index._endOp(); }
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Find all functions called by a function using AST-based detection
|
|
357
|
+
* @param {object} index - ProjectIndex instance
|
|
358
|
+
* @param {object} def - Symbol definition with file, name, startLine, endLine
|
|
359
|
+
* @param {object} [options] - Options
|
|
360
|
+
* @param {boolean} [options.includeMethods] - Include method calls (default: false)
|
|
361
|
+
*/
|
|
362
|
+
function findCallees(index, def, options = {}) {
|
|
363
|
+
index._beginOp();
|
|
364
|
+
try {
|
|
365
|
+
try {
|
|
366
|
+
// Get all calls from the file's cache (now includes enclosingFunction)
|
|
367
|
+
const calls = getCachedCalls(index, def.file);
|
|
368
|
+
if (!calls) return [];
|
|
369
|
+
|
|
370
|
+
// Get file language for smart method call handling
|
|
371
|
+
const fileEntry = index.files.get(def.file);
|
|
372
|
+
const language = fileEntry?.language;
|
|
373
|
+
|
|
374
|
+
// Build list of inner class/struct method ranges to exclude from callee detection.
|
|
375
|
+
// Only class methods are excluded — they are independently addressable symbols.
|
|
376
|
+
// Calls within closures (named functions without className) ARE included as
|
|
377
|
+
// callees of the parent function, since closures are part of the parent's behavior.
|
|
378
|
+
const innerSymbolRanges = fileEntry ? fileEntry.symbols
|
|
379
|
+
.filter(s => !NON_CALLABLE_TYPES.has(s.type) &&
|
|
380
|
+
s.className && // Only exclude class methods, not closures
|
|
381
|
+
s.startLine > def.startLine && s.endLine <= def.endLine &&
|
|
382
|
+
s.startLine !== def.startLine)
|
|
383
|
+
.map(s => [s.startLine, s.endLine]) : [];
|
|
384
|
+
|
|
385
|
+
const callees = new Map(); // key -> { name, bindingId, count }
|
|
386
|
+
let selfAttrCalls = null; // collected for Python self.attr.method() resolution
|
|
387
|
+
let selfMethodCalls = null; // collected for Python self.method() resolution
|
|
388
|
+
|
|
389
|
+
for (const call of calls) {
|
|
390
|
+
// Filter to calls within this function's scope
|
|
391
|
+
// Method 1: Direct match via enclosingFunction (fast path for direct calls)
|
|
392
|
+
const isDirectMatch = call.enclosingFunction &&
|
|
393
|
+
call.enclosingFunction.startLine === def.startLine;
|
|
394
|
+
// Method 2: Line-range containment (catches calls inside nested callbacks/closures)
|
|
395
|
+
// A call is in our scope if it's within our line range AND not inside a named inner symbol
|
|
396
|
+
const isInRange = call.line >= def.startLine && call.line <= def.endLine;
|
|
397
|
+
const isInInnerSymbol = isInRange && innerSymbolRanges.some(
|
|
398
|
+
([start, end]) => call.line >= start && call.line <= end);
|
|
399
|
+
const isNestedCallback = isInRange && !isInInnerSymbol && !isDirectMatch;
|
|
400
|
+
|
|
401
|
+
if (!isDirectMatch && !isNestedCallback) continue;
|
|
402
|
+
|
|
403
|
+
// Smart method call handling:
|
|
404
|
+
// - Go: include all method calls (Go doesn't use this/self/cls)
|
|
405
|
+
// - self/this.method(): resolve to same-class method (handled below)
|
|
406
|
+
// - Python self.attr.method(): resolve via selfAttribute (handled below)
|
|
407
|
+
// - Other languages: skip method calls unless explicitly requested
|
|
408
|
+
if (call.isMethod) {
|
|
409
|
+
if (call.selfAttribute && language === 'python') {
|
|
410
|
+
// Will be resolved in second pass below
|
|
411
|
+
} else if (['self', 'cls', 'this'].includes(call.receiver)) {
|
|
412
|
+
// self.method() / cls.method() / this.method() — resolve to same-class method below
|
|
413
|
+
} else if (call.receiver === 'super') {
|
|
414
|
+
// super().method() — resolve to parent class method below
|
|
415
|
+
} else if (language !== 'go' && language !== 'java' && language !== 'rust' && !options.includeMethods) {
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Skip keywords and built-ins
|
|
421
|
+
if (index.isKeyword(call.name, language)) continue;
|
|
422
|
+
|
|
423
|
+
// Use resolved name (from alias tracking) if available
|
|
424
|
+
// For multi-target aliases (ternary), pick the first that exists in symbol table
|
|
425
|
+
let effectiveName = call.resolvedName || call.name;
|
|
426
|
+
if (call.resolvedNames) {
|
|
427
|
+
for (const rn of call.resolvedNames) {
|
|
428
|
+
if (index.symbols.has(rn)) { effectiveName = rn; break; }
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// For potential callbacks (identifier args to non-HOF calls),
|
|
433
|
+
// only include if name exists as a function in symbol table
|
|
434
|
+
// AND has binding/import evidence or same-file definition.
|
|
435
|
+
// Prevents local variables (request, context) from matching
|
|
436
|
+
// unrelated functions defined elsewhere (especially test files).
|
|
437
|
+
if (call.isPotentialCallback) {
|
|
438
|
+
const syms = index.symbols.get(effectiveName);
|
|
439
|
+
if (!syms || !syms.some(s =>
|
|
440
|
+
['function', 'method', 'constructor', 'static', 'public', 'abstract'].includes(s.type))) {
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
const hasBinding = fileEntry?.bindings?.some(b => b.name === call.name);
|
|
444
|
+
const inSameFile = syms.some(s => s.file === def.file);
|
|
445
|
+
if (!hasBinding && !inSameFile) {
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Collect selfAttribute calls for second-pass resolution
|
|
451
|
+
if (call.selfAttribute && language === 'python') {
|
|
452
|
+
if (!selfAttrCalls) selfAttrCalls = [];
|
|
453
|
+
selfAttrCalls.push(call);
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Collect self/this.method() calls for same-class resolution
|
|
458
|
+
if (call.isMethod && ['self', 'cls', 'this'].includes(call.receiver)) {
|
|
459
|
+
if (!selfMethodCalls) selfMethodCalls = [];
|
|
460
|
+
selfMethodCalls.push(call);
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Collect super().method() calls for parent-class resolution
|
|
465
|
+
if (call.isMethod && call.receiver === 'super') {
|
|
466
|
+
if (!selfMethodCalls) selfMethodCalls = [];
|
|
467
|
+
selfMethodCalls.push(call);
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Resolve binding within this file (without mutating cached call objects)
|
|
472
|
+
let calleeKey = call.bindingId || effectiveName;
|
|
473
|
+
let bindingResolved = call.bindingId;
|
|
474
|
+
let isUncertain = call.uncertain;
|
|
475
|
+
if (!call.bindingId && fileEntry?.bindings) {
|
|
476
|
+
let bindings = fileEntry.bindings.filter(b => b.name === call.name);
|
|
477
|
+
// For Go, also check sibling files in same directory (same package scope)
|
|
478
|
+
if (bindings.length === 0 && language === 'go') {
|
|
479
|
+
const dir = path.dirname(def.file);
|
|
480
|
+
for (const [fp, fe] of index.files) {
|
|
481
|
+
if (fp !== def.file && path.dirname(fp) === dir) {
|
|
482
|
+
const sibling = (fe.bindings || []).filter(b => b.name === call.name);
|
|
483
|
+
bindings = bindings.concat(sibling);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
// Method call with no binding for the method name:
|
|
488
|
+
// Different strategies by language family:
|
|
489
|
+
if (bindings.length === 0 && call.isMethod) {
|
|
490
|
+
if (language !== 'go' && language !== 'java' && language !== 'rust') {
|
|
491
|
+
// JS/TS/Python: mark uncertain unless receiver has import/binding
|
|
492
|
+
// evidence in file scope. Prevents false positives like m.get() →
|
|
493
|
+
// repository.get() when m is just a parameter with no type info.
|
|
494
|
+
const hasReceiverEvidence = call.receiver &&
|
|
495
|
+
fileEntry?.bindings?.some(b => b.name === call.receiver);
|
|
496
|
+
if (!hasReceiverEvidence) {
|
|
497
|
+
isUncertain = true;
|
|
498
|
+
}
|
|
499
|
+
} else {
|
|
500
|
+
// Go/Java/Rust: nominal type systems make single-def method links
|
|
501
|
+
// reliable. Only mark uncertain when multiple definitions exist
|
|
502
|
+
// (cross-type ambiguity, e.g. TypeA.Length vs TypeB.Length).
|
|
503
|
+
const defs = index.symbols.get(call.name);
|
|
504
|
+
if (defs && defs.length > 1) {
|
|
505
|
+
isUncertain = true;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (bindings.length === 1) {
|
|
510
|
+
bindingResolved = bindings[0].id;
|
|
511
|
+
calleeKey = bindingResolved;
|
|
512
|
+
} else if (bindings.length > 1) {
|
|
513
|
+
if (call.name === def.name) {
|
|
514
|
+
// Calling same-name function (e.g., Java overloads)
|
|
515
|
+
// Add ALL other overloads as potential callees
|
|
516
|
+
const otherBindings = bindings.filter(b =>
|
|
517
|
+
b.startLine !== def.startLine
|
|
518
|
+
);
|
|
519
|
+
for (const ob of otherBindings) {
|
|
520
|
+
const existing = callees.get(ob.id);
|
|
521
|
+
if (existing) {
|
|
522
|
+
existing.count += 1;
|
|
523
|
+
} else {
|
|
524
|
+
callees.set(ob.id, {
|
|
525
|
+
name: effectiveName,
|
|
526
|
+
bindingId: ob.id,
|
|
527
|
+
count: 1
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
continue; // Already added all overloads, skip normal add
|
|
532
|
+
} else if (def.className && !call.isMethod) {
|
|
533
|
+
// Implicit same-class call (Java: execute() means this.execute())
|
|
534
|
+
// Try to resolve to a binding in the same class via symbol lookup
|
|
535
|
+
const callSymbols = index.symbols.get(call.name);
|
|
536
|
+
if (callSymbols) {
|
|
537
|
+
const sameClassSym = callSymbols.find(s => s.className === def.className);
|
|
538
|
+
if (sameClassSym) {
|
|
539
|
+
// Find the binding that matches this symbol's line
|
|
540
|
+
const matchingBinding = bindings.find(b => b.startLine === sameClassSym.startLine);
|
|
541
|
+
if (matchingBinding) {
|
|
542
|
+
bindingResolved = matchingBinding.id;
|
|
543
|
+
calleeKey = bindingResolved;
|
|
544
|
+
} else {
|
|
545
|
+
bindingResolved = sameClassSym.bindingId;
|
|
546
|
+
calleeKey = bindingResolved || `${def.className}.${call.name}`;
|
|
547
|
+
}
|
|
548
|
+
} else {
|
|
549
|
+
isUncertain = true;
|
|
550
|
+
}
|
|
551
|
+
} else {
|
|
552
|
+
isUncertain = true;
|
|
553
|
+
}
|
|
554
|
+
} else {
|
|
555
|
+
// Try to resolve to a binding defined within the parent function's
|
|
556
|
+
// scope (inner closure). E.g., hookRunnerApplication defines next()
|
|
557
|
+
// internally — prefer that over other next() in the same file.
|
|
558
|
+
const innerBinding = bindings.find(b =>
|
|
559
|
+
b.startLine > def.startLine && b.startLine <= def.endLine);
|
|
560
|
+
if (innerBinding) {
|
|
561
|
+
bindingResolved = innerBinding.id;
|
|
562
|
+
calleeKey = bindingResolved;
|
|
563
|
+
} else {
|
|
564
|
+
isUncertain = true;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (isUncertain && !options.includeUncertain) {
|
|
571
|
+
if (options.stats) options.stats.uncertain = (options.stats.uncertain || 0) + 1;
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const existing = callees.get(calleeKey);
|
|
576
|
+
if (existing) {
|
|
577
|
+
existing.count += 1;
|
|
578
|
+
} else {
|
|
579
|
+
callees.set(calleeKey, {
|
|
580
|
+
name: effectiveName,
|
|
581
|
+
bindingId: bindingResolved,
|
|
582
|
+
count: 1
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Second pass: resolve Python self.attr.method() calls
|
|
588
|
+
if (selfAttrCalls && def.className) {
|
|
589
|
+
const attrTypes = getInstanceAttributeTypes(index, def.file, def.className);
|
|
590
|
+
if (attrTypes) {
|
|
591
|
+
for (const call of selfAttrCalls) {
|
|
592
|
+
const targetClass = attrTypes.get(call.selfAttribute);
|
|
593
|
+
if (!targetClass) continue;
|
|
594
|
+
|
|
595
|
+
// Find method in symbol table where className matches
|
|
596
|
+
const symbols = index.symbols.get(call.name);
|
|
597
|
+
if (!symbols) continue;
|
|
598
|
+
|
|
599
|
+
const match = symbols.find(s => s.className === targetClass);
|
|
600
|
+
if (!match) continue;
|
|
601
|
+
|
|
602
|
+
const key = match.bindingId || `${targetClass}.${call.name}`;
|
|
603
|
+
const existing = callees.get(key);
|
|
604
|
+
if (existing) {
|
|
605
|
+
existing.count += 1;
|
|
606
|
+
} else {
|
|
607
|
+
callees.set(key, {
|
|
608
|
+
name: call.name,
|
|
609
|
+
bindingId: match.bindingId,
|
|
610
|
+
count: 1
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Third pass: resolve self/this/super.method() calls to same-class or parent methods
|
|
618
|
+
// Falls back to walking the inheritance chain if not found in same class
|
|
619
|
+
if (selfMethodCalls && def.className) {
|
|
620
|
+
for (const call of selfMethodCalls) {
|
|
621
|
+
const symbols = index.symbols.get(call.name);
|
|
622
|
+
if (!symbols) continue;
|
|
623
|
+
|
|
624
|
+
// For super().method(), skip same-class — start from parent
|
|
625
|
+
let match = call.receiver === 'super'
|
|
626
|
+
? null
|
|
627
|
+
: symbols.find(s => s.className === def.className);
|
|
628
|
+
|
|
629
|
+
// Walk inheritance chain using BFS if not found in same class
|
|
630
|
+
if (!match) {
|
|
631
|
+
const visited = new Set([def.className]);
|
|
632
|
+
const defFile = def.file;
|
|
633
|
+
const startParents = index._getInheritanceParents(def.className, defFile) || [];
|
|
634
|
+
const queue = startParents.map(p => ({ name: p, contextFile: defFile }));
|
|
635
|
+
while (queue.length > 0 && !match) {
|
|
636
|
+
const { name: current, contextFile } = queue.shift();
|
|
637
|
+
if (visited.has(current)) continue;
|
|
638
|
+
visited.add(current);
|
|
639
|
+
match = symbols.find(s => s.className === current);
|
|
640
|
+
if (!match) {
|
|
641
|
+
const resolvedFile = index._resolveClassFile(current, contextFile);
|
|
642
|
+
const grandparents = index._getInheritanceParents(current, resolvedFile) || [];
|
|
643
|
+
for (const gp of grandparents) {
|
|
644
|
+
if (!visited.has(gp)) queue.push({ name: gp, contextFile: resolvedFile });
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if (!match) continue;
|
|
651
|
+
|
|
652
|
+
const key = match.bindingId || `${match.className}.${call.name}`;
|
|
653
|
+
const existing = callees.get(key);
|
|
654
|
+
if (existing) {
|
|
655
|
+
existing.count += 1;
|
|
656
|
+
} else {
|
|
657
|
+
callees.set(key, {
|
|
658
|
+
name: call.name,
|
|
659
|
+
bindingId: match.bindingId,
|
|
660
|
+
count: 1
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Look up each callee in the symbol table
|
|
667
|
+
// For methods, prefer callees from: 1) same file, 2) same package, 3) same receiver type
|
|
668
|
+
// Also deprioritize test-file definitions when caller is in production code
|
|
669
|
+
const result = [];
|
|
670
|
+
const defDir = path.dirname(def.file);
|
|
671
|
+
const defReceiver = def.receiver;
|
|
672
|
+
const defFileEntry = fileEntry;
|
|
673
|
+
const callerIsTest = defFileEntry && isTestFile(defFileEntry.relativePath, defFileEntry.language);
|
|
674
|
+
|
|
675
|
+
for (const { name: calleeName, bindingId, count } of callees.values()) {
|
|
676
|
+
const symbols = index.symbols.get(calleeName);
|
|
677
|
+
if (symbols && symbols.length > 0) {
|
|
678
|
+
let callee = symbols[0];
|
|
679
|
+
|
|
680
|
+
// If we have a binding ID, find the exact matching symbol
|
|
681
|
+
if (bindingId && symbols.length > 1) {
|
|
682
|
+
const exactMatch = symbols.find(s => s.bindingId === bindingId);
|
|
683
|
+
if (exactMatch) {
|
|
684
|
+
callee = exactMatch;
|
|
685
|
+
}
|
|
686
|
+
} else if (symbols.length > 1) {
|
|
687
|
+
// Priority 1: Same file, but different definition (for overloads)
|
|
688
|
+
const sameFileDifferent = symbols.find(s => s.file === def.file && s.startLine !== def.startLine);
|
|
689
|
+
const sameFile = symbols.find(s => s.file === def.file);
|
|
690
|
+
if (sameFileDifferent && calleeName === def.name) {
|
|
691
|
+
callee = sameFileDifferent;
|
|
692
|
+
} else if (sameFile) {
|
|
693
|
+
callee = sameFile;
|
|
694
|
+
} else {
|
|
695
|
+
// Priority 2: Same directory (package)
|
|
696
|
+
const sameDir = symbols.find(s => path.dirname(s.file) === defDir);
|
|
697
|
+
if (sameDir) {
|
|
698
|
+
callee = sameDir;
|
|
699
|
+
} else if (defReceiver) {
|
|
700
|
+
// Priority 3: Same receiver type (for methods)
|
|
701
|
+
const sameReceiver = symbols.find(s => s.receiver === defReceiver);
|
|
702
|
+
if (sameReceiver) {
|
|
703
|
+
callee = sameReceiver;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
// Priority 4: If default (symbols[0]) is a test file, prefer non-test
|
|
708
|
+
if (!bindingId) {
|
|
709
|
+
const calleeFileEntry = index.files.get(callee.file);
|
|
710
|
+
if (calleeFileEntry && isTestFile(calleeFileEntry.relativePath, calleeFileEntry.language)) {
|
|
711
|
+
const nonTest = symbols.find(s => {
|
|
712
|
+
const fe = index.files.get(s.file);
|
|
713
|
+
return fe && !isTestFile(fe.relativePath, fe.language);
|
|
714
|
+
});
|
|
715
|
+
if (nonTest) callee = nonTest;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Skip test-file callees when caller is production code and
|
|
721
|
+
// there's no binding (import) evidence linking them
|
|
722
|
+
if (!callerIsTest && !bindingId) {
|
|
723
|
+
const calleeFileEntry = index.files.get(callee.file);
|
|
724
|
+
if (calleeFileEntry && isTestFile(calleeFileEntry.relativePath, calleeFileEntry.language)) {
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
result.push({
|
|
730
|
+
...callee,
|
|
731
|
+
callCount: count,
|
|
732
|
+
weight: index.calculateWeight(count)
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Sort by call count (core dependencies first)
|
|
738
|
+
result.sort((a, b) => b.callCount - a.callCount);
|
|
739
|
+
|
|
740
|
+
return result;
|
|
741
|
+
} catch (e) {
|
|
742
|
+
// Expected: file read/parse failures (minified, binary, buffer exceeded).
|
|
743
|
+
// Return empty callees rather than crashing the entire query.
|
|
744
|
+
return [];
|
|
745
|
+
}
|
|
746
|
+
} finally { index._endOp(); }
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Get instance attribute types for a class in a file.
|
|
751
|
+
* Returns Map<attrName, typeName> for a given className.
|
|
752
|
+
* Caches results per file.
|
|
753
|
+
* @param {object} index - ProjectIndex instance
|
|
754
|
+
* @param {string} filePath - File path
|
|
755
|
+
* @param {string} className - Class name
|
|
756
|
+
*/
|
|
757
|
+
function getInstanceAttributeTypes(index, filePath, className) {
|
|
758
|
+
if (!index._attrTypeCache) index._attrTypeCache = new Map();
|
|
759
|
+
|
|
760
|
+
let fileCache = index._attrTypeCache.get(filePath);
|
|
761
|
+
if (!fileCache) {
|
|
762
|
+
const fileEntry = index.files.get(filePath);
|
|
763
|
+
if (!fileEntry || fileEntry.language !== 'python') return null;
|
|
764
|
+
|
|
765
|
+
const langModule = getLanguageModule('python');
|
|
766
|
+
if (!langModule?.findInstanceAttributeTypes) return null;
|
|
767
|
+
|
|
768
|
+
try {
|
|
769
|
+
const content = index._readFile(filePath);
|
|
770
|
+
const parser = getParser('python');
|
|
771
|
+
fileCache = langModule.findInstanceAttributeTypes(content, parser);
|
|
772
|
+
index._attrTypeCache.set(filePath, fileCache);
|
|
773
|
+
} catch {
|
|
774
|
+
return null;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
return fileCache.get(className) || null;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Check if a function is used as a callback anywhere in the codebase
|
|
783
|
+
* @param {object} index - ProjectIndex instance
|
|
784
|
+
* @param {string} name - Function name
|
|
785
|
+
* @returns {Array} Callback usages
|
|
786
|
+
*/
|
|
787
|
+
function findCallbackUsages(index, name) {
|
|
788
|
+
const usages = [];
|
|
789
|
+
|
|
790
|
+
for (const [filePath, fileEntry] of index.files) {
|
|
791
|
+
try {
|
|
792
|
+
const content = index._readFile(filePath);
|
|
793
|
+
const language = detectLanguage(filePath);
|
|
794
|
+
if (!language) continue;
|
|
795
|
+
|
|
796
|
+
const langModule = getLanguageModule(language);
|
|
797
|
+
if (!langModule.findCallbackUsages) continue;
|
|
798
|
+
|
|
799
|
+
const parser = getParser(language);
|
|
800
|
+
const callbacks = langModule.findCallbackUsages(content, name, parser);
|
|
801
|
+
|
|
802
|
+
for (const cb of callbacks) {
|
|
803
|
+
usages.push({
|
|
804
|
+
file: filePath,
|
|
805
|
+
relativePath: fileEntry.relativePath,
|
|
806
|
+
...cb
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
} catch (e) {
|
|
810
|
+
// Skip files that can't be processed
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
return usages;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
module.exports = { getCachedCalls, findCallers, findCallees, getInstanceAttributeTypes, findCallbackUsages };
|