ucn 3.8.22 → 3.8.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.claude/skills/ucn/SKILL.md +114 -11
  2. package/README.md +152 -156
  3. package/cli/index.js +363 -37
  4. package/core/analysis.js +960 -37
  5. package/core/bridge.js +1111 -0
  6. package/core/brief.js +408 -0
  7. package/core/cache.js +213 -59
  8. package/core/callers.js +117 -41
  9. package/core/check.js +200 -0
  10. package/core/deadcode.js +31 -2
  11. package/core/discovery.js +57 -34
  12. package/core/entrypoints.js +638 -4
  13. package/core/execute.js +304 -5
  14. package/core/git-enrich.js +130 -0
  15. package/core/graph-build.js +4 -4
  16. package/core/graph.js +31 -12
  17. package/core/output/analysis.js +157 -25
  18. package/core/output/brief.js +100 -0
  19. package/core/output/check.js +79 -0
  20. package/core/output/doctor.js +85 -0
  21. package/core/output/endpoints.js +239 -0
  22. package/core/output/extraction.js +2 -0
  23. package/core/output/find.js +126 -39
  24. package/core/output/graph.js +48 -15
  25. package/core/output/refactoring.js +103 -5
  26. package/core/output/reporting.js +63 -23
  27. package/core/output/search.js +110 -17
  28. package/core/output/shared.js +56 -2
  29. package/core/output.js +4 -0
  30. package/core/parallel-build.js +10 -7
  31. package/core/parser.js +8 -2
  32. package/core/project.js +147 -41
  33. package/core/registry.js +30 -14
  34. package/core/reporting.js +465 -2
  35. package/core/search.js +139 -15
  36. package/core/shared.js +101 -5
  37. package/core/tracing.js +31 -12
  38. package/core/verify.js +982 -95
  39. package/languages/go.js +91 -6
  40. package/languages/html.js +10 -0
  41. package/languages/java.js +151 -35
  42. package/languages/javascript.js +290 -33
  43. package/languages/python.js +78 -11
  44. package/languages/rust.js +267 -12
  45. package/languages/utils.js +315 -3
  46. package/mcp/server.js +91 -16
  47. package/package.json +10 -2
package/core/cache.js CHANGED
@@ -68,34 +68,53 @@ 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]);
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.
84
+
85
+ // PERF-1: persist _reachableSymbols if computed. Set keys are
86
+ // "absolutePath:line"; we strip the root prefix on save and re-attach on
87
+ // load so paths stay portable. Sorted for stable output ordering.
88
+ //
89
+ // Also save a fingerprint so we can detect index drift on load: if the
90
+ // saved fingerprint matches the loaded index state, the cached set is
91
+ // still valid. If the index was rebuilt after load (stale cache → build),
92
+ // the fingerprint won't match and computeReachability will recompute.
93
+ let reachableSymbolsRel = undefined;
94
+ let reachableFingerprint = undefined;
95
+ if (index._reachableSymbols && index._reachableSymbols.size > 0) {
96
+ const rels = [];
97
+ for (const k of index._reachableSymbols) {
98
+ const colon = k.lastIndexOf(':');
99
+ if (colon < 0) continue;
100
+ const absFile = k.slice(0, colon);
101
+ const lineStr = k.slice(colon + 1);
102
+ const relFile = path.relative(root, absFile);
103
+ rels.push(`${relFile}:${lineStr}`);
91
104
  }
105
+ rels.sort(); // stable ordering — output contract
106
+ reachableSymbolsRel = rels;
107
+ reachableFingerprint = _computeReachabilityFingerprint(index);
92
108
  }
93
109
 
94
110
  const cacheData = {
95
- version: 7, // v7: strip symbols/bindings from file entries (dedup ~45% cache reduction)
111
+ // v10: persist _reachableSymbols set (computed by entrypoints.computeReachability)
112
+ version: 10,
96
113
  ucnVersion: UCN_VERSION, // Invalidate cache when UCN is updated
97
114
  configHash,
98
115
  root,
116
+ // PERF-2: refresh buildTime on each save so partial rebuilds report
117
+ // accurate stats. Falls back to original on first save.
99
118
  buildTime: index.buildTime,
100
119
  timestamp: Date.now(),
101
120
  files: strippedFiles,
@@ -108,23 +127,37 @@ function saveCache(index, cachePath) {
108
127
  failedFiles: index.failedFiles
109
128
  ? Array.from(index.failedFiles).map(f => path.relative(root, f))
110
129
  : [],
111
- ...(calleeIndexData && { calleeIndex: calleeIndexData })
130
+ ...(reachableSymbolsRel !== undefined && {
131
+ reachableSymbols: reachableSymbolsRel,
132
+ reachableFingerprint,
133
+ }),
112
134
  };
113
135
 
114
- fs.writeFileSync(cacheFile, JSON.stringify(cacheData));
136
+ // PERF-3: atomic write — tmp file + rename so concurrent readers/writers
137
+ // never see a torn JSON. The calls/ shard write below already does this.
138
+ const tmpFile = cacheFile + '.tmp';
139
+ fs.writeFileSync(tmpFile, JSON.stringify(cacheData));
140
+ fs.renameSync(tmpFile, cacheFile);
141
+
142
+ // MED-1 (Round 5): clear the reachabilityDirty flag now that the set is
143
+ // safely persisted. The cli/index.js cache-save guard checks this flag
144
+ // along with needsCacheSave/callsCacheDirty.
145
+ if (index.reachabilityDirty) {
146
+ index.reachabilityDirty = false;
147
+ }
115
148
 
116
- // Save callsCache sharded by directory for lazy loading
149
+ // Save callsCache sharded by directory for lazy loading.
150
+ // Write to a temp directory first, then atomic swap to avoid data loss on crash.
117
151
  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 });
152
+ const cacheDir = path.dirname(cacheFile);
153
+ const callsDir = path.join(cacheDir, 'calls');
154
+ const callsTmpDir = path.join(cacheDir, 'calls.tmp');
155
+
156
+ // Clean up any leftover temp dir from a previous crashed save
157
+ if (fs.existsSync(callsTmpDir)) {
158
+ fs.rmSync(callsTmpDir, { recursive: true, force: true });
126
159
  }
127
- fs.mkdirSync(callsDir, { recursive: true });
160
+ fs.mkdirSync(callsTmpDir, { recursive: true });
128
161
 
129
162
  // Group by directory
130
163
  const shards = new Map();
@@ -134,17 +167,29 @@ function saveCache(index, cachePath) {
134
167
  shards.get(dir).push([relPath, entry]);
135
168
  }
136
169
 
137
- // Write one shard per directory
170
+ // Write all shards to temp directory
138
171
  const shardManifest = [];
139
172
  for (const [dir, entries] of shards) {
140
173
  const hash = crypto.createHash('md5').update(dir).digest('hex').slice(0, 10);
141
- const shardFile = path.join(callsDir, `${hash}.json`);
174
+ const shardFile = path.join(callsTmpDir, `${hash}.json`);
142
175
  fs.writeFileSync(shardFile, JSON.stringify(entries));
143
176
  shardManifest.push([dir, hash, entries.length]);
144
177
  }
145
178
 
146
- // Write manifest for lazy loading
147
- fs.writeFileSync(path.join(callsDir, 'manifest.json'), JSON.stringify(shardManifest));
179
+ // Write manifest to temp directory
180
+ fs.writeFileSync(path.join(callsTmpDir, 'manifest.json'), JSON.stringify(shardManifest));
181
+
182
+ // Atomic swap: remove old, rename temp to final
183
+ if (fs.existsSync(callsDir)) {
184
+ fs.rmSync(callsDir, { recursive: true, force: true });
185
+ }
186
+ fs.renameSync(callsTmpDir, callsDir);
187
+
188
+ // Clean up legacy monolithic file
189
+ const legacyFile = path.join(cacheDir, 'calls-cache.json');
190
+ if (fs.existsSync(legacyFile)) {
191
+ fs.rmSync(legacyFile, { force: true });
192
+ }
148
193
  }
149
194
 
150
195
  return cacheFile;
@@ -168,7 +213,9 @@ function loadCache(index, cachePath) {
168
213
 
169
214
  // Check version compatibility
170
215
  // v7: symbols/bindings stripped from file entries (dedup)
171
- if (cacheData.version !== 7) {
216
+ // v9: addSymbol propagates isAsync/isGenerator/paramTypes (force rebuild for old)
217
+ // v10: persists _reachableSymbols set
218
+ if (cacheData.version !== 10) {
172
219
  return false;
173
220
  }
174
221
 
@@ -186,12 +233,19 @@ function loadCache(index, cachePath) {
186
233
  }
187
234
 
188
235
  const root = cacheData.root || index.root;
236
+ // Fast path conversion: string concat is ~70x faster than path.join for
237
+ // cache-stored relative paths (no '..' segments). On Windows, path.relative
238
+ // produces backslash paths, so rootPrefix uses the native separator.
239
+ const rootPrefix = root.endsWith(path.sep) ? root : root + path.sep;
240
+ const toAbs = path.sep === '/'
241
+ ? (relPath) => rootPrefix + relPath
242
+ : (relPath) => rootPrefix + relPath.replace(/\//g, path.sep);
189
243
 
190
244
  // Reconstruct files Map: relative key → absolute key, restore path and relativePath
191
245
  // Initialize symbols/bindings arrays (will be populated from top-level symbols)
192
246
  index.files = new Map();
193
247
  for (const [relPath, entry] of cacheData.files) {
194
- const absPath = path.join(root, relPath);
248
+ const absPath = toAbs(relPath);
195
249
  entry.path = absPath;
196
250
  entry.relativePath = relPath;
197
251
  if (!entry.symbols) entry.symbols = [];
@@ -204,7 +258,7 @@ function loadCache(index, cachePath) {
204
258
  index.symbols = new Map(cacheData.symbols);
205
259
  for (const [, defs] of index.symbols) {
206
260
  for (const s of defs) {
207
- if (!s.file && s.relativePath) s.file = path.join(root, s.relativePath);
261
+ if (!s.file && s.relativePath) s.file = toAbs(s.relativePath);
208
262
  if (!s.bindingId && s.relativePath && s.type && s.startLine) {
209
263
  s.bindingId = `${s.relativePath}:${s.type}:${s.startLine}`;
210
264
  }
@@ -222,11 +276,14 @@ function loadCache(index, cachePath) {
222
276
  }
223
277
  }
224
278
 
225
- // Reconstruct graphs: relative paths → absolute paths
279
+ // Reconstruct graphs: relative paths → absolute paths (as Sets)
280
+ // Uses string concat (toAbs) instead of path.join — 70x faster on 464K edges
226
281
  const absGraph = (data) => {
227
282
  const m = new Map();
228
283
  for (const [relKey, relValues] of data) {
229
- m.set(path.join(root, relKey), relValues.map(v => path.join(root, v)));
284
+ const absValues = new Set();
285
+ for (const v of relValues) absValues.add(toAbs(v));
286
+ m.set(toAbs(relKey), absValues);
230
287
  }
231
288
  return m;
232
289
  };
@@ -243,10 +300,10 @@ function loadCache(index, cachePath) {
243
300
  index.extendedByGraph = new Map(cacheData.extendedByGraph);
244
301
  }
245
302
 
246
- // Eagerly load callsCache from separate file.
247
- // Prevents 10K cold tree-sitter re-parses (2GB+ peak) when findCallers runs.
303
+ // Prepare lazy calls cache loading — load manifest but defer shard parsing.
304
+ // Shards are loaded on first getCachedCalls access via ensureCallsCacheLoaded().
248
305
  if (index.callsCache.size === 0) {
249
- loadCallsCache(index);
306
+ _prepareCallsCache(index);
250
307
  }
251
308
 
252
309
  // Build directory→files index from loaded data
@@ -257,21 +314,44 @@ function loadCache(index, cachePath) {
257
314
  // Restore failedFiles if present (convert relative paths back to absolute)
258
315
  if (Array.isArray(cacheData.failedFiles)) {
259
316
  index.failedFiles = new Set(
260
- cacheData.failedFiles.map(f => path.isAbsolute(f) ? f : path.join(root, f))
317
+ cacheData.failedFiles.map(f => path.isAbsolute(f) ? f : toAbs(f))
261
318
  );
262
319
  }
263
320
 
264
- // Restore calleeIndex if persisted
321
+ // Restore calleeIndex if persisted (v7 caches only; v8+ rebuilds lazily)
265
322
  if (Array.isArray(cacheData.calleeIndex)) {
266
323
  index.calleeIndex = new Map();
267
324
  for (const [name, files] of cacheData.calleeIndex) {
268
325
  if (!Array.isArray(files)) continue;
269
326
  index.calleeIndex.set(name, new Set(
270
- files.map(f => path.isAbsolute(f) ? f : path.join(root, f))
327
+ files.map(f => path.isAbsolute(f) ? f : toAbs(f))
271
328
  ));
272
329
  }
273
330
  }
274
331
 
332
+ // PERF-1: restore _reachableSymbols if persisted (v10+).
333
+ // Saved as relative-path keys; rehydrate to absolute keys here so the
334
+ // in-memory set matches what computeReachability would produce fresh.
335
+ // The fingerprint is checked by computeReachability before reuse — if
336
+ // the index drifts (e.g. a rebuild after stale cache), the cached set
337
+ // is dropped and recomputed.
338
+ if (Array.isArray(cacheData.reachableSymbols)) {
339
+ const reachable = new Set();
340
+ for (const k of cacheData.reachableSymbols) {
341
+ if (typeof k !== 'string') continue;
342
+ const colon = k.lastIndexOf(':');
343
+ if (colon < 0) continue;
344
+ const relFile = k.slice(0, colon);
345
+ const lineStr = k.slice(colon + 1);
346
+ const absFile = path.isAbsolute(relFile) ? relFile : toAbs(relFile);
347
+ reachable.add(`${absFile}:${lineStr}`);
348
+ }
349
+ index._reachableSymbols = reachable;
350
+ if (cacheData.reachableFingerprint) {
351
+ index._reachableFingerprint = cacheData.reachableFingerprint;
352
+ }
353
+ }
354
+
275
355
  // Only rebuild graphs if config changed (e.g., aliases modified)
276
356
  const currentConfigHash = crypto.createHash('md5')
277
357
  .update(JSON.stringify(index.config || {})).digest('hex');
@@ -292,6 +372,12 @@ function loadCache(index, cachePath) {
292
372
  * @returns {boolean} - True if cache needs rebuilding
293
373
  */
294
374
  function isCacheStale(index) {
375
+ // Ultra-fast path: skip full check if last confirmed-fresh < 2s ago (covers MCP burst calls).
376
+ // Only uses _lastFreshAt (set at the end of a successful full check), not cache save timestamp.
377
+ if (index._lastFreshAt && Date.now() - index._lastFreshAt < 2000) {
378
+ return false;
379
+ }
380
+
295
381
  // Fast path: check cached files for modifications/deletions first (stat-only).
296
382
  // This returns early without the expensive directory walk when any file changed.
297
383
  for (const [filePath, fileEntry] of index.files) {
@@ -337,44 +423,64 @@ function isCacheStale(index) {
337
423
  }
338
424
  }
339
425
 
426
+ // Record when we last confirmed the cache is fresh (enables 2s skip on burst calls)
427
+ index._lastFreshAt = Date.now();
340
428
  return false;
341
429
  }
342
430
 
343
431
  /**
344
- * Load callsCache from separate file on demand.
345
- * Only loads if callsCache is empty (not already populated from inline or prior load).
432
+ * Prepare calls cache for lazy loading — reads manifest but defers shard parsing.
433
+ * Called during loadCache() to set up the manifest without the ~1s shard parse cost.
434
+ * Actual shards are loaded on first ensureCallsCacheLoaded() call.
346
435
  * @param {object} index - ProjectIndex instance
347
- * @returns {boolean} - True if loaded successfully
348
436
  */
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
-
437
+ function _prepareCallsCache(index) {
438
+ if (index._callsCacheLoaded) return;
354
439
  const cacheDir = path.join(index.root, '.ucn-cache');
355
-
356
- // Try sharded format first (calls/manifest.json)
357
440
  const manifestFile = path.join(cacheDir, 'calls', 'manifest.json');
358
441
  if (fs.existsSync(manifestFile)) {
359
442
  try {
360
443
  const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf-8'));
361
- // Store manifest for lazy loading
362
444
  index._callsManifest = new Map();
363
445
  for (const [dir, hash, count] of manifest) {
364
446
  index._callsManifest.set(dir, { hash, count, loaded: false });
365
447
  }
366
- // Eagerly load all shards (matches previous behavior)
367
- for (const [, { hash }] of index._callsManifest) {
368
- _loadCallsShard(index, hash);
369
- }
370
- return true;
448
+ index._callsCachePrepared = true;
449
+ return;
371
450
  } catch (e) {
372
- // Corrupted manifest — fall through to legacy
451
+ // Corrupted manifest — fall through
452
+ }
453
+ }
454
+ // Check legacy format
455
+ const legacyFile = path.join(cacheDir, 'calls-cache.json');
456
+ if (fs.existsSync(legacyFile)) {
457
+ index._callsCacheLegacyFile = legacyFile;
458
+ index._callsCachePrepared = true;
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Load callsCache from separate file on demand.
464
+ * Only loads if callsCache is empty (not already populated from inline or prior load).
465
+ * @param {object} index - ProjectIndex instance
466
+ * @returns {boolean} - True if loaded successfully
467
+ */
468
+ function loadCallsCache(index) {
469
+ if (index.callsCache.size > 0) return true; // Already populated
470
+ if (index._callsCacheLoaded) return false; // Already attempted, file didn't exist
471
+ index._callsCacheLoaded = true;
472
+
473
+ // If manifest was prepared lazily, load all shards now
474
+ if (index._callsManifest) {
475
+ for (const [, { hash }] of index._callsManifest) {
476
+ _loadCallsShard(index, hash);
373
477
  }
478
+ return index.callsCache.size > 0;
374
479
  }
375
480
 
376
481
  // Legacy format: single calls-cache.json
377
- const callsCacheFile = path.join(cacheDir, 'calls-cache.json');
482
+ const callsCacheFile = index._callsCacheLegacyFile ||
483
+ path.join(index.root, '.ucn-cache', 'calls-cache.json');
378
484
  if (!fs.existsSync(callsCacheFile)) return false;
379
485
 
380
486
  try {
@@ -393,6 +499,17 @@ function loadCallsCache(index) {
393
499
  return false;
394
500
  }
395
501
 
502
+ /**
503
+ * Ensure calls cache is fully loaded (trigger lazy load if prepared but not loaded).
504
+ * Call this before any operation that needs callsCache (findCallers, buildCalleeIndex, etc.)
505
+ * @param {object} index - ProjectIndex instance
506
+ */
507
+ function ensureCallsCacheLoaded(index) {
508
+ if (index._callsCachePrepared && !index._callsCacheLoaded) {
509
+ loadCallsCache(index);
510
+ }
511
+ }
512
+
396
513
  /**
397
514
  * Load a single calls shard by hash.
398
515
  * @param {object} index - ProjectIndex instance
@@ -403,9 +520,13 @@ function _loadCallsShard(index, hash) {
403
520
  try {
404
521
  const data = JSON.parse(fs.readFileSync(shardFile, 'utf-8'));
405
522
  if (!Array.isArray(data)) return;
523
+ const rootPrefix = index.root.endsWith(path.sep) ? index.root : index.root + path.sep;
524
+ const toAbsShard = path.sep === '/'
525
+ ? (rp) => rootPrefix + rp
526
+ : (rp) => rootPrefix + rp.replace(/\//g, path.sep);
406
527
  for (const [relPath, entry] of data) {
407
528
  if (!relPath || !entry) continue;
408
- const absPath = path.isAbsolute(relPath) ? relPath : path.join(index.root, relPath);
529
+ const absPath = path.isAbsolute(relPath) ? relPath : toAbsShard(relPath);
409
530
  index.callsCache.set(absPath, entry);
410
531
  }
411
532
  } catch (e) {
@@ -413,4 +534,37 @@ function _loadCallsShard(index, hash) {
413
534
  }
414
535
  }
415
536
 
416
- module.exports = { saveCache, loadCache, loadCallsCache, isCacheStale };
537
+ /**
538
+ * Compute a cheap fingerprint of the index used to detect drift since the
539
+ * last reachability computation. Two states with the same fingerprint are
540
+ * indistinguishable for reachability purposes (file count + symbol count are
541
+ * monotonic with structural changes; an extra `entries[0]` byte detects most
542
+ * incremental rebuilds even when counts happen to match).
543
+ *
544
+ * Used by entrypoints.computeReachability to decide whether the persisted
545
+ * `_reachableSymbols` set is still valid.
546
+ *
547
+ * @param {object} index - ProjectIndex instance
548
+ * @returns {string} compact fingerprint
549
+ */
550
+ function _computeReachabilityFingerprint(index) {
551
+ const fileCount = index.files ? index.files.size : 0;
552
+ const symbolCount = index.symbols ? index.symbols.size : 0;
553
+ // Sample a tiny prefix of the symbol map for a cheap structural check.
554
+ // Map iteration order is insertion order, which is stable across an
555
+ // unmodified load (built from cacheData.symbols in the same order).
556
+ let sample = '';
557
+ if (index.symbols && index.symbols.size > 0) {
558
+ let count = 0;
559
+ for (const [name, defs] of index.symbols) {
560
+ sample += name + ':' + (Array.isArray(defs) ? defs.length : 0) + '|';
561
+ if (++count >= 8) break;
562
+ }
563
+ }
564
+ return `${fileCount}:${symbolCount}:${sample}`;
565
+ }
566
+
567
+ module.exports = {
568
+ saveCache, loadCache, loadCallsCache, isCacheStale, ensureCallsCacheLoaded,
569
+ _computeReachabilityFingerprint,
570
+ };