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 +24 -5
- package/core/cache.js +114 -60
- package/core/callers.js +45 -23
- package/core/deadcode.js +31 -2
- package/core/graph-build.js +4 -4
- package/core/graph.js +7 -10
- package/core/parallel-build.js +10 -7
- package/core/project.js +108 -38
- package/core/search.js +9 -5
- package/core/tracing.js +15 -6
- package/package.json +2 -2
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
83
|
-
|
|
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:
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
147
|
-
fs.writeFileSync(path.join(
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
//
|
|
247
|
-
//
|
|
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
|
-
|
|
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 :
|
|
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 :
|
|
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
|
-
*
|
|
345
|
-
*
|
|
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
|
|
350
|
-
if (index.
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
_loadCallsShard(index, hash);
|
|
369
|
-
}
|
|
370
|
-
return true;
|
|
381
|
+
index._callsCachePrepared = true;
|
|
382
|
+
return;
|
|
371
383
|
} catch (e) {
|
|
372
|
-
// Corrupted manifest — fall through
|
|
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 =
|
|
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 :
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
//
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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];
|
package/core/graph-build.js
CHANGED
|
@@ -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.
|
|
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).
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
594
|
+
if (targets && targets.size > 0) filesWithImports++;
|
|
598
595
|
}
|
|
599
596
|
|
|
600
597
|
return {
|
package/core/parallel-build.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
1562
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
440
|
-
if (!visited.has(
|
|
441
|
-
const
|
|
442
|
-
|
|
443
|
-
|
|
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.
|
|
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
|
},
|