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.
@@ -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 };