ucn 5.2.1 → 5.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/index.js CHANGED
@@ -760,6 +760,9 @@ function runProjectCommand(rootDir, command, arg) {
760
760
  if (flags.cache && (needsCacheSave || index.callsCacheDirty || index.reachabilityDirty || index.computedDispatchDirty)) {
761
761
  try { index.saveCache(); } catch (e) { /* best-effort */ }
762
762
  }
763
+ if (flags.cache && index.usageCacheDirty) {
764
+ try { index.saveUsageCache(); } catch (e) { /* best-effort */ }
765
+ }
763
766
  }
764
767
  }
765
768
 
package/core/account.js CHANGED
@@ -36,14 +36,16 @@
36
36
  * Ground-set semantics are grep `-n -w`: unit is the (file, line) pair, each
37
37
  * line with >= 1 word-boundary match counts once, case-sensitive.
38
38
  *
39
- * Performance: the ground scan is one `includes()`-gated read per project file
40
- * per caller-command — the same I/O profile as the existing `search`/`usages`
41
- * commands. Deriving counts from callsCache (zero reads) was rejected because
39
+ * Performance: the first ground scan is one `includes()`-gated read per project
40
+ * file — the same I/O profile as the existing `search`/`usages` commands.
41
+ * Exact results are retained in a small LRU for the lifetime of one built index
42
+ * so context/about/impact projections of the same symbol do not rescan the
43
+ * project. Deriving counts from callsCache (zero reads) was rejected because
42
44
  * comments/strings/references are not in the calls cache and the contract's
43
45
  * ground set is text-defined. AST parsing (the expensive part) is restricted
44
- * to files containing UNCLAIMED ground lines, via the op-cached
45
- * `index._getCachedUsages`. No file is read twice in one command because
46
- * context/about/impact run inside `index._beginOp()`.
46
+ * to files containing UNCLAIMED ground lines, via the content-hash-keyed
47
+ * `index._getCachedUsages`. Build invalidation and cache bounds preserve the
48
+ * same answer without retaining an unbounded repository mirror.
47
49
  */
48
50
 
49
51
  'use strict';
@@ -75,6 +77,12 @@ const UNSUPPORTED_SITE_TEXT_MAX = 160;
75
77
  * }}
76
78
  */
77
79
  function computeGroundSet(index, name) {
80
+ if (index._groundSetCache?.has(name)) {
81
+ const cached = index._groundSetCache.get(name);
82
+ index._groundSetCache.delete(name);
83
+ index._groundSetCache.set(name, cached);
84
+ return cached.result;
85
+ }
78
86
  const wordRe = new RegExp('\\b' + escapeRegExp(name) + '\\b');
79
87
  const perFile = new Map();
80
88
  let total = 0;
@@ -117,7 +125,7 @@ function computeGroundSet(index, name) {
117
125
  ? index.discoveryIssues.map(issue => ({ ...issue })) : [];
118
126
  unreadableFiles.sort();
119
127
 
120
- return {
128
+ const result = {
121
129
  total: total + unparsed.lines + unsupported.lines,
122
130
  fileCount: fileCount + unparsed.fileCount + unsupported.fileCount,
123
131
  perFile,
@@ -126,6 +134,21 @@ function computeGroundSet(index, name) {
126
134
  unreadableFiles,
127
135
  skippedSources,
128
136
  };
137
+ if (index._groundSetCache) {
138
+ const weight = result.total + result.fileCount;
139
+ index._groundSetCache.set(name, { result, weight });
140
+ index._groundSetCacheLines = (index._groundSetCacheLines || 0) + weight;
141
+ const maxNames = 64;
142
+ const maxLines = 100000;
143
+ while (index._groundSetCache.size > maxNames ||
144
+ index._groundSetCacheLines > maxLines) {
145
+ const oldest = index._groundSetCache.entries().next().value;
146
+ if (!oldest) break;
147
+ index._groundSetCache.delete(oldest[0]);
148
+ index._groundSetCacheLines -= oldest[1].weight;
149
+ }
150
+ }
151
+ return result;
129
152
  }
130
153
 
131
154
  /** Scan only files the parser/index could not ingest. */
@@ -300,7 +323,12 @@ function classifyGroundLines(index, name, groundSet, claimedKeys) {
300
323
 
301
324
  // Remainder: AST usage scan distinguishes import/definition/reference
302
325
  // from comment/string/skipped-token lines.
303
- const usages = index._getCachedUsages(filePath, name);
326
+ // Call lines were classified from the complete calls cache above.
327
+ // Language adapters may skip usage-only call recovery here; notably,
328
+ // C/C++ avoids reparsing every matching macro replacement list.
329
+ const usages = index._getCachedUsages(filePath, name, {
330
+ skipCallRecovery: true,
331
+ });
304
332
  const byLine = new Map();
305
333
  if (Array.isArray(usages)) {
306
334
  for (const u of usages) {
package/core/cache.js CHANGED
@@ -26,8 +26,9 @@ const CACHE_PRUNE_INTERVAL_MS = 60 * 60 * 1000;
26
26
  const CACHE_MAX_PROJECTS = 128;
27
27
  const CACHE_MAX_BYTES = 1024 * 1024 * 1024;
28
28
 
29
- function discoveryRulesHash(root) {
30
- return crypto.createHash('md5').update(parseGitignore(root).join('\0')).digest('hex');
29
+ function discoveryRulesHash(root, patterns = null) {
30
+ const rules = patterns || parseGitignore(root);
31
+ return crypto.createHash('md5').update(rules.join('\0')).digest('hex');
31
32
  }
32
33
 
33
34
  /**
@@ -648,7 +649,127 @@ function clearAllCaches() {
648
649
  // reporting can classify import-time vs lazy edges from fresh and cached
649
650
  // indexes; C# properties retain property identity instead of masquerading as
650
651
  // ordinary fields for accessor impact/refactoring.
651
- const CACHE_FORMAT_VERSION = 189;
652
+ // v190: members of Rust generic impl owners retain ownerGenerics so blanket
653
+ // impl parameters (`impl<I> Trait for I`) cannot be mistaken for concrete
654
+ // receiver types after a cache round-trip (fix #302).
655
+ // v191: Python call records retain untyped loop-element provenance so a
656
+ // same-spelled project method cannot gain confirmed identity after reload.
657
+ // v192: JS/TS callback records carry moduleLocalBinding so dynamically
658
+ // produced module values cannot borrow target identity from file imports.
659
+ // v193: overload-heavy JS/TS class-member aliases materialize when every
660
+ // declared return has one concrete runtime head, changing indexed symbols.
661
+ // v194: JS/TS static fields preserve direct same-file callable forwarding for
662
+ // immutable module/export aliases (fix #313).
663
+ // v195: JS/TS call records retain safe ordered namespace-spread receiver
664
+ // compositions for exact module export ownership (fix #314).
665
+ // v196: TS parameter type references no longer mark namespace receiver roots
666
+ // as locally shadowed, changing receiverLocalBinding evidence (fix #315).
667
+ // v197 (fix #317): JS/TS expression-bodied arrow symbols persist the exact
668
+ // returned call span so query-time flow can resolve compiler-inferred factory
669
+ // results without treating block bodies or arbitrary expressions as returns.
670
+ // v198 (fix #318): JS/TS one-return methods persist exact this-field paths for
671
+ // generic declared-field result flow.
672
+ // v199 (fix #321): Python call records persist receiver types derived from
673
+ // exact, scope-local class-value aliases such as `_Segment = Segment`.
674
+ // v200 (fix #323): Python dotted module calls persist their exact import
675
+ // specifier (`import rich.repr` → `rich.repr.auto()`).
676
+ // v201 (fix #324): Python method-call records persist simple subscript roots
677
+ // and their local type provenance for `items[key].method()` dispatch; local
678
+ // callable aliases/non-callable bindings no longer leak across functions.
679
+ // v202 (fix #325): Python call records preserve exact typed subscript sources
680
+ // across one local assignment (`item = items[key]; item.method()`).
681
+ // v203 (fix #326): Python call records type stable, direct module constructor
682
+ // globals even when their functions are declared before the assignment.
683
+ // v204 (fix #328): Python union-alias symbols retain their concrete members
684
+ // so imported annotations can participate in exact conditional narrowing.
685
+ // v205 (fix #332): Go method-call records retain capture-aware lexical scope
686
+ // chains so return-type flow reaches nested closures without crossing shadows.
687
+ // v206 (fix #333): Go calls assigned in `var` declarations retain their
688
+ // declaration targets for compiler-return-type receiver flow.
689
+ // v207 (fix #334): Go method-call records retain compiler-exact receiver
690
+ // types for values bound from declared map, slice, and array indexes.
691
+ // v208 (fix #335): Go indexed-value calls preserve receiver-root/field
692
+ // provenance so sibling-file container declarations resolve query-time.
693
+ const CACHE_FORMAT_VERSION = 208;
694
+ const USAGE_CACHE_FILE = 'usage-results.json';
695
+
696
+ /**
697
+ * Persist the small hot-name usage cache independently of index/call shards.
698
+ * This keeps one-shot CLI repeats fast without rewriting the whole project
699
+ * cache after every read-only account query.
700
+ */
701
+ function saveUsageCache(index, cachePath) {
702
+ if (!index.usageCacheDirty) return null;
703
+ const cacheDir = cachePath
704
+ ? path.dirname(cachePath)
705
+ : getProjectCacheDir(index.root);
706
+ fs.mkdirSync(cacheDir, { recursive: true });
707
+ const entries = [];
708
+ for (const [key, cached] of index._usageResultCache || []) {
709
+ const parts = key.split('\0');
710
+ if (parts.length < 3 || !Array.isArray(cached?.value)) continue;
711
+ const [filePath, fileHash, name, mode = ''] = parts;
712
+ const fileEntry = index.files.get(filePath);
713
+ // Drop removed/changed-file generations rather than carrying dead LRU
714
+ // weight across incremental builds.
715
+ if (!fileEntry || fileEntry.hash !== fileHash) continue;
716
+ entries.push([
717
+ path.relative(index.root, filePath), fileHash, name, mode,
718
+ cached.value,
719
+ ]);
720
+ }
721
+ const usageFile = path.join(cacheDir, USAGE_CACHE_FILE);
722
+ const tmpFile = usageFile + '.tmp';
723
+ fs.writeFileSync(tmpFile, JSON.stringify({
724
+ version: CACHE_FORMAT_VERSION,
725
+ ucnVersion: UCN_VERSION,
726
+ entries,
727
+ }));
728
+ fs.renameSync(tmpFile, usageFile);
729
+ index.usageCacheDirty = false;
730
+ return usageFile;
731
+ }
732
+
733
+ function loadUsageCache(index, cacheFile) {
734
+ const usageFile = path.join(path.dirname(cacheFile), USAGE_CACHE_FILE);
735
+ if (!fs.existsSync(usageFile)) return false;
736
+ try {
737
+ const payload = JSON.parse(fs.readFileSync(usageFile, 'utf-8'));
738
+ if (payload.version !== CACHE_FORMAT_VERSION ||
739
+ payload.ucnVersion !== UCN_VERSION ||
740
+ !Array.isArray(payload.entries)) return false;
741
+ const restored = new Map();
742
+ let weightTotal = 0;
743
+ for (const entry of payload.entries) {
744
+ if (!Array.isArray(entry) || entry.length < 5) continue;
745
+ const [relativePath, fileHash, name, mode, value] = entry;
746
+ if (typeof relativePath !== 'string' ||
747
+ typeof fileHash !== 'string' ||
748
+ typeof name !== 'string' || typeof mode !== 'string' ||
749
+ !Array.isArray(value)) continue;
750
+ const filePath = path.resolve(index.root, relativePath);
751
+ const fileEntry = index.files.get(filePath);
752
+ if (!fileEntry || fileEntry.hash !== fileHash) continue;
753
+ const suffix = mode ? `\0${mode}` : '';
754
+ const key = `${filePath}\0${fileHash}\0${name}${suffix}`;
755
+ const weight = 64 + value.length * 40;
756
+ restored.set(key, { value, weight });
757
+ weightTotal += weight;
758
+ while (restored.size > 4096 || weightTotal > 16 * 1024 * 1024) {
759
+ const oldest = restored.entries().next().value;
760
+ if (!oldest) break;
761
+ restored.delete(oldest[0]);
762
+ weightTotal -= oldest[1].weight;
763
+ }
764
+ }
765
+ index._usageResultCache = restored;
766
+ index._usageResultCacheWeight = weightTotal;
767
+ index.usageCacheDirty = false;
768
+ return true;
769
+ } catch (_) {
770
+ return false;
771
+ }
772
+ }
652
773
 
653
774
  /**
654
775
  * Save index to cache file
@@ -926,8 +1047,14 @@ function loadCache(index, cachePath) {
926
1047
  : (relPath) => rootPrefix + relPath.replace(/\//g, path.sep);
927
1048
 
928
1049
  // Loading into a previously-used ProjectIndex replaces its indexed
929
- // contents, so no parsed tree from the old state may survive.
1050
+ // contents, so no parsed tree or cross-file derived query answer from
1051
+ // the old state may survive. Usage results are content-hash keyed and
1052
+ // remain safe; these caches are graph/text-universe keyed instead.
930
1053
  index._clearParsedTreeCache?.();
1054
+ index._groundSetCache = new Map();
1055
+ index._groundSetCacheLines = 0;
1056
+ index._nameBindingReachCache = new Map();
1057
+ index._returnTypeFlowCache = new Map();
931
1058
 
932
1059
  // Reconstruct files Map: relative key → absolute key, restore path and relativePath
933
1060
  // Initialize symbols/bindings arrays (will be populated from top-level symbols)
@@ -1075,6 +1202,8 @@ function loadCache(index, cachePath) {
1075
1202
  index.buildInheritanceGraph();
1076
1203
  }
1077
1204
 
1205
+ loadUsageCache(index, cacheFile);
1206
+
1078
1207
  return true;
1079
1208
  } catch (e) {
1080
1209
  return false;
@@ -1092,9 +1221,17 @@ function isCacheStale(index) {
1092
1221
  if (index._loadedConfigHash && currentConfigHash !== index._loadedConfigHash) {
1093
1222
  return true;
1094
1223
  }
1095
- if (index._loadedDiscoveryHash &&
1096
- discoveryRulesHash(index.root) !== index._loadedDiscoveryHash) {
1097
- return true;
1224
+ // Parse gitignore rules once for both the discovery fingerprint and the
1225
+ // new-file walk below. On a fresh cache these used to run two identical
1226
+ // `git ls-files` subprocesses per one-shot CLI invocation, accounting for
1227
+ // roughly a third of warm-start staleness time on measured repositories.
1228
+ let gitignorePatterns = null;
1229
+ if (index._loadedDiscoveryHash) {
1230
+ gitignorePatterns = parseGitignore(index.root);
1231
+ if (discoveryRulesHash(index.root, gitignorePatterns) !==
1232
+ index._loadedDiscoveryHash) {
1233
+ return true;
1234
+ }
1098
1235
  }
1099
1236
  // Modified/deleted detection (stat sweep) runs UNCONDITIONALLY — agents
1100
1237
  // edit a file and re-query through MCP within seconds, and a stale answer
@@ -1153,7 +1290,7 @@ function isCacheStale(index) {
1153
1290
  });
1154
1291
  },
1155
1292
  };
1156
- const gitignorePatterns = parseGitignore(index.root);
1293
+ if (!gitignorePatterns) gitignorePatterns = parseGitignore(index.root);
1157
1294
  globOpts.gitignorePatterns = gitignorePatterns;
1158
1295
  globOpts.trackedPaths = gitTrackedPaths(index.root);
1159
1296
  const configExclude = index.config.exclude || [];
@@ -1347,7 +1484,7 @@ function _computeReachabilityFingerprint(index) {
1347
1484
  }
1348
1485
 
1349
1486
  module.exports = {
1350
- saveCache, loadCache, loadCallsCache, isCacheStale, ensureCallsCacheLoaded,
1487
+ saveCache, saveUsageCache, loadCache, loadCallsCache, isCacheStale, ensureCallsCacheLoaded,
1351
1488
  getUserCacheRoot, getProjectCacheDir, getProjectCachePath,
1352
1489
  getLegacyProjectCacheDir, migrateLegacyProjectCache, clearProjectCache,
1353
1490
  clearAllCaches, pruneUserCache,