ucn 4.2.3 → 5.0.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.
Files changed (72) hide show
  1. package/.claude/skills/ucn/SKILL.md +89 -77
  2. package/.claude/skills/ucn/references/commands.md +62 -68
  3. package/.claude/skills/ucn/references/trust-contract.md +31 -6
  4. package/README.md +438 -305
  5. package/assets/demo.svg +31 -0
  6. package/cli/index.js +430 -1385
  7. package/core/account.js +144 -34
  8. package/core/analysis.js +182 -72
  9. package/core/ast-analysis.js +279 -0
  10. package/core/bridge.js +205 -24
  11. package/core/brief.js +27 -58
  12. package/core/build-worker.js +21 -140
  13. package/core/cache.js +513 -11
  14. package/core/callers.js +4920 -456
  15. package/core/check.js +13 -4
  16. package/core/command-contracts.js +402 -0
  17. package/core/compilation-database.js +276 -0
  18. package/core/confidence.js +4 -1
  19. package/core/deadcode.js +397 -19
  20. package/core/discovery.js +359 -46
  21. package/core/entrypoints.js +195 -41
  22. package/core/execute.js +887 -81
  23. package/core/graph-build.js +162 -7
  24. package/core/graph.js +53 -77
  25. package/core/imports.js +65 -6
  26. package/core/index-ir.js +138 -0
  27. package/core/ir.js +195 -0
  28. package/core/output/analysis.js +212 -22
  29. package/core/output/brief.js +23 -0
  30. package/core/output/check.js +4 -0
  31. package/core/output/doctor.js +37 -6
  32. package/core/output/endpoints.js +5 -2
  33. package/core/output/extraction.js +24 -12
  34. package/core/output/find.js +141 -36
  35. package/core/output/graph.js +11 -5
  36. package/core/output/public.js +462 -0
  37. package/core/output/refactoring.js +42 -10
  38. package/core/output/reporting.js +97 -20
  39. package/core/output/search.js +24 -16
  40. package/core/output/shared.js +22 -1
  41. package/core/output/tracing.js +30 -15
  42. package/core/output-budget.js +295 -0
  43. package/core/output.js +1 -0
  44. package/core/parallel-build.js +44 -11
  45. package/core/parser.js +3 -3
  46. package/core/project.js +384 -187
  47. package/core/public-command.js +47 -0
  48. package/core/registry.js +247 -117
  49. package/core/reporting.js +312 -290
  50. package/core/search.js +317 -185
  51. package/core/semantic-provider.js +110 -0
  52. package/core/stacktrace.js +25 -0
  53. package/core/tracing.js +101 -51
  54. package/core/trust-matrix.js +19 -40
  55. package/core/verify.js +534 -37
  56. package/languages/adapter.js +218 -0
  57. package/languages/c-family.js +2791 -0
  58. package/languages/c.js +3 -0
  59. package/languages/cpp.js +3 -0
  60. package/languages/csharp.js +1402 -0
  61. package/languages/go.js +60 -21
  62. package/languages/html.js +2 -2
  63. package/languages/index.js +85 -7
  64. package/languages/java.js +396 -13
  65. package/languages/javascript.js +199 -19
  66. package/languages/python.js +964 -22
  67. package/languages/rust.js +1317 -152
  68. package/languages/utils.js +40 -3
  69. package/mcp/server.js +254 -636
  70. package/package.json +39 -22
  71. package/eslint.config.js +0 -43
  72. package/jsconfig.json +0 -10
package/core/cache.js CHANGED
@@ -7,12 +7,302 @@
7
7
 
8
8
  const fs = require('fs');
9
9
  const path = require('path');
10
+ const os = require('os');
10
11
  const crypto = require('crypto');
11
- const { expandGlob, detectProjectPattern, parseGitignore, DEFAULT_IGNORES } = require('./discovery');
12
+ const {
13
+ expandGlob, detectProjectPattern, parseGitignore, gitTrackedPaths, DEFAULT_IGNORES,
14
+ classifyUnsupportedSourceFile,
15
+ } = require('./discovery');
16
+ const { codeUnitCompare } = require('./shared');
12
17
 
13
18
  // Read UCN version for cache invalidation
14
19
  const UCN_VERSION = require('../package.json').version;
15
20
 
21
+ const CACHE_DIRECTORY_NAME = 'ucn';
22
+ const CACHE_PROJECTS_DIRECTORY = 'projects';
23
+ const LEGACY_CACHE_DIRECTORY = '.ucn-cache';
24
+ const CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
25
+ const CACHE_PRUNE_INTERVAL_MS = 60 * 60 * 1000;
26
+ const CACHE_MAX_PROJECTS = 128;
27
+ const CACHE_MAX_BYTES = 1024 * 1024 * 1024;
28
+
29
+ function discoveryRulesHash(root) {
30
+ return crypto.createHash('md5').update(parseGitignore(root).join('\0')).digest('hex');
31
+ }
32
+
33
+ /**
34
+ * Resolve the per-user cache root without writing anything.
35
+ *
36
+ * UCN_CACHE_DIR is the explicit override. Otherwise follow the host cache
37
+ * convention: XDG_CACHE_HOME on Unix, Library/Caches on macOS, and
38
+ * LOCALAPPDATA on Windows. Every fallback remains below the user's home.
39
+ *
40
+ * @param {object} [options]
41
+ * @param {NodeJS.ProcessEnv} [options.env]
42
+ * @param {string} [options.platform]
43
+ * @param {string} [options.homeDir]
44
+ * @returns {string}
45
+ */
46
+ function getUserCacheRoot({
47
+ env = process.env,
48
+ platform = process.platform,
49
+ homeDir = os.homedir(),
50
+ } = {}) {
51
+ const resolveConfiguredPath = (configured) => {
52
+ const value = String(configured || '').trim();
53
+ if (!value) return null;
54
+ if (value === '~') return homeDir;
55
+ if (value.startsWith('~/') || value.startsWith('~\\')) {
56
+ return path.resolve(homeDir, value.slice(2));
57
+ }
58
+ return path.resolve(value);
59
+ };
60
+
61
+ const explicit = resolveConfiguredPath(env.UCN_CACHE_DIR);
62
+ if (explicit) return explicit;
63
+
64
+ const xdg = resolveConfiguredPath(env.XDG_CACHE_HOME);
65
+ if (xdg) return path.join(xdg, CACHE_DIRECTORY_NAME);
66
+
67
+ if (platform === 'darwin') {
68
+ return path.join(homeDir, 'Library', 'Caches', CACHE_DIRECTORY_NAME);
69
+ }
70
+ if (platform === 'win32') {
71
+ const localAppData = resolveConfiguredPath(env.LOCALAPPDATA);
72
+ return localAppData
73
+ ? path.join(localAppData, CACHE_DIRECTORY_NAME, 'cache')
74
+ : path.join(homeDir, 'AppData', 'Local', CACHE_DIRECTORY_NAME, 'cache');
75
+ }
76
+ return path.join(homeDir, '.cache', CACHE_DIRECTORY_NAME);
77
+ }
78
+
79
+ /**
80
+ * Canonicalize a project root for cache identity. Symlinked paths to the same
81
+ * checkout share one cache, while separate copies remain isolated.
82
+ *
83
+ * @param {string} projectRoot
84
+ * @returns {string}
85
+ */
86
+ function canonicalProjectRoot(projectRoot) {
87
+ const resolved = path.resolve(projectRoot);
88
+ try {
89
+ const realpath = fs.realpathSync.native || fs.realpathSync;
90
+ return realpath(resolved);
91
+ } catch (_) {
92
+ return resolved;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Return the default cache directory for one project.
98
+ *
99
+ * The readable basename aids manual inspection; the canonical-path hash
100
+ * prevents collisions between unrelated projects with the same directory
101
+ * name without exposing the full checkout path.
102
+ *
103
+ * @param {string} projectRoot
104
+ * @param {object} [options] - Forwarded to getUserCacheRoot()
105
+ * @returns {string}
106
+ */
107
+ function getProjectCacheDir(projectRoot, options) {
108
+ const canonicalRoot = canonicalProjectRoot(projectRoot);
109
+ const slug = (path.basename(canonicalRoot) || 'project')
110
+ .replace(/[^A-Za-z0-9._-]+/g, '-')
111
+ .replace(/^-+|-+$/g, '')
112
+ .slice(0, 48) || 'project';
113
+ const identity = process.platform === 'win32'
114
+ ? canonicalRoot.toLowerCase()
115
+ : canonicalRoot;
116
+ const hash = crypto.createHash('sha256').update(identity).digest('hex').slice(0, 20);
117
+ return path.join(getUserCacheRoot(options), CACHE_PROJECTS_DIRECTORY, `${slug}-${hash}`);
118
+ }
119
+
120
+ /**
121
+ * @param {string} projectRoot
122
+ * @param {object} [options]
123
+ * @returns {string}
124
+ */
125
+ function getProjectCachePath(projectRoot, options) {
126
+ return path.join(getProjectCacheDir(projectRoot, options), 'index.json');
127
+ }
128
+
129
+ /**
130
+ * @param {string} projectRoot
131
+ * @returns {string}
132
+ */
133
+ function getLegacyProjectCacheDir(projectRoot) {
134
+ return path.join(path.resolve(projectRoot), LEGACY_CACHE_DIRECTORY);
135
+ }
136
+
137
+ /**
138
+ * Move a legacy project-local cache to the per-user location. Migration is
139
+ * best-effort because cache availability must never block an analysis query.
140
+ * Symlinks are left untouched to avoid following or deleting user-managed
141
+ * paths outside the project.
142
+ *
143
+ * @param {string} projectRoot
144
+ * @param {string} [targetDir]
145
+ * @returns {boolean} True when a legacy directory was removed or migrated.
146
+ */
147
+ function migrateLegacyProjectCache(projectRoot, targetDir = getProjectCacheDir(projectRoot)) {
148
+ const legacyDir = getLegacyProjectCacheDir(projectRoot);
149
+ let legacyStat;
150
+ try {
151
+ legacyStat = fs.lstatSync(legacyDir);
152
+ } catch (_) {
153
+ return false;
154
+ }
155
+ if (!legacyStat.isDirectory() || legacyStat.isSymbolicLink()) return false;
156
+
157
+ try {
158
+ if (fs.existsSync(targetDir)) {
159
+ fs.rmSync(legacyDir, { recursive: true, force: true });
160
+ return true;
161
+ }
162
+
163
+ fs.mkdirSync(path.dirname(targetDir), { recursive: true });
164
+ try {
165
+ fs.renameSync(legacyDir, targetDir);
166
+ return true;
167
+ } catch (error) {
168
+ if (error.code !== 'EXDEV') throw error;
169
+ }
170
+
171
+ const tempDir = `${targetDir}.migrating-${process.pid}-${Date.now()}`;
172
+ try {
173
+ fs.cpSync(legacyDir, tempDir, { recursive: true, errorOnExist: true });
174
+ fs.renameSync(tempDir, targetDir);
175
+ fs.rmSync(legacyDir, { recursive: true, force: true });
176
+ return true;
177
+ } catch (_) {
178
+ fs.rmSync(tempDir, { recursive: true, force: true });
179
+ return false;
180
+ }
181
+ } catch (_) {
182
+ return false;
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Remove both the current per-user cache and the obsolete project-local cache
188
+ * for one project.
189
+ *
190
+ * @param {string} projectRoot
191
+ * @returns {string[]} Removed directories.
192
+ */
193
+ function clearProjectCache(projectRoot) {
194
+ const removed = [];
195
+ for (const cacheDir of [
196
+ getProjectCacheDir(projectRoot),
197
+ getLegacyProjectCacheDir(projectRoot),
198
+ ]) {
199
+ try {
200
+ if (!fs.existsSync(cacheDir)) continue;
201
+ fs.rmSync(cacheDir, { recursive: true, force: true });
202
+ removed.push(cacheDir);
203
+ } catch (_) {
204
+ // Cache cleanup is best-effort; callers can rebuild regardless.
205
+ }
206
+ }
207
+ return removed;
208
+ }
209
+
210
+ function directorySize(root) {
211
+ let total = 0;
212
+ const pending = [root];
213
+ while (pending.length > 0) {
214
+ const current = pending.pop();
215
+ let entries;
216
+ try { entries = fs.readdirSync(current, { withFileTypes: true }); }
217
+ catch (_) { continue; }
218
+ for (const entry of entries) {
219
+ const target = path.join(current, entry.name);
220
+ if (entry.isSymbolicLink()) continue;
221
+ if (entry.isDirectory()) pending.push(target);
222
+ else {
223
+ try { total += fs.statSync(target).size; } catch (_) { /* raced */ }
224
+ }
225
+ }
226
+ }
227
+ return total;
228
+ }
229
+
230
+ /** Bound the shared per-user cache by age, project count, and total bytes. */
231
+ function pruneUserCache({ force = false, now = Date.now() } = {}) {
232
+ const cacheRoot = getUserCacheRoot();
233
+ const projectsRoot = path.join(cacheRoot, CACHE_PROJECTS_DIRECTORY);
234
+ const marker = path.join(cacheRoot, '.last-pruned');
235
+ try {
236
+ if (!force && fs.existsSync(marker) &&
237
+ now - fs.statSync(marker).mtimeMs < CACHE_PRUNE_INTERVAL_MS) {
238
+ return { removed: [], skipped: true };
239
+ }
240
+ } catch (_) { /* run maintenance */ }
241
+ if (!fs.existsSync(projectsRoot)) return { removed: [], skipped: false };
242
+
243
+ const removed = [];
244
+ const entries = [];
245
+ for (const dirent of fs.readdirSync(projectsRoot, { withFileTypes: true })) {
246
+ if (!dirent.isDirectory() || dirent.isSymbolicLink()) continue;
247
+ const dir = path.join(projectsRoot, dirent.name);
248
+ const indexFile = path.join(dir, 'index.json');
249
+ let root = null;
250
+ let touched = 0;
251
+ try {
252
+ const stat = fs.statSync(indexFile);
253
+ touched = stat.mtimeMs;
254
+ const data = JSON.parse(fs.readFileSync(indexFile, 'utf8'));
255
+ root = data.root || null;
256
+ touched = Math.max(touched, Number(data.timestamp) || 0);
257
+ } catch (_) { /* malformed/incomplete cache expires below */ }
258
+ if (!root || !fs.existsSync(root) || now - touched > CACHE_TTL_MS) {
259
+ try {
260
+ fs.rmSync(dir, { recursive: true, force: true });
261
+ removed.push(dir);
262
+ } catch (_) { /* best effort */ }
263
+ continue;
264
+ }
265
+ entries.push({ dir, touched, bytes: null });
266
+ }
267
+
268
+ entries.sort((a, b) => a.touched - b.touched || codeUnitCompare(a.dir, b.dir));
269
+ while (entries.length > CACHE_MAX_PROJECTS) {
270
+ const victim = entries.shift();
271
+ try { fs.rmSync(victim.dir, { recursive: true, force: true }); removed.push(victim.dir); }
272
+ catch (_) { /* best effort */ }
273
+ }
274
+
275
+ let totalBytes = 0;
276
+ for (const entry of entries) {
277
+ entry.bytes = directorySize(entry.dir);
278
+ totalBytes += entry.bytes;
279
+ }
280
+ while (entries.length > 1 && totalBytes > CACHE_MAX_BYTES) {
281
+ const victim = entries.shift();
282
+ try {
283
+ fs.rmSync(victim.dir, { recursive: true, force: true });
284
+ removed.push(victim.dir);
285
+ totalBytes -= victim.bytes;
286
+ } catch (_) { /* best effort */ }
287
+ }
288
+ try {
289
+ fs.mkdirSync(cacheRoot, { recursive: true });
290
+ fs.writeFileSync(marker, String(now));
291
+ } catch (_) { /* maintenance must never block analysis */ }
292
+ return { removed, skipped: false, totalBytes, projects: entries.length };
293
+ }
294
+
295
+ function clearAllCaches() {
296
+ const cacheRoot = getUserCacheRoot();
297
+ try {
298
+ if (!fs.existsSync(cacheRoot)) return [];
299
+ fs.rmSync(cacheRoot, { recursive: true, force: true });
300
+ return [cacheRoot];
301
+ } catch (_) {
302
+ return [];
303
+ }
304
+ }
305
+
16
306
  // Index/calls cache format version — bump when the persisted call-record or
17
307
  // symbol shape changes (saveCache writes it; loadCache rejects anything else).
18
308
  // v14: Go qualified composite literals (pkg.Foo{...}) record the package
@@ -180,11 +470,136 @@ const UCN_VERSION = require('../package.json').version;
180
470
  // v73: JS/TS call records preserve qualified-constructor provenance and bound
181
471
  // local receivers; conditional reassignments no longer persist a definite
182
472
  // inferred constructor type past a branch.
473
+ // v74: JS/TS CommonJS object-spread barrels persist `module.exports = {
474
+ // ...require('./source') }` as source-bearing re-export-all records. Namespace
475
+ // calls can therefore establish exact name ownership through the facade.
183
476
  // Java nested type symbols preserve their enclosing type for exact constructor
184
477
  // ownership across same-named top-level and inner classes, and cast receivers
185
478
  // retain their compiler-declared type. Rust tuple fields are indexed by numeric
186
479
  // position so `self.0.method()` participates in declared-field resolution.
187
- const CACHE_FORMAT_VERSION = 73;
480
+ // v75: v5's shared IR path persists lexical-owner ranges and C/C++/C# symbol
481
+ // metadata; JS/TS call records preserve inline CommonJS module ownership,
482
+ // explicit builtin-member reassignment, and the richer C-family receiver/flow
483
+ // evidence used by caller resolution.
484
+ // v76: Java member symbols retain their nested enclosing type so overload and
485
+ // owner identity cannot conflate same-named inner classes.
486
+ // v77: Java call records retain explicit type-qualified receivers and nested
487
+ // type qualifiers for receiver-owner routing before overload selection.
488
+ // v78: Java argument kinds retain qualified producer ownership, same-class
489
+ // helper calls, and generic collection value types for exact overload routing.
490
+ // v79: Java chained-call records retain their full producer type path for
491
+ // platform collection/receiver ownership.
492
+ // v80: Rust chained-call records retain exact producer byte offsets so
493
+ // same-line nested constructors cannot collide in the fold.
494
+ // v81: Rust producer identities include the complete byte span, separating
495
+ // repeated same-name hops whose nested call expressions share a start offset.
496
+ // v82: Rust macro symbols persist conservative transcriber return contracts;
497
+ // macro invocations and token-tree calls retain complete producer spans and
498
+ // path ownership for exact builder-chain folding.
499
+ // v83: Rust callable symbols persist callback parameter type contracts and
500
+ // calls inside closures retain their enclosing call/argument identity.
501
+ // v84: Rust token-tree call records retain their containing macro identity so
502
+ // code-generation templates (`quote!`) can use their own trust policy.
503
+ // v85: Rust callable symbols persist declared iterator Item types for exact
504
+ // closure and loop receiver inference.
505
+ // v86: JS/TS package self-references can resolve a source entry when exported
506
+ // build artifacts are absent, changing persisted module ownership graphs.
507
+ // v87: JS/TS and Rust call records retain their lexical function-scope chain
508
+ // so return-flow assignments remain visible inside nested closures.
509
+ // v88: JS/TS callable symbols persist a unanimous concrete constructor return
510
+ // for runtime dispatch through interface-typed factory annotations.
511
+ // v89: JS/TS call records retain exact producer byte spans for same-line
512
+ // return-flow assignments and chained-call identity.
513
+ // v90: TypeScript object type aliases persist their declared field/method
514
+ // members so structural field-hop resolution can use their type contracts;
515
+ // explicit Type.prototype method bindings retain their type owner.
516
+ // v91: Python call records retain complete attribute receiver paths and their
517
+ // annotated root type for multi-hop declared-field resolution.
518
+ // v92: Python calls retain isinstance-refined receiver types.
519
+ // v93: Python calls retain receiver types derived from annotated iterable
520
+ // tuple destructuring in loops and comprehensions.
521
+ // v94: Python variadic parameter receivers retain their container type
522
+ // (**kwargs is dict, *args is tuple).
523
+ // v95: Python static generic aliases normalize to their runtime identity;
524
+ // calls retain receiver types from same-file callable iterable returns and
525
+ // collection-protocol conditional normalization.
526
+ // v96: Python explicit instance-field type comments type receivers yielded
527
+ // by loops and comprehensions over those fields.
528
+ // v97: Python receiver records preserve compiler-declared variable types
529
+ // through later assignments and unwrap parenthesized chained receivers.
530
+ // v98: Python calls retain declared attribute paths that supply loop and
531
+ // comprehension receiver values.
532
+ // v99: Python context-manager producer calls retain their `as` target so
533
+ // Iterator/ContextManager return contracts type the bound receiver.
534
+ // v100: builtin open() context bindings retain their IO receiver type.
535
+ // v101: Python generator yield assignments retain the declared send type.
536
+ // v102: Python subscript receivers retain exact value types from local
537
+ // string-keyed dictionary literals.
538
+ // v103: Python pickle round-trip receivers retain the serialized value's
539
+ // type plus the stdlib provenance needed to validate that contract.
540
+ // v104: Python self-referential assignment calls retain the receiver's
541
+ // pre-assignment type.
542
+ // v105: Python calls persist nested lexical ownership, returned-constructor
543
+ // contracts, and runtime instance-field assignments used by exact receiver
544
+ // flow.
545
+ // v106: Go calls persist package-scope typed receivers and exact wrapper-call
546
+ // ownership used by the nominal dispatch path.
547
+ // v107: Rust imports persist fully flattened grouped-use leaves; call records
548
+ // retain iterator/collection contracts, match-payload provenance, multi-hop
549
+ // field paths, tuple/match assignment producers, and primitive slice roots.
550
+ // v108: JavaScript/TypeScript call records retain unresolved deep-member
551
+ // receiver provenance so name bindings cannot impersonate receiver identity.
552
+ // v109: Python call records retain positive receiver-exact hasattr capability
553
+ // guards so uncertainty can name the runtime dispatch boundary.
554
+ // v110: Rust untyped closure parameters block same-named outer receiver
555
+ // annotations instead of persisting a stale, unrelated receiver type.
556
+ // v111: Java call IR preserves lexical nested-type owners and uses declared
557
+ // field types for overload argument shapes.
558
+ // v112: Java argument kinds retain class-qualified field identity so the
559
+ // field declaration's static value type participates in overload selection.
560
+ // v113: persist common unsupported source files so cached repo/health output
561
+ // cannot silently lose the grep handoff discovered during a full scan.
562
+ // v114: Rust impl owners normalize nested generics and reference impls to the
563
+ // same concrete identity used by receiver-flow analysis.
564
+ // v115: C# callable symbols persist extension-method identity and their
565
+ // receiver parameter marker; string/char literal receiver typing changes the
566
+ // call evidence consumed from cached indexes.
567
+ // v116: C# call records persist overload argument kinds and callable-scoped
568
+ // receiver types; enum members are indexed as typed fields for exact overload
569
+ // selection.
570
+ // v117: nullable GetValueOrDefault argument shapes retain their underlying
571
+ // value type for C# inherited-overload selection.
572
+ // v118: C# declarations nested under preprocessor nodes and recovered
573
+ // namespace-level method siblings remain attached to their lexical class.
574
+ // v125: C/C++ multiline typedef/class symbols persist the declaration
575
+ // identifier's nameLine so compiler-selected handles survive caching.
576
+ // v126: C/C++ parser recovery recognizes calling-convention/export macros
577
+ // between a builtin return type and a function name.
578
+ // v127: ambiguous .h files use the repository translation-unit convention
579
+ // when no compilation database or same-directory source sibling is present.
580
+ // v128: C++ using-alias symbols are indexed and template-qualified
581
+ // out-of-line methods close with their in-class declarations.
582
+ // v150: C/C++ call records persist source spans and chained-receiver producer
583
+ // links so the nominal return-type fold can resolve call().member() identity.
584
+ // v151: inheritance graphs normalize compiler-owned qualified nominal bases
585
+ // (for example `detail::buffer<T>`) to their indexed type identity.
586
+ // v152: C/C++ callable signatures persist anonymous C-style variadic tails.
587
+ // v153: C++ callable symbols persist explicit C-language linkage identity.
588
+ // v154: C/C++ calls parsed from preprocessor replacement lists persist their
589
+ // macro-body and macro-parameter provenance.
590
+ // v155: C# method symbols persist explicit-interface ownership so ordinary
591
+ // member overload resolution cannot select an interface-only implementation.
592
+ // v156: C# calls persist cast receiver types and multi-hop/null-forgiving
593
+ // declared-field paths used for compiler-grade member ownership.
594
+ // v157: C# cast receivers distinguish `((IFace)this).M()` from an arbitrary
595
+ // interface-typed variable so explicit implementations are never overclaimed.
596
+ // v159: importBindings persist source lines so rename plans can edit aliased
597
+ // CJS/Python imports without relying on usage-kind heuristics.
598
+ // v160: C# file entries persist project-wide `global using` modules.
599
+ // v166: C++ nested aliases persist their lexical owner ranges, and `auto`
600
+ // return functions persist a unanimously inferred local concrete type. v165
601
+ // was used during prerelease development before both fields were complete.
602
+ const CACHE_FORMAT_VERSION = 166;
188
603
 
189
604
  /**
190
605
  * Save index to cache file
@@ -193,9 +608,12 @@ const CACHE_FORMAT_VERSION = 73;
193
608
  * @returns {string} - Path to cache file
194
609
  */
195
610
  function saveCache(index, cachePath) {
611
+ if (!cachePath) {
612
+ migrateLegacyProjectCache(index.root);
613
+ }
196
614
  const cacheDir = cachePath
197
615
  ? path.dirname(cachePath)
198
- : path.join(index.root, '.ucn-cache');
616
+ : getProjectCacheDir(index.root);
199
617
 
200
618
  if (!fs.existsSync(cacheDir)) {
201
619
  fs.mkdirSync(cacheDir, { recursive: true });
@@ -217,6 +635,7 @@ function saveCache(index, cachePath) {
217
635
  // Hash config to detect when graph rebuild is needed on load
218
636
  const configHash = crypto.createHash('md5')
219
637
  .update(JSON.stringify(index.config || {})).digest('hex');
638
+ const discoveryHash = discoveryRulesHash(index.root);
220
639
 
221
640
  // Strip redundant fields from symbols and file entries to reduce cache size.
222
641
  // v6: All paths stored as relative paths (saves ~60% on large codebases).
@@ -294,6 +713,7 @@ function saveCache(index, cachePath) {
294
713
  version: CACHE_FORMAT_VERSION,
295
714
  ucnVersion: UCN_VERSION, // Invalidate cache when UCN is updated
296
715
  configHash,
716
+ discoveryHash,
297
717
  root,
298
718
  // PERF-2: refresh buildTime on each save so partial rebuilds report
299
719
  // accurate stats. Falls back to original on first save.
@@ -309,10 +729,21 @@ function saveCache(index, cachePath) {
309
729
  failedFiles: index.failedFiles
310
730
  ? Array.from(index.failedFiles).map(f => path.relative(root, f))
311
731
  : [],
732
+ unsupportedFiles: Array.isArray(index.unsupportedFiles)
733
+ ? index.unsupportedFiles
734
+ : [],
735
+ discoveryIssues: Array.isArray(index.discoveryIssues)
736
+ ? index.discoveryIssues
737
+ : [],
738
+ truncated: index.truncated || null,
312
739
  ...(reachableSymbolsRel !== undefined && {
313
740
  reachableSymbols: reachableSymbolsRel,
314
741
  reachableFingerprint,
315
742
  }),
743
+ ...(index._computedDispatchBlindspots instanceof Map && {
744
+ computedDispatchBlindspots: [...index._computedDispatchBlindspots]
745
+ .map(([filePath, sites]) => [path.relative(root, filePath), sites]),
746
+ }),
316
747
  };
317
748
 
318
749
  // PERF-3: atomic write — tmp file + rename so concurrent readers/writers
@@ -327,6 +758,7 @@ function saveCache(index, cachePath) {
327
758
  if (index.reachabilityDirty) {
328
759
  index.reachabilityDirty = false;
329
760
  }
761
+ index.computedDispatchDirty = false;
330
762
 
331
763
  // Save callsCache sharded by directory for lazy loading.
332
764
  // Write to a temp directory first, then atomic swap to avoid data loss on crash.
@@ -374,6 +806,7 @@ function saveCache(index, cachePath) {
374
806
  }
375
807
  }
376
808
 
809
+ if (!cachePath) pruneUserCache();
377
810
  return cacheFile;
378
811
  }
379
812
 
@@ -384,7 +817,10 @@ function saveCache(index, cachePath) {
384
817
  * @returns {boolean} - True if loaded successfully
385
818
  */
386
819
  function loadCache(index, cachePath) {
387
- const cacheFile = cachePath || path.join(index.root, '.ucn-cache', 'index.json');
820
+ if (!cachePath) {
821
+ migrateLegacyProjectCache(index.root);
822
+ }
823
+ const cacheFile = cachePath || getProjectCachePath(index.root);
388
824
 
389
825
  if (!fs.existsSync(cacheFile)) {
390
826
  return false;
@@ -432,6 +868,10 @@ function loadCache(index, cachePath) {
432
868
  ? (relPath) => rootPrefix + relPath
433
869
  : (relPath) => rootPrefix + relPath.replace(/\//g, path.sep);
434
870
 
871
+ // Loading into a previously-used ProjectIndex replaces its indexed
872
+ // contents, so no parsed tree from the old state may survive.
873
+ index._clearParsedTreeCache?.();
874
+
435
875
  // Reconstruct files Map: relative key → absolute key, restore path and relativePath
436
876
  // Initialize symbols/bindings arrays (will be populated from top-level symbols)
437
877
  index.files = new Map();
@@ -514,6 +954,24 @@ function loadCache(index, cachePath) {
514
954
  cacheData.failedFiles.map(f => path.isAbsolute(f) ? f : toAbs(f))
515
955
  );
516
956
  }
957
+ index.unsupportedFiles = Array.isArray(cacheData.unsupportedFiles)
958
+ ? cacheData.unsupportedFiles
959
+ : [];
960
+ index.discoveryIssues = Array.isArray(cacheData.discoveryIssues)
961
+ ? cacheData.discoveryIssues
962
+ : [];
963
+ index.truncated = cacheData.truncated || null;
964
+ index._loadedConfigHash = cacheData.configHash || null;
965
+ index._loadedDiscoveryHash = cacheData.discoveryHash || null;
966
+ if (Array.isArray(cacheData.computedDispatchBlindspots)) {
967
+ index._computedDispatchBlindspots = new Map(
968
+ cacheData.computedDispatchBlindspots.map(([relPath, sites]) => [
969
+ path.isAbsolute(relPath) ? relPath : toAbs(relPath),
970
+ Array.isArray(sites) ? sites : [],
971
+ ]),
972
+ );
973
+ index.computedDispatchDirty = false;
974
+ }
517
975
 
518
976
  // Restore calleeIndex if persisted (v7 caches only; v8+ rebuilds lazily)
519
977
  if (Array.isArray(cacheData.calleeIndex)) {
@@ -569,6 +1027,15 @@ function loadCache(index, cachePath) {
569
1027
  * @returns {boolean} - True if cache needs rebuilding
570
1028
  */
571
1029
  function isCacheStale(index) {
1030
+ const currentConfigHash = crypto.createHash('md5')
1031
+ .update(JSON.stringify(index.config || {})).digest('hex');
1032
+ if (index._loadedConfigHash && currentConfigHash !== index._loadedConfigHash) {
1033
+ return true;
1034
+ }
1035
+ if (index._loadedDiscoveryHash &&
1036
+ discoveryRulesHash(index.root) !== index._loadedDiscoveryHash) {
1037
+ return true;
1038
+ }
572
1039
  // Modified/deleted detection (stat sweep) runs UNCONDITIONALLY — agents
573
1040
  // edit a file and re-query through MCP within seconds, and a stale answer
574
1041
  // presented as fresh is the worst trust failure the tool can produce.
@@ -614,20 +1081,52 @@ function isCacheStale(index) {
614
1081
  // Slow path: glob the project to detect new files added since last build.
615
1082
  // Only reached when all cached files are unchanged.
616
1083
  const pattern = detectProjectPattern(index.root);
617
- const globOpts = { root: index.root };
1084
+ const currentUnsupported = [];
1085
+ const globOpts = {
1086
+ root: index.root,
1087
+ onSkippedFile: (filePath) => {
1088
+ const kind = classifyUnsupportedSourceFile(filePath);
1089
+ if (!kind) return;
1090
+ currentUnsupported.push({
1091
+ relativePath: path.relative(index.root, filePath),
1092
+ ...kind,
1093
+ });
1094
+ },
1095
+ };
618
1096
  const gitignorePatterns = parseGitignore(index.root);
1097
+ globOpts.gitignorePatterns = gitignorePatterns;
1098
+ globOpts.trackedPaths = gitTrackedPaths(index.root);
619
1099
  const configExclude = index.config.exclude || [];
620
- if (gitignorePatterns.length > 0 || configExclude.length > 0) {
621
- globOpts.ignores = [...DEFAULT_IGNORES, ...gitignorePatterns, ...configExclude];
1100
+ if (configExclude.length > 0) {
1101
+ globOpts.ignores = [...DEFAULT_IGNORES, ...configExclude];
622
1102
  }
623
1103
  const currentFiles = expandGlob(pattern, globOpts);
624
1104
  const cachedPaths = new Set(index.files.keys());
1105
+ const currentPaths = new Set(currentFiles);
1106
+
1107
+ // A cached file can still exist on disk while becoming excluded by a new
1108
+ // .gitignore/.ucn.json rule. Treat that set contraction as stale too;
1109
+ // otherwise ignored code remains queryable until an unrelated edit forces
1110
+ // a rebuild.
1111
+ for (const file of cachedPaths) {
1112
+ if (!currentPaths.has(file)) return true;
1113
+ }
625
1114
 
626
1115
  for (const file of currentFiles) {
627
1116
  if (!cachedPaths.has(file) && !(index.failedFiles && index.failedFiles.has(file))) {
628
1117
  return true; // New file found
629
1118
  }
630
1119
  }
1120
+ const cachedUnsupported = (index.unsupportedFiles || [])
1121
+ .map(f => `${f.relativePath}\0${f.language}`)
1122
+ .sort();
1123
+ const discoveredUnsupported = currentUnsupported
1124
+ .map(f => `${f.relativePath}\0${f.language}`)
1125
+ .sort();
1126
+ if (cachedUnsupported.length !== discoveredUnsupported.length ||
1127
+ cachedUnsupported.some((value, i) => value !== discoveredUnsupported[i])) {
1128
+ return true;
1129
+ }
631
1130
 
632
1131
  // Record when we last confirmed the cache is fresh (enables 2s skip on burst calls)
633
1132
  index._lastFreshAt = Date.now();
@@ -644,11 +1143,11 @@ function _prepareCallsCache(index, cacheFile) {
644
1143
  if (index._callsCacheLoaded) return;
645
1144
  // Shards live beside the selected index.json. A custom cachePath must be
646
1145
  // a complete portable cache, not an index file that silently looks for
647
- // call shards under <project>/.ucn-cache. The latter caused cache-loaded
1146
+ // call shards under the default project cache. The latter caused cache-loaded
648
1147
  // semantic queries to reparse source (or consume unrelated stale shards).
649
1148
  const cacheDir = cacheFile
650
1149
  ? path.dirname(cacheFile)
651
- : path.join(index.root, '.ucn-cache');
1150
+ : getProjectCacheDir(index.root);
652
1151
  index._callsCacheDir = cacheDir;
653
1152
  const manifestFile = path.join(cacheDir, 'calls', 'manifest.json');
654
1153
  if (fs.existsSync(manifestFile)) {
@@ -694,7 +1193,7 @@ function loadCallsCache(index) {
694
1193
 
695
1194
  // Legacy format: single calls-cache.json
696
1195
  const callsCacheFile = index._callsCacheLegacyFile ||
697
- path.join(index._callsCacheDir || path.join(index.root, '.ucn-cache'), 'calls-cache.json');
1196
+ path.join(index._callsCacheDir || getProjectCacheDir(index.root), 'calls-cache.json');
698
1197
  if (!fs.existsSync(callsCacheFile)) return index.callsCache.size > 0;
699
1198
 
700
1199
  try {
@@ -733,7 +1232,7 @@ function ensureCallsCacheLoaded(index) {
733
1232
  */
734
1233
  function _loadCallsShard(index, hash) {
735
1234
  const shardFile = path.join(
736
- index._callsCacheDir || path.join(index.root, '.ucn-cache'),
1235
+ index._callsCacheDir || getProjectCacheDir(index.root),
737
1236
  'calls', `${hash}.json`);
738
1237
  try {
739
1238
  const data = JSON.parse(fs.readFileSync(shardFile, 'utf-8'));
@@ -789,5 +1288,8 @@ function _computeReachabilityFingerprint(index) {
789
1288
 
790
1289
  module.exports = {
791
1290
  saveCache, loadCache, loadCallsCache, isCacheStale, ensureCallsCacheLoaded,
1291
+ getUserCacheRoot, getProjectCacheDir, getProjectCachePath,
1292
+ getLegacyProjectCacheDir, migrateLegacyProjectCache, clearProjectCache,
1293
+ clearAllCaches, pruneUserCache,
792
1294
  _computeReachabilityFingerprint, CACHE_FORMAT_VERSION,
793
1295
  };