ucn 3.8.22 → 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/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 };
@@ -390,14 +407,14 @@ function findCallers(index, name, options = {}) {
390
407
  langTraits(fileEntry.language)?.typeSystem === 'structural') {
391
408
  const targetFiles = new Set(targetDefs.map(d => d.file).filter(Boolean));
392
409
  if (targetFiles.size > 0 && !targetFiles.has(filePath)) {
393
- const imports = index.importGraph.get(filePath) || [];
394
- 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));
395
412
  if (!importsTarget) {
396
413
  // Check one level of re-exports (barrel files)
397
414
  let foundViaReexport = false;
398
- for (const imp of imports) {
399
- const transImports = index.importGraph.get(imp) || [];
400
- 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))) {
401
418
  foundViaReexport = true;
402
419
  break;
403
420
  }
@@ -455,10 +472,10 @@ function findCallers(index, name, options = {}) {
455
472
  continue;
456
473
  }
457
474
  // Multi-segment import — verify via import graph
458
- const callerImportedFiles = index.importGraph.get(filePath) || [];
475
+ const callerImportedFiles = index.importGraph.get(filePath);
459
476
  const targetFiles = new Set(targetDefs.map(d => d.file).filter(Boolean));
460
477
  if (!targetFiles.has(filePath)) {
461
- const hasImportEdge = callerImportedFiles.some(imp => targetFiles.has(imp));
478
+ const hasImportEdge = callerImportedFiles && setSome(callerImportedFiles, imp => targetFiles.has(imp));
462
479
  if (!hasImportEdge) {
463
480
  // No import edge — allow same-package (same directory) calls
464
481
  const callerDir = path.dirname(filePath);
@@ -583,13 +600,13 @@ function findCallers(index, name, options = {}) {
583
600
  // Check import graph evidence: does this file import from the target definition's file?
584
601
  const targetDefs2 = options.targetDefinitions || definitions;
585
602
  const targetFiles2 = new Set(targetDefs2.map(d => d.file).filter(Boolean));
586
- const callerImports = index.importGraph.get(filePath) || [];
587
- 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)));
588
605
  // Check one level of re-exports (barrel files) for import evidence
589
- if (!hasImportLink) {
606
+ if (!hasImportLink && callerImports) {
590
607
  for (const imp of callerImports) {
591
- const transImports = index.importGraph.get(imp) || [];
592
- if (transImports.some(ti => targetFiles2.has(ti))) {
608
+ const transImports = index.importGraph.get(imp);
609
+ if (transImports && setSome(transImports, ti => targetFiles2.has(ti))) {
593
610
  hasImportLink = true;
594
611
  break;
595
612
  }
@@ -1175,7 +1192,7 @@ function findCallees(index, def, options = {}) {
1175
1192
  const defFileEntry = fileEntry;
1176
1193
  const callerIsTest = defFileEntry && isTestFile(defFileEntry.relativePath, defFileEntry.language);
1177
1194
  // Pre-compute import graph for callee confidence scoring
1178
- const callerImportSet = new Set(index.importGraph.get(def.file) || []);
1195
+ const callerImportSet = index.importGraph.get(def.file) || new Set();
1179
1196
 
1180
1197
  for (const { name: calleeName, bindingId, count, isConstructor } of callees.values()) {
1181
1198
  const symbols = index.symbols.get(calleeName);
@@ -1366,6 +1383,7 @@ function _buildLocalTypeMap(index, def, calls) {
1366
1383
  }
1367
1384
  const lines = content.split('\n');
1368
1385
  const localTypes = new Map();
1386
+ const regexCache = new Map();
1369
1387
 
1370
1388
  for (const call of calls) {
1371
1389
  // Only look at calls within this function's scope
@@ -1383,18 +1401,21 @@ function _buildLocalTypeMap(index, def, calls) {
1383
1401
  const sourceLine = lines[call.line - 1];
1384
1402
  if (!sourceLine) continue;
1385
1403
 
1386
- // Match: identifier = ClassName(...) or identifier: Type = ClassName(...)
1387
- const escapedName = call.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1388
- const assignMatch = sourceLine.match(
1389
- new RegExp(`(\\w+)\\s*(?::\\s*\\w+)?\\s*=\\s*${escapedName}\\s*\\(`)
1390
- );
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);
1391
1415
  if (assignMatch) {
1392
1416
  localTypes.set(assignMatch[1], call.name);
1393
1417
  }
1394
- // Match: with ClassName(...) as identifier:
1395
- const withMatch = sourceLine.match(
1396
- new RegExp(`with\\s+${escapedName}\\s*\\([^)]*\\)\\s+as\\s+(\\w+)`)
1397
- );
1418
+ const withMatch = sourceLine.match(patterns.with);
1398
1419
  if (withMatch) {
1399
1420
  localTypes.set(withMatch[1], call.name);
1400
1421
  }
@@ -1432,10 +1453,11 @@ function _buildTypedLocalTypeMap(index, def, calls) {
1432
1453
  // Handles: x := NewFoo(), x, err := NewFoo(), x := pkg.NewFoo(), x, err := pkg.NewFoo()
1433
1454
  const newName = call.isMethod ? call.name : call.name;
1434
1455
  if (/^New[A-Z]/.test(newName) && !call.isPotentialCallback) {
1456
+ if (_cachedLines === false) continue; // File unreadable, skip all
1435
1457
  if (!_cachedLines) {
1436
1458
  try {
1437
1459
  _cachedLines = index._readFile(def.file).split('\n');
1438
- } catch { continue; }
1460
+ } catch { _cachedLines = false; continue; }
1439
1461
  }
1440
1462
  const sourceLine = _cachedLines[call.line - 1];
1441
1463
  if (!sourceLine) continue;
package/core/deadcode.js CHANGED
@@ -212,6 +212,28 @@ function deadcode(index, options = {}) {
212
212
  }
213
213
  }
214
214
 
215
+ // Pre-filter exported symbols from the scan set when not auditing exports.
216
+ // Go exports ~63K capitalized names on K8s — scanning these in Phase 2 only to
217
+ // skip them in Phase 3 wastes O(63K × 11K files) = ~700M comparisons.
218
+ if (!options.includeExported) {
219
+ const narrowed = new Set();
220
+ for (const name of potentiallyDeadNames) {
221
+ const syms = index.symbols.get(name) || [];
222
+ // Keep the name only if at least one definition is NOT exported
223
+ const allExported = syms.every(s => {
224
+ const fe = index.files.get(s.file);
225
+ const lang = fe?.language;
226
+ if (!fe) return false;
227
+ return fe.exports.includes(name) ||
228
+ (s.modifiers || []).includes('export') ||
229
+ (s.modifiers || []).includes('public') ||
230
+ (langTraits(lang)?.exportVisibility === 'capitalization' && /^[A-Z]/.test(name));
231
+ });
232
+ if (!allExported) narrowed.add(name);
233
+ }
234
+ potentiallyDeadNames = narrowed;
235
+ }
236
+
215
237
  // When --file is provided, pre-filter to only names of symbols in the target scope.
216
238
  // The text scan below is O(potentiallyDeadNames × files) — narrowing the name set
217
239
  // avoids scanning all files for names that will be filtered out at the result stage.
@@ -236,9 +258,16 @@ function deadcode(index, options = {}) {
236
258
  for (const [filePath, fileEntry] of index.files) {
237
259
  try {
238
260
  const content = index._readFile(filePath);
239
- const lines = content.split('\n');
261
+ // Fast pre-filter: extract identifiers from file, intersect with target names.
262
+ // One regex pass over content (O(content)) vs O(names × content) substring searches.
263
+ const fileIdentifiers = new Set(content.match(/\b[a-zA-Z_]\w*\b/g));
264
+ const namesInFile = [];
240
265
  for (const name of potentiallyDeadNames) {
241
- if (!content.includes(name)) continue;
266
+ if (fileIdentifiers.has(name)) namesInFile.push(name);
267
+ }
268
+ if (namesInFile.length === 0) continue;
269
+ const lines = content.split('\n');
270
+ for (const name of namesInFile) {
242
271
  const nameLen = name.length;
243
272
  for (let i = 0; i < lines.length; i++) {
244
273
  const line = lines[i];
@@ -103,7 +103,7 @@ function buildImportGraph(index) {
103
103
  }
104
104
 
105
105
  for (const [filePath, fileEntry] of index.files) {
106
- const importedFiles = [];
106
+ const importedFiles = new Set();
107
107
  const seenModules = new Set();
108
108
 
109
109
  for (const importModule of fileEntry.imports) {
@@ -144,11 +144,11 @@ function buildImportGraph(index) {
144
144
  }
145
145
 
146
146
  for (const linkedFile of filesToLink) {
147
- importedFiles.push(linkedFile);
147
+ importedFiles.add(linkedFile);
148
148
  if (!index.exportGraph.has(linkedFile)) {
149
- index.exportGraph.set(linkedFile, []);
149
+ index.exportGraph.set(linkedFile, new Set());
150
150
  }
151
- index.exportGraph.get(linkedFile).push(filePath);
151
+ index.exportGraph.get(linkedFile).add(filePath);
152
152
  }
153
153
  }
154
154
  }
package/core/graph.js CHANGED
@@ -119,9 +119,9 @@ function exporters(index, filePath) {
119
119
 
120
120
  const targetPath = resolved;
121
121
 
122
- const importers = index.exportGraph.get(targetPath) || [];
122
+ const importers = index.exportGraph.get(targetPath) || new Set();
123
123
 
124
- return importers.map(importerPath => {
124
+ return [...importers].map(importerPath => {
125
125
  const fileEntry = index.files.get(importerPath);
126
126
 
127
127
  // Find the import line
@@ -470,13 +470,10 @@ function graph(index, filePath, options = {}) {
470
470
  if (depth >= maxDepth) return;
471
471
 
472
472
  const neighbors = dir === 'imports'
473
- ? (index.importGraph.get(file) || [])
474
- : (index.exportGraph.get(file) || []);
473
+ ? (index.importGraph.get(file) || new Set())
474
+ : (index.exportGraph.get(file) || new Set());
475
475
 
476
- // Deduplicate neighbors (same file may be imported multiple times, e.g. Java inner classes)
477
- const uniqueNeighbors = [...new Set(neighbors)];
478
-
479
- for (const neighbor of uniqueNeighbors) {
476
+ for (const neighbor of neighbors) {
480
477
  edges.push({ from: file, to: neighbor });
481
478
  traverse(neighbor, depth + 1);
482
479
  }
@@ -543,7 +540,7 @@ function circularDeps(index, options = {}) {
543
540
  color.set(file, GRAY);
544
541
  stack.push(file);
545
542
 
546
- const neighbors = [...new Set(index.importGraph.get(file) || [])];
543
+ const neighbors = index.importGraph.get(file) || new Set();
547
544
 
548
545
  for (const neighbor of neighbors) {
549
546
  if (neighbor === file) continue; // Skip self-imports (not a cycle)
@@ -594,7 +591,7 @@ function circularDeps(index, options = {}) {
594
591
  // Count files that participate in import graph (have edges)
595
592
  let filesWithImports = 0;
596
593
  for (const [, targets] of index.importGraph) {
597
- if (targets && targets.length > 0) filesWithImports++;
594
+ if (targets && targets.size > 0) filesWithImports++;
598
595
  }
599
596
 
600
597
  return {
@@ -40,12 +40,6 @@ function parallelBuild(index, files, options = {}) {
40
40
  console.error(`Parallel build: ${workerCount} workers for ${files.length} files`);
41
41
  }
42
42
 
43
- // Prepare existing hash data for skip-if-unchanged checks in workers
44
- const existingHashes = Object.create(null);
45
- for (const [fp, entry] of index.files) {
46
- existingHashes[fp] = { mtime: entry.mtime, size: entry.size, hash: entry.hash };
47
- }
48
-
49
43
  // Partition files round-robin for balanced work distribution
50
44
  const chunks = Array.from({ length: workerCount }, () => []);
51
45
  for (let i = 0; i < files.length; i++) {
@@ -63,11 +57,20 @@ function parallelBuild(index, files, options = {}) {
63
57
  const { port1, port2 } = new MessageChannel();
64
58
  ports.push(port1);
65
59
 
60
+ // Build per-worker hash subset (each worker only needs hashes for its chunk)
61
+ const workerHashes = Object.create(null);
62
+ for (const fp of chunks[i]) {
63
+ const entry = index.files.get(fp);
64
+ if (entry) {
65
+ workerHashes[fp] = { mtime: entry.mtime, size: entry.size, hash: entry.hash };
66
+ }
67
+ }
68
+
66
69
  const worker = new Worker(path.join(__dirname, 'build-worker.js'), {
67
70
  workerData: {
68
71
  files: chunks[i],
69
72
  rootDir: index.root,
70
- existingHashes,
73
+ existingHashes: workerHashes,
71
74
  signal: sab,
72
75
  workerIndex: i,
73
76
  port: port2,
package/core/project.js CHANGED
@@ -77,6 +77,7 @@ class ProjectIndex {
77
77
  this._opContentCache = new Map();
78
78
  this._opUsagesCache = new Map();
79
79
  this._opCallsCountCache = new Map();
80
+ this._opEnclosingFnCache = new Map();
80
81
  this._opDepth = 0;
81
82
  }
82
83
  this._opDepth++;
@@ -84,10 +85,17 @@ class ProjectIndex {
84
85
 
85
86
  /** End a per-operation content cache scope (only clears when outermost scope ends) */
86
87
  _endOp() {
88
+ if (!this._opContentCache) return; // Mismatched call — no active operation
87
89
  if (--this._opDepth <= 0) {
88
90
  this._opContentCache = null;
89
91
  this._opUsagesCache = null;
90
92
  this._opCallsCountCache = null;
93
+ this._opEnclosingFnCache = null;
94
+ // Free cached file content from callsCache entries (retained during
95
+ // operation for _readFile caching, not needed between operations)
96
+ for (const entry of this.callsCache.values()) {
97
+ if (entry.content !== undefined) entry.content = undefined;
98
+ }
91
99
  this._opDepth = 0;
92
100
  }
93
101
  }
@@ -474,14 +482,18 @@ class ProjectIndex {
474
482
  }
475
483
  }
476
484
 
477
- // Invalidate cached call data for this file
485
+ // Incrementally update callee index before deleting cached calls
486
+ const oldCached = this.callsCache.get(filePath);
487
+ if (oldCached) {
488
+ this._removeFromCalleeIndex(filePath, oldCached.calls);
489
+ }
478
490
  this.callsCache.delete(filePath);
479
491
 
480
- // Invalidate callee index (will be rebuilt lazily)
481
- this.calleeIndex = null;
482
-
483
492
  // Invalidate attribute type cache for this file
484
493
  if (this._attrTypeCache) this._attrTypeCache.delete(filePath);
494
+
495
+ // Invalidate lazy Java file index (will be rebuilt on next use)
496
+ this._javaFileIndex = null;
485
497
  }
486
498
 
487
499
  /**
@@ -490,6 +502,61 @@ class ProjectIndex {
490
502
  */
491
503
  _buildDirIndex() { graphBuildModule.buildDirIndex(this); }
492
504
 
505
+ /**
506
+ * Add a file's calls to the callee index (name → Set<filePath>).
507
+ * Used by buildCalleeIndex (full build) and getCachedCalls (incremental update).
508
+ */
509
+ _addToCalleeIndex(filePath, calls) {
510
+ if (!this.calleeIndex || !calls) return;
511
+ for (const call of calls) {
512
+ const name = call.name;
513
+ if (!this.calleeIndex.has(name)) {
514
+ this.calleeIndex.set(name, new Set());
515
+ }
516
+ this.calleeIndex.get(name).add(filePath);
517
+ if (call.resolvedName && call.resolvedName !== name) {
518
+ if (!this.calleeIndex.has(call.resolvedName)) {
519
+ this.calleeIndex.set(call.resolvedName, new Set());
520
+ }
521
+ this.calleeIndex.get(call.resolvedName).add(filePath);
522
+ }
523
+ if (call.resolvedNames) {
524
+ for (const rn of call.resolvedNames) {
525
+ if (rn !== name) {
526
+ if (!this.calleeIndex.has(rn)) {
527
+ this.calleeIndex.set(rn, new Set());
528
+ }
529
+ this.calleeIndex.get(rn).add(filePath);
530
+ }
531
+ }
532
+ }
533
+ }
534
+ }
535
+
536
+ /**
537
+ * Remove a file's calls from the callee index.
538
+ * Used by removeFileSymbols for incremental updates instead of full invalidation.
539
+ */
540
+ _removeFromCalleeIndex(filePath, calls) {
541
+ if (!this.calleeIndex || !calls) return;
542
+ for (const call of calls) {
543
+ const removeName = (n) => {
544
+ const fileSet = this.calleeIndex.get(n);
545
+ if (fileSet) {
546
+ fileSet.delete(filePath);
547
+ if (fileSet.size === 0) this.calleeIndex.delete(n);
548
+ }
549
+ };
550
+ removeName(call.name);
551
+ if (call.resolvedName && call.resolvedName !== call.name) removeName(call.resolvedName);
552
+ if (call.resolvedNames) {
553
+ for (const rn of call.resolvedNames) {
554
+ if (rn !== call.name) removeName(rn);
555
+ }
556
+ }
557
+ }
558
+ }
559
+
493
560
  /**
494
561
  * Build inverted call index: callee name -> Set<filePath>.
495
562
  * Built lazily on first findCallers call, from the calls cache.
@@ -497,37 +564,15 @@ class ProjectIndex {
497
564
  */
498
565
  buildCalleeIndex() {
499
566
  const { getCachedCalls } = require('./callers');
567
+ const { ensureCallsCacheLoaded } = require('./cache');
568
+ ensureCallsCacheLoaded(this);
500
569
  this.calleeIndex = new Map();
501
570
 
502
571
  for (const [filePath] of this.files) {
503
572
  // Fast path: use pre-populated callsCache (avoids stat per file)
504
573
  const cached = this.callsCache.get(filePath);
505
574
  const calls = cached ? cached.calls : getCachedCalls(this, filePath);
506
- if (!calls) continue;
507
- for (const call of calls) {
508
- const name = call.name;
509
- if (!this.calleeIndex.has(name)) {
510
- this.calleeIndex.set(name, new Set());
511
- }
512
- this.calleeIndex.get(name).add(filePath);
513
- // Also index resolvedName and resolvedNames for alias resolution
514
- if (call.resolvedName && call.resolvedName !== name) {
515
- if (!this.calleeIndex.has(call.resolvedName)) {
516
- this.calleeIndex.set(call.resolvedName, new Set());
517
- }
518
- this.calleeIndex.get(call.resolvedName).add(filePath);
519
- }
520
- if (call.resolvedNames) {
521
- for (const rn of call.resolvedNames) {
522
- if (rn !== name) {
523
- if (!this.calleeIndex.has(rn)) {
524
- this.calleeIndex.set(rn, new Set());
525
- }
526
- this.calleeIndex.get(rn).add(filePath);
527
- }
528
- }
529
- }
530
- }
575
+ this._addToCalleeIndex(filePath, calls);
531
576
  }
532
577
  }
533
578
 
@@ -548,6 +593,20 @@ class ProjectIndex {
548
593
  * Progressively strips trailing segments to find the class file.
549
594
  */
550
595
  _resolveJavaPackageImport(importModule, javaFileIndex) {
596
+ if (!javaFileIndex) {
597
+ // Lazy-build index to avoid O(N) fallback scan of all files
598
+ if (!this._javaFileIndex) {
599
+ this._javaFileIndex = new Map();
600
+ for (const [fp, fe] of this.files) {
601
+ if (fe.language === 'java') {
602
+ const name = path.basename(fp, '.java');
603
+ if (!this._javaFileIndex.has(name)) this._javaFileIndex.set(name, []);
604
+ this._javaFileIndex.get(name).push(fp);
605
+ }
606
+ }
607
+ }
608
+ javaFileIndex = this._javaFileIndex;
609
+ }
551
610
  return graphBuildModule._resolveJavaPackageImport(this, importModule, javaFileIndex);
552
611
  }
553
612
 
@@ -582,7 +641,7 @@ class ProjectIndex {
582
641
  if (contextFile) {
583
642
  const imports = this.importGraph.get(contextFile);
584
643
  if (imports) {
585
- const imported = entries.find(e => imports.includes(e.file));
644
+ const imported = entries.find(e => imports.has(e.file));
586
645
  if (imported) return imported.parents;
587
646
  }
588
647
  }
@@ -614,7 +673,7 @@ class ProjectIndex {
614
673
  if (contextFile) {
615
674
  const imports = this.importGraph.get(contextFile);
616
675
  if (imports) {
617
- const imported = classSymbols.find(s => imports.includes(s.file));
676
+ const imported = classSymbols.find(s => imports.has(s.file));
618
677
  if (imported) return imported.file;
619
678
  }
620
679
  }
@@ -824,7 +883,7 @@ class ProjectIndex {
824
883
  for (const candidate of tiedCandidates) {
825
884
  let importerCount = 0;
826
885
  for (const [, importedFiles] of this.importGraph) {
827
- if (importedFiles.includes(candidate.def.file)) {
886
+ if (importedFiles.has(candidate.def.file)) {
828
887
  importerCount++;
829
888
  }
830
889
  }
@@ -899,8 +958,7 @@ class ProjectIndex {
899
958
  const hasFilters = options.exclude && options.exclude.length > 0;
900
959
 
901
960
  // Pre-compute which files can reference THIS specific definition
902
- const importers = this.exportGraph.get(defFile) || [];
903
- const importersSet = new Set(importers);
961
+ const importersSet = this.exportGraph.get(defFile) || new Set();
904
962
  const defEntry = this.files.get(defFile);
905
963
  const isDirectoryScope = langTraits(defEntry?.language)?.packageScope === 'directory';
906
964
  const defDir = isDirectoryScope ? path.dirname(defFile) : null;
@@ -963,7 +1021,7 @@ class ProjectIndex {
963
1021
 
964
1022
  // Count imports from import graph (files that import from defFile and use this name)
965
1023
  let imports = 0;
966
- for (const importer of importers) {
1024
+ for (const importer of importersSet) {
967
1025
  const fe = this.files.get(importer);
968
1026
  if (!fe) continue;
969
1027
  if (hasFilters && !this.matchesFilters(fe.relativePath, { exclude: options.exclude })) continue;
@@ -1022,7 +1080,7 @@ class ProjectIndex {
1022
1080
 
1023
1081
  while (queue.length > 0) {
1024
1082
  const file = queue.pop();
1025
- const importersArr = this.exportGraph.get(file) || [];
1083
+ const importersArr = this.exportGraph.get(file) || new Set();
1026
1084
  for (const importer of importersArr) {
1027
1085
  if (!relevantFiles.has(importer)) {
1028
1086
  relevantFiles.add(importer);
@@ -1548,6 +1606,15 @@ class ProjectIndex {
1548
1606
  const fileEntry = this.files.get(filePath);
1549
1607
  if (!fileEntry) return null;
1550
1608
 
1609
+ // Per-operation cache: avoid rescanning symbols for same (file, line)
1610
+ const cacheKey = filePath + '\0' + lineNum;
1611
+ if (this._opEnclosingFnCache) {
1612
+ const cached = this._opEnclosingFnCache.get(cacheKey);
1613
+ if (cached !== undefined) {
1614
+ return cached === null ? null : (returnSymbol ? cached : cached.name);
1615
+ }
1616
+ }
1617
+
1551
1618
  let best = null;
1552
1619
  for (const symbol of fileEntry.symbols) {
1553
1620
  if (!NON_CALLABLE_TYPES.has(symbol.type) &&
@@ -1558,8 +1625,11 @@ class ProjectIndex {
1558
1625
  }
1559
1626
  }
1560
1627
  }
1561
- if (!best) return null;
1562
- return returnSymbol ? best : best.name;
1628
+
1629
+ if (this._opEnclosingFnCache) {
1630
+ this._opEnclosingFnCache.set(cacheKey, best);
1631
+ }
1632
+ return best ? (returnSymbol ? best : best.name) : null;
1563
1633
  }
1564
1634
 
1565
1635
  /** Get instance attribute types for a class in a file */
package/core/search.js CHANGED
@@ -221,11 +221,15 @@ function usages(index, name, options = {}) {
221
221
  let _importedHasDef = null;
222
222
  const importedFileHasDef = () => {
223
223
  if (_importedHasDef !== null) return _importedHasDef;
224
- const importedFiles = index.importGraph.get(filePath) || [];
225
- _importedHasDef = importedFiles.some(imp => {
224
+ const importedFiles = index.importGraph.get(filePath);
225
+ _importedHasDef = false;
226
+ if (importedFiles) for (const imp of importedFiles) {
226
227
  const impEntry = index.files.get(imp);
227
- return impEntry?.symbols?.some(s => s.name === name);
228
- });
228
+ if (impEntry?.symbols?.some(s => s.name === name)) {
229
+ _importedHasDef = true;
230
+ break;
231
+ }
232
+ }
229
233
  return _importedHasDef;
230
234
  };
231
235
 
@@ -1008,7 +1012,7 @@ function _buildSourceFileImporters(index, defs) {
1008
1012
 
1009
1013
  while (queue.length > 0) {
1010
1014
  const current = queue.shift();
1011
- const directImporters = index.exportGraph?.get(current) || [];
1015
+ const directImporters = index.exportGraph?.get(current) || new Set();
1012
1016
  for (const imp of directImporters) {
1013
1017
  importers.add(imp);
1014
1018
  // Check if this importer re-exports the symbol (barrel pattern).
package/core/tracing.js CHANGED
@@ -435,13 +435,22 @@ function reverseTrace(index, name, options = {}) {
435
435
  if (callerEntries.length > maxChildren) {
436
436
  node.truncatedChildren = callerEntries.length - maxChildren;
437
437
  // Count entry points in truncated branches so summary is accurate
438
+ // Use callerCache to avoid redundant findCallers calls
438
439
  for (const { def: cDef } of callerEntries.slice(maxChildren)) {
439
- const key = `${cDef.file}:${cDef.startLine}`;
440
- if (!visited.has(key)) {
441
- const cCallers = index.findCallers(cDef.name, {
442
- includeMethods, includeUncertain,
443
- targetDefinitions: cDef.bindingId ? [cDef] : undefined,
444
- });
440
+ const cKey = `${cDef.file}:${cDef.startLine}`;
441
+ if (!visited.has(cKey)) {
442
+ const cCacheKey = cDef.bindingId
443
+ ? `${cDef.name}:${cDef.bindingId}`
444
+ : `${cDef.name}:${cKey}`;
445
+ let cCallers = callerCache.get(cCacheKey);
446
+ if (!cCallers) {
447
+ cCallers = index.findCallers(cDef.name, {
448
+ includeMethods, includeUncertain,
449
+ targetDefinitions: cDef.bindingId ? [cDef] : undefined,
450
+ maxResults: 1, // Only need to know if any exist
451
+ });
452
+ callerCache.set(cCacheKey, cCallers);
453
+ }
445
454
  if (cCallers.length === 0) {
446
455
  entryPoints.push({ name: cDef.name, file: cDef.relativePath || path.relative(index.root, cDef.file), line: cDef.startLine });
447
456
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucn",
3
- "version": "3.8.22",
3
+ "version": "3.8.23",
4
4
  "mcpName": "io.github.mleoca/ucn",
5
5
  "description": "Code intelligence toolkit for AI agents — extract functions, trace call chains, find callers, detect dead code without reading entire files. Works as MCP server, CLI, or agent skill. Supports JS/TS, Python, Go, Rust, Java.",
6
6
  "main": "index.js",
@@ -9,7 +9,7 @@
9
9
  "ucn-mcp": "mcp/server.js"
10
10
  },
11
11
  "scripts": {
12
- "test": "node --test test/parser-unit.test.js test/integration.test.js test/cache.test.js test/formatter.test.js test/interactive.test.js test/feature.test.js test/regression-js.test.js test/regression-py.test.js test/regression-go.test.js test/regression-java.test.js test/regression-rust.test.js test/regression-cross.test.js test/regression-mcp.test.js test/regression-parser.test.js test/regression-commands.test.js test/regression-fixes.test.js test/regression-bugfixes.test.js test/cross-language.test.js test/accuracy.test.js test/command-coverage.test.js test/systematic-test.js test/mcp-edge-cases.js test/parity-test.js",
12
+ "test": "node --test test/parser-unit.test.js test/integration.test.js test/cache.test.js test/formatter.test.js test/interactive.test.js test/feature.test.js test/regression-js.test.js test/regression-py.test.js test/regression-go.test.js test/regression-java.test.js test/regression-rust.test.js test/regression-cross.test.js test/regression-mcp.test.js test/regression-parser.test.js test/regression-commands.test.js test/regression-fixes.test.js test/regression-bugfixes.test.js test/cross-language.test.js test/accuracy.test.js test/command-coverage.test.js test/perf-optimizations.test.js test/systematic-test.js test/mcp-edge-cases.js test/parity-test.js",
13
13
  "benchmark:agent": "node test/agent-understanding-benchmark.js",
14
14
  "lint": "eslint core/ cli/ mcp/ languages/"
15
15
  },