ucn 3.8.21 → 3.8.23

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 CHANGED
@@ -14,7 +14,7 @@ const { detectLanguage } = require('../core/parser');
14
14
  const { ProjectIndex } = require('../core/project');
15
15
  const { expandGlob, findProjectRoot } = require('../core/discovery');
16
16
  const output = require('../core/output');
17
- const { getCliCommandSet, resolveCommand, FLAG_APPLICABILITY, toCliName } = require('../core/registry');
17
+ const { getCliCommandSet, resolveCommand, FLAG_APPLICABILITY, toCliName, FILE_LOCAL_COMMANDS } = require('../core/registry');
18
18
  const { execute } = require('../core/execute');
19
19
  const { ExpandCache } = require('../core/expand-cache');
20
20
 
@@ -322,7 +322,7 @@ function runFileCommand(filePath, command, arg) {
322
322
  const canonical = resolveCommand(command, 'cli') || command;
323
323
 
324
324
  // Commands that need full project index — auto-route to project mode
325
- const fileLocalCommands = new Set(['toc', 'fn', 'class', 'find', 'usages', 'search', 'lines', 'typedef', 'api']);
325
+ const fileLocalCommands = FILE_LOCAL_COMMANDS;
326
326
 
327
327
  if (!fileLocalCommands.has(canonical)) {
328
328
  // Auto-detect project root and route to project mode
package/core/analysis.js CHANGED
@@ -353,13 +353,24 @@ function related(index, name, options = {}) {
353
353
  if (related.similarNames.length > similarLimit) related.similarNames = related.similarNames.slice(0, similarLimit);
354
354
 
355
355
  // 3. Shared callers - functions called by the same callers
356
- const myCallers = new Set(index.findCallers(name).map(c => c.callerName).filter(Boolean));
356
+ // Cap findCallers to avoid O(hundreds × findCallees) on ambiguous names
357
+ const maxSharedCallerScan = options.all ? 50 : 20;
358
+ const myCallersRaw = index.findCallers(name, { maxResults: maxSharedCallerScan * 3 });
359
+ const myCallers = new Set(myCallersRaw.map(c => c.callerName).filter(Boolean));
357
360
  if (myCallers.size > 0) {
358
361
  const callerCounts = new Map();
362
+ const calleeCache = new Map();
363
+ let scannedCallers = 0;
359
364
  for (const callerName of myCallers) {
365
+ if (scannedCallers >= maxSharedCallerScan) break;
360
366
  const callerDef = index.symbols.get(callerName)?.[0];
361
367
  if (callerDef) {
362
- const callees = index.findCallees(callerDef);
368
+ let callees = calleeCache.get(callerName);
369
+ if (!callees) {
370
+ callees = index.findCallees(callerDef);
371
+ calleeCache.set(callerName, callees);
372
+ }
373
+ scannedCallers++;
363
374
  for (const callee of callees) {
364
375
  if (callee.name !== name) {
365
376
  callerCounts.set(callee.name, (callerCounts.get(callee.name) || 0) + 1);
@@ -394,9 +405,14 @@ function related(index, name, options = {}) {
394
405
  const myCalleeNames = new Set(myCallees.map(c => c.name));
395
406
  if (myCalleeNames.size > 0) {
396
407
  const calleeCounts = new Map();
408
+ // Cap callee scan to avoid O(callees × findCallers) explosion
409
+ const maxCalleeScan = options.all ? 30 : 15;
410
+ let scannedCallees = 0;
397
411
  for (const calleeName of myCalleeNames) {
412
+ if (scannedCallees >= maxCalleeScan) break;
413
+ scannedCallees++;
398
414
  // Find other functions that also call this callee
399
- const callers = index.findCallers(calleeName);
415
+ const callers = index.findCallers(calleeName, { maxResults: 50 });
400
416
  for (const caller of callers) {
401
417
  if (caller.callerName && caller.callerName !== name) {
402
418
  calleeCounts.set(caller.callerName, (calleeCounts.get(caller.callerName) || 0) + 1);
@@ -789,7 +805,9 @@ function about(index, name, options = {}) {
789
805
  let aboutConfFiltered = 0;
790
806
  if (primary.type === 'function' || primary.params !== undefined) {
791
807
  // Use maxResults to limit file iteration (with buffer for exclude filtering)
792
- const callerCap = maxCallers === Infinity ? undefined : maxCallers * 3;
808
+ // Reduce buffer for highly ambiguous names (many definitions = more noise, less value per caller)
809
+ const callerMultiplier = definitions.length > 5 ? 1.5 : 3;
810
+ const callerCap = maxCallers === Infinity ? undefined : Math.ceil(maxCallers * callerMultiplier);
793
811
  allCallers = index.findCallers(symbolName, { includeMethods, includeUncertain: options.includeUncertain, targetDefinitions: [primary], maxResults: callerCap });
794
812
  // Apply exclude filter before slicing
795
813
  if (options.exclude && options.exclude.length > 0) {
@@ -837,7 +855,8 @@ function about(index, name, options = {}) {
837
855
  }
838
856
 
839
857
  // Find tests — scope to the same file/class as the primary definition
840
- const tests = index.tests(symbolName, {
858
+ // Skip expensive test search for highly ambiguous names (>10 other definitions)
859
+ const tests = (others.length > 10 && !options.all) ? [] : index.tests(symbolName, {
841
860
  file: options.file,
842
861
  className: options.className || primary.className,
843
862
  exclude: options.exclude,
package/core/cache.js CHANGED
@@ -68,31 +68,22 @@ function saveCache(index, cachePath) {
68
68
  strippedFiles.push([rp, rest]);
69
69
  }
70
70
 
71
- // Convert graph paths from absolute to relative
71
+ // Convert graph paths from absolute to relative (Sets serialized as arrays)
72
72
  const relGraph = (graph) => {
73
73
  const result = [];
74
74
  for (const [absKey, absValues] of graph) {
75
75
  const relKey = path.relative(root, absKey);
76
- const relValues = absValues.map(v => path.relative(root, v));
76
+ const relValues = [...absValues].map(v => path.relative(root, v));
77
77
  result.push([relKey, relValues]);
78
78
  }
79
79
  return result;
80
80
  };
81
81
 
82
- // Persist calleeIndex if built (paths must be absolute from this.files keys)
83
- let calleeIndexData;
84
- if (index.calleeIndex && index.calleeIndex.size > 0) {
85
- calleeIndexData = [];
86
- for (const [name, files] of index.calleeIndex) {
87
- const relFiles = [...files].map(f =>
88
- path.isAbsolute(f) ? path.relative(root, f) : f
89
- );
90
- calleeIndexData.push([name, relFiles]);
91
- }
92
- }
82
+ // calleeIndex is NOT persisted in index.json it's rebuilt lazily from callsCache
83
+ // on first findCallers/buildCalleeIndex call. Removing it saves ~22MB (14%) on large projects.
93
84
 
94
85
  const cacheData = {
95
- version: 7, // v7: strip symbols/bindings from file entries (dedup ~45% cache reduction)
86
+ version: 8, // v8: remove calleeIndex from index.json (rebuilt from callsCache)
96
87
  ucnVersion: UCN_VERSION, // Invalidate cache when UCN is updated
97
88
  configHash,
98
89
  root,
@@ -108,23 +99,22 @@ function saveCache(index, cachePath) {
108
99
  failedFiles: index.failedFiles
109
100
  ? Array.from(index.failedFiles).map(f => path.relative(root, f))
110
101
  : [],
111
- ...(calleeIndexData && { calleeIndex: calleeIndexData })
112
102
  };
113
103
 
114
104
  fs.writeFileSync(cacheFile, JSON.stringify(cacheData));
115
105
 
116
- // Save callsCache sharded by directory for lazy loading
106
+ // Save callsCache sharded by directory for lazy loading.
107
+ // Write to a temp directory first, then atomic swap to avoid data loss on crash.
117
108
  if (callsCacheData.length > 0) {
118
- const callsDir = path.join(path.dirname(cacheFile), 'calls');
119
- // Clean up old shards and legacy monolithic file
120
- if (fs.existsSync(callsDir)) {
121
- fs.rmSync(callsDir, { recursive: true, force: true });
122
- }
123
- const legacyFile = path.join(path.dirname(cacheFile), 'calls-cache.json');
124
- if (fs.existsSync(legacyFile)) {
125
- fs.rmSync(legacyFile, { force: true });
109
+ const cacheDir = path.dirname(cacheFile);
110
+ const callsDir = path.join(cacheDir, 'calls');
111
+ const callsTmpDir = path.join(cacheDir, 'calls.tmp');
112
+
113
+ // Clean up any leftover temp dir from a previous crashed save
114
+ if (fs.existsSync(callsTmpDir)) {
115
+ fs.rmSync(callsTmpDir, { recursive: true, force: true });
126
116
  }
127
- fs.mkdirSync(callsDir, { recursive: true });
117
+ fs.mkdirSync(callsTmpDir, { recursive: true });
128
118
 
129
119
  // Group by directory
130
120
  const shards = new Map();
@@ -134,17 +124,29 @@ function saveCache(index, cachePath) {
134
124
  shards.get(dir).push([relPath, entry]);
135
125
  }
136
126
 
137
- // Write one shard per directory
127
+ // Write all shards to temp directory
138
128
  const shardManifest = [];
139
129
  for (const [dir, entries] of shards) {
140
130
  const hash = crypto.createHash('md5').update(dir).digest('hex').slice(0, 10);
141
- const shardFile = path.join(callsDir, `${hash}.json`);
131
+ const shardFile = path.join(callsTmpDir, `${hash}.json`);
142
132
  fs.writeFileSync(shardFile, JSON.stringify(entries));
143
133
  shardManifest.push([dir, hash, entries.length]);
144
134
  }
145
135
 
146
- // Write manifest for lazy loading
147
- fs.writeFileSync(path.join(callsDir, 'manifest.json'), JSON.stringify(shardManifest));
136
+ // Write manifest to temp directory
137
+ fs.writeFileSync(path.join(callsTmpDir, 'manifest.json'), JSON.stringify(shardManifest));
138
+
139
+ // Atomic swap: remove old, rename temp to final
140
+ if (fs.existsSync(callsDir)) {
141
+ fs.rmSync(callsDir, { recursive: true, force: true });
142
+ }
143
+ fs.renameSync(callsTmpDir, callsDir);
144
+
145
+ // Clean up legacy monolithic file
146
+ const legacyFile = path.join(cacheDir, 'calls-cache.json');
147
+ if (fs.existsSync(legacyFile)) {
148
+ fs.rmSync(legacyFile, { force: true });
149
+ }
148
150
  }
149
151
 
150
152
  return cacheFile;
@@ -168,7 +170,8 @@ function loadCache(index, cachePath) {
168
170
 
169
171
  // Check version compatibility
170
172
  // v7: symbols/bindings stripped from file entries (dedup)
171
- if (cacheData.version !== 7) {
173
+ // v8: calleeIndex removed from index.json (rebuilt from callsCache)
174
+ if (cacheData.version !== 7 && cacheData.version !== 8) {
172
175
  return false;
173
176
  }
174
177
 
@@ -186,12 +189,19 @@ function loadCache(index, cachePath) {
186
189
  }
187
190
 
188
191
  const root = cacheData.root || index.root;
192
+ // Fast path conversion: string concat is ~70x faster than path.join for
193
+ // cache-stored relative paths (no '..' segments). On Windows, path.relative
194
+ // produces backslash paths, so rootPrefix uses the native separator.
195
+ const rootPrefix = root.endsWith(path.sep) ? root : root + path.sep;
196
+ const toAbs = path.sep === '/'
197
+ ? (relPath) => rootPrefix + relPath
198
+ : (relPath) => rootPrefix + relPath.replace(/\//g, path.sep);
189
199
 
190
200
  // Reconstruct files Map: relative key → absolute key, restore path and relativePath
191
201
  // Initialize symbols/bindings arrays (will be populated from top-level symbols)
192
202
  index.files = new Map();
193
203
  for (const [relPath, entry] of cacheData.files) {
194
- const absPath = path.join(root, relPath);
204
+ const absPath = toAbs(relPath);
195
205
  entry.path = absPath;
196
206
  entry.relativePath = relPath;
197
207
  if (!entry.symbols) entry.symbols = [];
@@ -204,7 +214,7 @@ function loadCache(index, cachePath) {
204
214
  index.symbols = new Map(cacheData.symbols);
205
215
  for (const [, defs] of index.symbols) {
206
216
  for (const s of defs) {
207
- if (!s.file && s.relativePath) s.file = path.join(root, s.relativePath);
217
+ if (!s.file && s.relativePath) s.file = toAbs(s.relativePath);
208
218
  if (!s.bindingId && s.relativePath && s.type && s.startLine) {
209
219
  s.bindingId = `${s.relativePath}:${s.type}:${s.startLine}`;
210
220
  }
@@ -222,11 +232,14 @@ function loadCache(index, cachePath) {
222
232
  }
223
233
  }
224
234
 
225
- // Reconstruct graphs: relative paths → absolute paths
235
+ // Reconstruct graphs: relative paths → absolute paths (as Sets)
236
+ // Uses string concat (toAbs) instead of path.join — 70x faster on 464K edges
226
237
  const absGraph = (data) => {
227
238
  const m = new Map();
228
239
  for (const [relKey, relValues] of data) {
229
- m.set(path.join(root, relKey), relValues.map(v => path.join(root, v)));
240
+ const absValues = new Set();
241
+ for (const v of relValues) absValues.add(toAbs(v));
242
+ m.set(toAbs(relKey), absValues);
230
243
  }
231
244
  return m;
232
245
  };
@@ -243,10 +256,10 @@ function loadCache(index, cachePath) {
243
256
  index.extendedByGraph = new Map(cacheData.extendedByGraph);
244
257
  }
245
258
 
246
- // Eagerly load callsCache from separate file.
247
- // Prevents 10K cold tree-sitter re-parses (2GB+ peak) when findCallers runs.
259
+ // Prepare lazy calls cache loading — load manifest but defer shard parsing.
260
+ // Shards are loaded on first getCachedCalls access via ensureCallsCacheLoaded().
248
261
  if (index.callsCache.size === 0) {
249
- loadCallsCache(index);
262
+ _prepareCallsCache(index);
250
263
  }
251
264
 
252
265
  // Build directory→files index from loaded data
@@ -257,17 +270,17 @@ function loadCache(index, cachePath) {
257
270
  // Restore failedFiles if present (convert relative paths back to absolute)
258
271
  if (Array.isArray(cacheData.failedFiles)) {
259
272
  index.failedFiles = new Set(
260
- cacheData.failedFiles.map(f => path.isAbsolute(f) ? f : path.join(root, f))
273
+ cacheData.failedFiles.map(f => path.isAbsolute(f) ? f : toAbs(f))
261
274
  );
262
275
  }
263
276
 
264
- // Restore calleeIndex if persisted
277
+ // Restore calleeIndex if persisted (v7 caches only; v8+ rebuilds lazily)
265
278
  if (Array.isArray(cacheData.calleeIndex)) {
266
279
  index.calleeIndex = new Map();
267
280
  for (const [name, files] of cacheData.calleeIndex) {
268
281
  if (!Array.isArray(files)) continue;
269
282
  index.calleeIndex.set(name, new Set(
270
- files.map(f => path.isAbsolute(f) ? f : path.join(root, f))
283
+ files.map(f => path.isAbsolute(f) ? f : toAbs(f))
271
284
  ));
272
285
  }
273
286
  }
@@ -292,6 +305,12 @@ function loadCache(index, cachePath) {
292
305
  * @returns {boolean} - True if cache needs rebuilding
293
306
  */
294
307
  function isCacheStale(index) {
308
+ // Ultra-fast path: skip full check if last confirmed-fresh < 2s ago (covers MCP burst calls).
309
+ // Only uses _lastFreshAt (set at the end of a successful full check), not cache save timestamp.
310
+ if (index._lastFreshAt && Date.now() - index._lastFreshAt < 2000) {
311
+ return false;
312
+ }
313
+
295
314
  // Fast path: check cached files for modifications/deletions first (stat-only).
296
315
  // This returns early without the expensive directory walk when any file changed.
297
316
  for (const [filePath, fileEntry] of index.files) {
@@ -337,44 +356,64 @@ function isCacheStale(index) {
337
356
  }
338
357
  }
339
358
 
359
+ // Record when we last confirmed the cache is fresh (enables 2s skip on burst calls)
360
+ index._lastFreshAt = Date.now();
340
361
  return false;
341
362
  }
342
363
 
343
364
  /**
344
- * Load callsCache from separate file on demand.
345
- * Only loads if callsCache is empty (not already populated from inline or prior load).
365
+ * Prepare calls cache for lazy loading — reads manifest but defers shard parsing.
366
+ * Called during loadCache() to set up the manifest without the ~1s shard parse cost.
367
+ * Actual shards are loaded on first ensureCallsCacheLoaded() call.
346
368
  * @param {object} index - ProjectIndex instance
347
- * @returns {boolean} - True if loaded successfully
348
369
  */
349
- function loadCallsCache(index) {
350
- if (index.callsCache.size > 0) return true; // Already populated
351
- if (index._callsCacheLoaded) return false; // Already attempted, file didn't exist
352
- index._callsCacheLoaded = true;
353
-
370
+ function _prepareCallsCache(index) {
371
+ if (index._callsCacheLoaded) return;
354
372
  const cacheDir = path.join(index.root, '.ucn-cache');
355
-
356
- // Try sharded format first (calls/manifest.json)
357
373
  const manifestFile = path.join(cacheDir, 'calls', 'manifest.json');
358
374
  if (fs.existsSync(manifestFile)) {
359
375
  try {
360
376
  const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf-8'));
361
- // Store manifest for lazy loading
362
377
  index._callsManifest = new Map();
363
378
  for (const [dir, hash, count] of manifest) {
364
379
  index._callsManifest.set(dir, { hash, count, loaded: false });
365
380
  }
366
- // Eagerly load all shards (matches previous behavior)
367
- for (const [, { hash }] of index._callsManifest) {
368
- _loadCallsShard(index, hash);
369
- }
370
- return true;
381
+ index._callsCachePrepared = true;
382
+ return;
371
383
  } catch (e) {
372
- // Corrupted manifest — fall through to legacy
384
+ // Corrupted manifest — fall through
385
+ }
386
+ }
387
+ // Check legacy format
388
+ const legacyFile = path.join(cacheDir, 'calls-cache.json');
389
+ if (fs.existsSync(legacyFile)) {
390
+ index._callsCacheLegacyFile = legacyFile;
391
+ index._callsCachePrepared = true;
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Load callsCache from separate file on demand.
397
+ * Only loads if callsCache is empty (not already populated from inline or prior load).
398
+ * @param {object} index - ProjectIndex instance
399
+ * @returns {boolean} - True if loaded successfully
400
+ */
401
+ function loadCallsCache(index) {
402
+ if (index.callsCache.size > 0) return true; // Already populated
403
+ if (index._callsCacheLoaded) return false; // Already attempted, file didn't exist
404
+ index._callsCacheLoaded = true;
405
+
406
+ // If manifest was prepared lazily, load all shards now
407
+ if (index._callsManifest) {
408
+ for (const [, { hash }] of index._callsManifest) {
409
+ _loadCallsShard(index, hash);
373
410
  }
411
+ return index.callsCache.size > 0;
374
412
  }
375
413
 
376
414
  // Legacy format: single calls-cache.json
377
- const callsCacheFile = path.join(cacheDir, 'calls-cache.json');
415
+ const callsCacheFile = index._callsCacheLegacyFile ||
416
+ path.join(index.root, '.ucn-cache', 'calls-cache.json');
378
417
  if (!fs.existsSync(callsCacheFile)) return false;
379
418
 
380
419
  try {
@@ -393,6 +432,17 @@ function loadCallsCache(index) {
393
432
  return false;
394
433
  }
395
434
 
435
+ /**
436
+ * Ensure calls cache is fully loaded (trigger lazy load if prepared but not loaded).
437
+ * Call this before any operation that needs callsCache (findCallers, buildCalleeIndex, etc.)
438
+ * @param {object} index - ProjectIndex instance
439
+ */
440
+ function ensureCallsCacheLoaded(index) {
441
+ if (index._callsCachePrepared && !index._callsCacheLoaded) {
442
+ loadCallsCache(index);
443
+ }
444
+ }
445
+
396
446
  /**
397
447
  * Load a single calls shard by hash.
398
448
  * @param {object} index - ProjectIndex instance
@@ -403,9 +453,13 @@ function _loadCallsShard(index, hash) {
403
453
  try {
404
454
  const data = JSON.parse(fs.readFileSync(shardFile, 'utf-8'));
405
455
  if (!Array.isArray(data)) return;
456
+ const rootPrefix = index.root.endsWith(path.sep) ? index.root : index.root + path.sep;
457
+ const toAbsShard = path.sep === '/'
458
+ ? (rp) => rootPrefix + rp
459
+ : (rp) => rootPrefix + rp.replace(/\//g, path.sep);
406
460
  for (const [relPath, entry] of data) {
407
461
  if (!relPath || !entry) continue;
408
- const absPath = path.isAbsolute(relPath) ? relPath : path.join(index.root, relPath);
462
+ const absPath = path.isAbsolute(relPath) ? relPath : toAbsShard(relPath);
409
463
  index.callsCache.set(absPath, entry);
410
464
  }
411
465
  } catch (e) {
@@ -413,4 +467,4 @@ function _loadCallsShard(index, hash) {
413
467
  }
414
468
  }
415
469
 
416
- module.exports = { saveCache, loadCache, loadCallsCache, isCacheStale };
470
+ module.exports = { saveCache, loadCache, loadCallsCache, isCacheStale, ensureCallsCacheLoaded };
package/core/callers.js CHANGED
@@ -13,6 +13,14 @@ const { isTestFile } = require('./discovery');
13
13
  const { NON_CALLABLE_TYPES } = require('./shared');
14
14
  const { scoreEdge } = require('./confidence');
15
15
 
16
+ /** Set.some() helper — like Array.some() but for Sets */
17
+ function setSome(set, predicate) {
18
+ for (const item of set) {
19
+ if (predicate(item)) return true;
20
+ }
21
+ return false;
22
+ }
23
+
16
24
  /**
17
25
  * Extract a single line from content without splitting the entire string.
18
26
  * @param {string} content - Full file content
@@ -40,6 +48,11 @@ function getLine(content, lineNum) {
40
48
  */
41
49
  function getCachedCalls(index, filePath, options = {}) {
42
50
  try {
51
+ // Trigger lazy calls cache load if prepared but not yet loaded
52
+ if (index._callsCachePrepared && !index._callsCacheLoaded) {
53
+ const { ensureCallsCacheLoaded } = require('./cache');
54
+ ensureCallsCacheLoaded(index);
55
+ }
43
56
  const cached = index.callsCache.get(filePath);
44
57
 
45
58
  // Fast path: check mtime first (stat is much faster than read+hash)
@@ -91,6 +104,8 @@ function getCachedCalls(index, filePath, options = {}) {
91
104
  }
92
105
  const calls = langModule.findCallsInCode(content, parser, callOpts);
93
106
 
107
+ // Remove old callee index entries before overwriting cache
108
+ if (cached) index._removeFromCalleeIndex(filePath, cached.calls);
94
109
  index.callsCache.set(filePath, {
95
110
  mtime,
96
111
  hash,
@@ -98,6 +113,8 @@ function getCachedCalls(index, filePath, options = {}) {
98
113
  content: options.includeContent ? content : undefined
99
114
  });
100
115
  index.callsCacheDirty = true;
116
+ // Incrementally update callee index with new calls
117
+ index._addToCalleeIndex(filePath, calls);
101
118
 
102
119
  if (options.includeContent) {
103
120
  return { calls, content };
@@ -109,7 +126,14 @@ function getCachedCalls(index, filePath, options = {}) {
109
126
  }
110
127
 
111
128
  /**
112
- * Find all callers of a function using AST-based detection
129
+ * Find all call sites that invoke the named symbol.
130
+ *
131
+ * ReceiverType filtering (nominal vs structural):
132
+ * - Nominal languages (Go/Java/Rust): uses call.receiverType (from parser-inferred
133
+ * method receivers, constructors, composite literals) to filter false positives.
134
+ * - Structural languages (JS/TS/Python): checks receiver binding evidence from imports
135
+ * instead of receiverType, since structural typing makes receiver types ambiguous.
136
+ *
113
137
  * @param {object} index - ProjectIndex instance
114
138
  * @param {string} name - Function name to find callers for
115
139
  * @param {object} [options] - Options
@@ -383,14 +407,14 @@ function findCallers(index, name, options = {}) {
383
407
  langTraits(fileEntry.language)?.typeSystem === 'structural') {
384
408
  const targetFiles = new Set(targetDefs.map(d => d.file).filter(Boolean));
385
409
  if (targetFiles.size > 0 && !targetFiles.has(filePath)) {
386
- const imports = index.importGraph.get(filePath) || [];
387
- const importsTarget = imports.some(imp => targetFiles.has(imp));
410
+ const imports = index.importGraph.get(filePath);
411
+ const importsTarget = imports && setSome(imports, imp => targetFiles.has(imp));
388
412
  if (!importsTarget) {
389
413
  // Check one level of re-exports (barrel files)
390
414
  let foundViaReexport = false;
391
- for (const imp of imports) {
392
- const transImports = index.importGraph.get(imp) || [];
393
- if (transImports.some(ti => targetFiles.has(ti))) {
415
+ if (imports) for (const imp of imports) {
416
+ const transImports = index.importGraph.get(imp);
417
+ if (transImports && setSome(transImports, ti => targetFiles.has(ti))) {
394
418
  foundViaReexport = true;
395
419
  break;
396
420
  }
@@ -448,10 +472,10 @@ function findCallers(index, name, options = {}) {
448
472
  continue;
449
473
  }
450
474
  // Multi-segment import — verify via import graph
451
- const callerImportedFiles = index.importGraph.get(filePath) || [];
475
+ const callerImportedFiles = index.importGraph.get(filePath);
452
476
  const targetFiles = new Set(targetDefs.map(d => d.file).filter(Boolean));
453
477
  if (!targetFiles.has(filePath)) {
454
- const hasImportEdge = callerImportedFiles.some(imp => targetFiles.has(imp));
478
+ const hasImportEdge = callerImportedFiles && setSome(callerImportedFiles, imp => targetFiles.has(imp));
455
479
  if (!hasImportEdge) {
456
480
  // No import edge — allow same-package (same directory) calls
457
481
  const callerDir = path.dirname(filePath);
@@ -576,13 +600,13 @@ function findCallers(index, name, options = {}) {
576
600
  // Check import graph evidence: does this file import from the target definition's file?
577
601
  const targetDefs2 = options.targetDefinitions || definitions;
578
602
  const targetFiles2 = new Set(targetDefs2.map(d => d.file).filter(Boolean));
579
- const callerImports = index.importGraph.get(filePath) || [];
580
- let hasImportLink = targetFiles2.has(filePath) || callerImports.some(imp => targetFiles2.has(imp));
603
+ const callerImports = index.importGraph.get(filePath);
604
+ let hasImportLink = targetFiles2.has(filePath) || (callerImports && setSome(callerImports, imp => targetFiles2.has(imp)));
581
605
  // Check one level of re-exports (barrel files) for import evidence
582
- if (!hasImportLink) {
606
+ if (!hasImportLink && callerImports) {
583
607
  for (const imp of callerImports) {
584
- const transImports = index.importGraph.get(imp) || [];
585
- if (transImports.some(ti => targetFiles2.has(ti))) {
608
+ const transImports = index.importGraph.get(imp);
609
+ if (transImports && setSome(transImports, ti => targetFiles2.has(ti))) {
586
610
  hasImportLink = true;
587
611
  break;
588
612
  }
@@ -652,7 +676,15 @@ function findCallers(index, name, options = {}) {
652
676
  }
653
677
 
654
678
  /**
655
- * Find all functions called by a function using AST-based detection
679
+ * Find all symbols called from within a function definition.
680
+ *
681
+ * Method resolution uses receiverType when available:
682
+ * - Go: receiverType from method receiver params + _buildTypedLocalTypeMap (New*() patterns)
683
+ * - Java: receiverType from `new Foo()` constructors + typed parameter declarations
684
+ * - Rust: receiverType from impl block context + _buildTypedLocalTypeMap
685
+ * - JS/TS: receiverType from constructor calls + import binding evidence
686
+ * - Python: receiverType from __init__ attribute type inference (getInstanceAttributeTypes)
687
+ *
656
688
  * @param {object} index - ProjectIndex instance
657
689
  * @param {object} def - Symbol definition with file, name, startLine, endLine
658
690
  * @param {object} [options] - Options
@@ -1160,7 +1192,7 @@ function findCallees(index, def, options = {}) {
1160
1192
  const defFileEntry = fileEntry;
1161
1193
  const callerIsTest = defFileEntry && isTestFile(defFileEntry.relativePath, defFileEntry.language);
1162
1194
  // Pre-compute import graph for callee confidence scoring
1163
- const callerImportSet = new Set(index.importGraph.get(def.file) || []);
1195
+ const callerImportSet = index.importGraph.get(def.file) || new Set();
1164
1196
 
1165
1197
  for (const { name: calleeName, bindingId, count, isConstructor } of callees.values()) {
1166
1198
  const symbols = index.symbols.get(calleeName);
@@ -1351,6 +1383,7 @@ function _buildLocalTypeMap(index, def, calls) {
1351
1383
  }
1352
1384
  const lines = content.split('\n');
1353
1385
  const localTypes = new Map();
1386
+ const regexCache = new Map();
1354
1387
 
1355
1388
  for (const call of calls) {
1356
1389
  // Only look at calls within this function's scope
@@ -1368,18 +1401,21 @@ function _buildLocalTypeMap(index, def, calls) {
1368
1401
  const sourceLine = lines[call.line - 1];
1369
1402
  if (!sourceLine) continue;
1370
1403
 
1371
- // Match: identifier = ClassName(...) or identifier: Type = ClassName(...)
1372
- const escapedName = call.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1373
- const assignMatch = sourceLine.match(
1374
- new RegExp(`(\\w+)\\s*(?::\\s*\\w+)?\\s*=\\s*${escapedName}\\s*\\(`)
1375
- );
1404
+ // Memoize compiled regex per call name (same name same pattern)
1405
+ let patterns = regexCache.get(call.name);
1406
+ if (!patterns) {
1407
+ const esc = call.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1408
+ patterns = {
1409
+ assign: new RegExp(`(\\w+)\\s*(?::\\s*\\w+)?\\s*=\\s*${esc}\\s*\\(`),
1410
+ with: new RegExp(`with\\s+${esc}\\s*\\([^)]*\\)\\s+as\\s+(\\w+)`)
1411
+ };
1412
+ regexCache.set(call.name, patterns);
1413
+ }
1414
+ const assignMatch = sourceLine.match(patterns.assign);
1376
1415
  if (assignMatch) {
1377
1416
  localTypes.set(assignMatch[1], call.name);
1378
1417
  }
1379
- // Match: with ClassName(...) as identifier:
1380
- const withMatch = sourceLine.match(
1381
- new RegExp(`with\\s+${escapedName}\\s*\\([^)]*\\)\\s+as\\s+(\\w+)`)
1382
- );
1418
+ const withMatch = sourceLine.match(patterns.with);
1383
1419
  if (withMatch) {
1384
1420
  localTypes.set(withMatch[1], call.name);
1385
1421
  }
@@ -1395,6 +1431,11 @@ function _buildLocalTypeMap(index, def, calls) {
1395
1431
  * @param {object} index - ProjectIndex instance
1396
1432
  * @param {object} def - Function definition with file, startLine, endLine
1397
1433
  * @param {Array} calls - Cached call sites for the file
1434
+ *
1435
+ * Sources: parser-inferred receiverType from method receivers, constructor calls,
1436
+ * composite literals. Used by Go, Java, Rust (nominal languages) to infer local
1437
+ * variable types for method resolution. Not used by JS/TS/Python -- structural
1438
+ * languages use import evidence via _buildLocalTypeMap instead.
1398
1439
  */
1399
1440
  function _buildTypedLocalTypeMap(index, def, calls) {
1400
1441
  const localTypes = new Map();
@@ -1412,10 +1453,11 @@ function _buildTypedLocalTypeMap(index, def, calls) {
1412
1453
  // Handles: x := NewFoo(), x, err := NewFoo(), x := pkg.NewFoo(), x, err := pkg.NewFoo()
1413
1454
  const newName = call.isMethod ? call.name : call.name;
1414
1455
  if (/^New[A-Z]/.test(newName) && !call.isPotentialCallback) {
1456
+ if (_cachedLines === false) continue; // File unreadable, skip all
1415
1457
  if (!_cachedLines) {
1416
1458
  try {
1417
1459
  _cachedLines = index._readFile(def.file).split('\n');
1418
- } catch { continue; }
1460
+ } catch { _cachedLines = false; continue; }
1419
1461
  }
1420
1462
  const sourceLine = _cachedLines[call.line - 1];
1421
1463
  if (!sourceLine) continue;
@@ -1437,7 +1479,10 @@ function _buildTypedLocalTypeMap(index, def, calls) {
1437
1479
  }
1438
1480
 
1439
1481
  /**
1440
- * Check if a function is used as a callback anywhere in the codebase
1482
+ * Find higher-order function usages where `name` is passed as a callback argument.
1483
+ * Handles patterns like .map(fn), setTimeout(fn), promise.then(handler).
1484
+ * Delegates to per-language findCallbackUsages implementations.
1485
+ *
1441
1486
  * @param {object} index - ProjectIndex instance
1442
1487
  * @param {string} name - Function name
1443
1488
  * @returns {Array} Callback usages