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/project.js CHANGED
@@ -8,10 +8,14 @@
8
8
  const fs = require('fs');
9
9
  const path = require('path');
10
10
  const crypto = require('crypto');
11
- const { expandGlob, findProjectRoot, detectProjectPattern, isTestFile, parseGitignore, DEFAULT_IGNORES, compareNames } = require('./discovery');
12
- const { extractImports, extractExports } = require('./imports');
13
- const { parse, cleanHtmlScriptTags } = require('./parser');
14
- const { detectLanguage, getParser, getLanguageModule, safeParse, langTraits, PARSE_OPTIONS } = require('../languages');
11
+ const {
12
+ expandGlob, findProjectRoot, detectProjectPattern, isTestFile,
13
+ parseGitignore, gitTrackedPaths, DEFAULT_IGNORES, compareNames, classifyUnsupportedSourceFile,
14
+ } = require('./discovery');
15
+ const { cleanHtmlScriptTags } = require('./parser');
16
+ const { detectLanguage, getParser, getLanguageAdapter, safeParse, langTraits, PARSE_OPTIONS } = require('../languages');
17
+ const { validateFileIR } = require('./ir');
18
+ const { createFileEntryFromIR, populateFileEntryFromIR } = require('./index-ir');
15
19
  const { getTokenTypeAtPosition } = require('../languages/utils');
16
20
  const { escapeRegExp, NON_CALLABLE_TYPES, codeUnitCompare } = require('./shared');
17
21
  const stacktrace = require('./stacktrace');
@@ -29,6 +33,15 @@ const reportingModule = require('./reporting');
29
33
  // Lazy-initialized per-language keyword sets (populated on first isKeyword call)
30
34
  let LANGUAGE_KEYWORDS = null;
31
35
 
36
+ // Query-time ASTs are immutable for the lifetime of an indexed file. Keep a
37
+ // small cross-operation LRU so a sequence of agent queries does not reparse the
38
+ // same large headers/modules for every conservation-account classification.
39
+ // The source-byte budget is deliberately conservative because tree-sitter's
40
+ // native tree is larger than its source. Evictions explicitly release native
41
+ // memory instead of waiting for N-API finalizers.
42
+ const PARSED_TREE_CACHE_MAX_ENTRIES = 128;
43
+ const PARSED_TREE_CACHE_MAX_SOURCE_BYTES = 32 * 1024 * 1024;
44
+
32
45
  /**
33
46
  * ProjectIndex - Manages symbol table for a project
34
47
  */
@@ -47,15 +60,27 @@ class ProjectIndex {
47
60
  this.extendedByGraph = new Map(); // parentName -> [childInfo]
48
61
  this.config = this.loadConfig();
49
62
  this.buildTime = null;
63
+ // Build telemetry is intentionally transient (never cached). Release
64
+ // performance gates use it to prove the requested worker shape was
65
+ // actually exercised instead of inferring parallelism from host CPUs.
66
+ this.lastBuildWorkerCount = 1;
67
+ this.lastBuildParallelEligible = false;
68
+ this.lastBuildRequestedWorkers = null;
50
69
  this.callsCache = new Map(); // filePath -> { mtime, hash, calls, content }
51
70
  this.callsCacheDirty = false; // set by getCachedCalls when entries are added or mutated
71
+ this.computedDispatchDirty = false; // persisted project-wide AST blind-spot inventory
52
72
  this.failedFiles = new Set(); // files that failed to index (e.g. large minified bundles)
73
+ this.unsupportedFiles = []; // common source files skipped by the parser registry
74
+ this.discoveryIssues = []; // explicit reasons the index may be partial
53
75
  this._opContentCache = null; // per-operation file content cache (Map<filePath, string>)
54
76
  this._opUsagesCache = null; // per-operation findUsagesInCode cache (Map<"file:name", usages[]>)
55
77
  this._opTreeCache = null; // per-operation parsed-tree cache (Map<filePath, tree|null>, bounded FIFO)
78
+ this._opTransientTrees = null; // evicted/errored trees kept alive until the active operation ends
56
79
  this._opLinesCache = null; // per-operation split-lines cache (Map<filePath, string[]>, bounded FIFO)
57
80
  this._opInnerSymbolRangesCache = null; // per-operation sorted class-method ranges by file
58
81
  this._opFlowTypeOriginCache = null; // per-operation annotation type identity results
82
+ this._parsedTreeCache = new Map(); // cross-operation LRU: filePath -> immutable tree entry
83
+ this._parsedTreeCacheSourceBytes = 0;
59
84
  this.calleeIndex = null; // name -> Set<filePath> — inverted call index (built lazily)
60
85
  }
61
86
 
@@ -84,6 +109,7 @@ class ProjectIndex {
84
109
  this._opUsageTotalsCache = new Map();
85
110
  this._opEnclosingFnCache = new Map();
86
111
  this._opTreeCache = new Map();
112
+ this._opTransientTrees = new Set();
87
113
  this._opLinesCache = new Map();
88
114
  // Query-time semantic derivations that depend only on one file's
89
115
  // immutable call records. Reachability may ask findCallees() for
@@ -109,6 +135,10 @@ class ProjectIndex {
109
135
  this._opUsageTotalsCache = null;
110
136
  this._opEnclosingFnCache = null;
111
137
  this._opTreeCache = null;
138
+ if (this._opTransientTrees) {
139
+ for (const tree of this._opTransientTrees) tree?.delete?.();
140
+ }
141
+ this._opTransientTrees = null;
112
142
  this._opLinesCache = null;
113
143
  this._opReturnTypeFlowCache = null;
114
144
  this._opCallsByLineCache = null;
@@ -124,11 +154,46 @@ class ProjectIndex {
124
154
  }
125
155
  }
126
156
 
157
+ /** Remove one persistent parsed tree, deferring disposal if an operation still uses it. */
158
+ _evictParsedTree(filePath) {
159
+ const entry = this._parsedTreeCache.get(filePath);
160
+ if (!entry) return;
161
+ this._parsedTreeCache.delete(filePath);
162
+ this._parsedTreeCacheSourceBytes -= entry.sourceBytes;
163
+ if (this._opTreeCache?.get(filePath) === entry.tree) {
164
+ this._opTransientTrees?.add(entry.tree);
165
+ } else {
166
+ entry.tree?.delete?.();
167
+ }
168
+ }
169
+
170
+ /** Dispose every cross-operation tree. Used when an index is replaced from cache. */
171
+ _clearParsedTreeCache() {
172
+ for (const filePath of [...this._parsedTreeCache.keys()]) {
173
+ this._evictParsedTree(filePath);
174
+ }
175
+ this._parsedTreeCacheSourceBytes = 0;
176
+ }
177
+
178
+ _cacheParsedTree(filePath, language, tree, sourceBytes) {
179
+ const fileHash = this.files.get(filePath)?.hash || null;
180
+ this._evictParsedTree(filePath);
181
+ this._parsedTreeCache.set(filePath, {
182
+ tree, language, fileHash, sourceBytes,
183
+ });
184
+ this._parsedTreeCacheSourceBytes += sourceBytes;
185
+ while (this._parsedTreeCache.size > PARSED_TREE_CACHE_MAX_ENTRIES ||
186
+ this._parsedTreeCacheSourceBytes > PARSED_TREE_CACHE_MAX_SOURCE_BYTES) {
187
+ this._evictParsedTree(this._parsedTreeCache.keys().next().value);
188
+ }
189
+ }
190
+
127
191
  /**
128
- * Parse a file once per operation and reuse the tree across symbol names.
192
+ * Parse a file once and reuse the immutable tree across operations.
129
193
  * Multi-symbol commands (diff-impact, check) classify ground lines for MANY
130
194
  * names against the SAME files; without this, each (file, name) pair costs a
131
- * full tree-sitter parse. Bounded FIFO (trees hold native memory).
195
+ * full tree-sitter parse. Both operation and persistent caches are bounded
196
+ * because trees hold native memory.
132
197
  * Returns null when parsing fails or no operation cache is active — callers
133
198
  * fall back to parsing themselves.
134
199
  */
@@ -136,6 +201,19 @@ class ProjectIndex {
136
201
  if (!this._opTreeCache) return null;
137
202
  const cached = this._opTreeCache.get(filePath);
138
203
  if (cached !== undefined) return cached;
204
+
205
+ const persistent = this._parsedTreeCache.get(filePath);
206
+ const currentHash = this.files.get(filePath)?.hash || null;
207
+ if (persistent && persistent.language === language &&
208
+ persistent.fileHash === currentHash) {
209
+ // LRU touch.
210
+ this._parsedTreeCache.delete(filePath);
211
+ this._parsedTreeCache.set(filePath, persistent);
212
+ this._opTreeCache.set(filePath, persistent.tree);
213
+ return persistent.tree;
214
+ }
215
+ if (persistent) this._evictParsedTree(filePath);
216
+
139
217
  let tree;
140
218
  try {
141
219
  const parser = getParser(language);
@@ -147,6 +225,9 @@ class ProjectIndex {
147
225
  this._opTreeCache.delete(this._opTreeCache.keys().next().value);
148
226
  }
149
227
  this._opTreeCache.set(filePath, tree);
228
+ if (tree) {
229
+ this._cacheParsedTree(filePath, language, tree, Buffer.byteLength(content));
230
+ }
150
231
  return tree;
151
232
  }
152
233
 
@@ -183,8 +264,13 @@ class ProjectIndex {
183
264
  if (cached !== undefined) return cached;
184
265
  }
185
266
 
186
- const lang = detectLanguage(filePath);
187
- const langModule = getLanguageModule(lang);
267
+ // Header language is resolved during indexing from compilation
268
+ // databases/include context. Re-detecting `.h` here can choose C for
269
+ // a C++ header and silently drop member/template usages from the raw
270
+ // inventory. The indexed language is the authoritative parse mode.
271
+ const lang = this.files.get(filePath)?.language ||
272
+ detectLanguage(filePath, this.root);
273
+ const langModule = getLanguageAdapter(lang);
188
274
  if (!langModule || typeof langModule.findUsagesInCode !== 'function') return null;
189
275
 
190
276
  try {
@@ -202,7 +288,8 @@ class ProjectIndex {
202
288
  if (!parser) return null;
203
289
  // Language modules that accept a pre-parsed tree (4th param) reuse
204
290
  // the per-operation tree cache; others (html) parse internally.
205
- const tree = langModule.findUsagesInCode.length >= 4
291
+ const tree = langModule.findUsagesInCode.length >= 4 &&
292
+ !langModule.managesOwnParseTree
206
293
  ? this._getParsedTree(filePath, content, lang)
207
294
  : null;
208
295
  const usages = langModule.findUsagesInCode(content, name, parser, tree);
@@ -224,7 +311,7 @@ class ProjectIndex {
224
311
  try {
225
312
  return JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
226
313
  } catch (e) {
227
- // Config load failed, use defaults
314
+ this.configError = `.ucn.json could not be parsed: ${e.message}`;
228
315
  }
229
316
  }
230
317
  return {};
@@ -233,10 +320,10 @@ class ProjectIndex {
233
320
  /**
234
321
  * Build index for files matching pattern
235
322
  *
236
- * @param {string} pattern - Glob pattern (e.g., "**\/*.js")
323
+ * @param {string|null} [pattern=null] - Glob pattern (e.g., "**\/*.js")
237
324
  * @param {object} options - { forceRebuild, maxFiles, quiet }
238
325
  */
239
- build(pattern, options = {}) {
326
+ build(pattern = null, options = {}) {
240
327
  const startTime = Date.now();
241
328
  const quiet = options.quiet !== false;
242
329
 
@@ -252,6 +339,23 @@ class ProjectIndex {
252
339
 
253
340
  // Accept pre-expanded file array (glob mode) or a pattern string
254
341
  let files;
342
+ const implicitProjectDiscovery = !Array.isArray(pattern) && !pattern;
343
+ this.unsupportedFiles = [];
344
+ this.discoveryIssues = [];
345
+ const discoveryIssueKeys = new Set();
346
+ const recordDiscoveryIssue = (issue) => {
347
+ const rel = path.relative(this.root, issue.path || this.root) || '.';
348
+ const key = `${issue.reason}\0${rel}`;
349
+ if (discoveryIssueKeys.has(key)) return;
350
+ discoveryIssueKeys.add(key);
351
+ this.discoveryIssues.push({ ...issue, relativePath: rel, path: undefined });
352
+ };
353
+ if (this.configError) {
354
+ recordDiscoveryIssue({
355
+ path: path.join(this.root, '.ucn.json'),
356
+ kind: 'file', reason: 'invalid-config', detail: this.configError,
357
+ });
358
+ }
255
359
  if (Array.isArray(pattern)) {
256
360
  files = pattern;
257
361
  } else {
@@ -262,23 +366,44 @@ class ProjectIndex {
262
366
  const globOpts = {
263
367
  root: this.root,
264
368
  maxFiles: options.maxFiles || this.config.maxFiles || 50000,
369
+ maxDepth: options.maxDepth ?? this.config.maxDepth,
370
+ maxFileSize: options.maxFileSize ?? this.config.maxFileSize,
265
371
  followSymlinks: options.followSymlinks
266
372
  };
267
373
 
268
374
  // Merge .gitignore and .ucn.json exclude into file discovery
269
375
  const gitignorePatterns = parseGitignore(this.root);
270
- const configExclude = this.config.exclude || [];
271
- if (gitignorePatterns.length > 0 || configExclude.length > 0) {
272
- globOpts.ignores = [...DEFAULT_IGNORES, ...gitignorePatterns, ...configExclude];
376
+ globOpts.gitignorePatterns = gitignorePatterns;
377
+ globOpts.trackedPaths = gitTrackedPaths(this.root);
378
+ const configExclude = Array.isArray(this.config.exclude)
379
+ ? this.config.exclude
380
+ : (this.config.exclude ? [String(this.config.exclude)] : []);
381
+ if (configExclude.length > 0) {
382
+ globOpts.ignores = [...DEFAULT_IGNORES, ...configExclude];
383
+ }
384
+ globOpts.disclosureIgnores = configExclude;
385
+ globOpts.onDiscoveryIssue = recordDiscoveryIssue;
386
+
387
+ if (implicitProjectDiscovery) {
388
+ globOpts.onSkippedFile = (filePath) => {
389
+ const kind = classifyUnsupportedSourceFile(filePath);
390
+ if (!kind) return;
391
+ this.unsupportedFiles.push({
392
+ relativePath: path.relative(this.root, filePath),
393
+ ...kind,
394
+ });
395
+ };
273
396
  }
274
-
275
397
  files = expandGlob(pattern, globOpts);
398
+ this.unsupportedFiles.sort((a, b) => compareNames(a.relativePath, b.relativePath));
399
+ this.discoveryIssues.sort((a, b) => compareNames(a.relativePath, b.relativePath));
276
400
  }
277
401
 
278
402
  // Track if files were truncated by maxFiles limit
279
403
  const maxFiles = options.maxFiles || this.config.maxFiles || 50000;
280
- if (!Array.isArray(pattern) && files.length >= maxFiles) {
281
- this.truncated = { indexed: files.length, maxFiles };
404
+ const maxFileIssues = this.discoveryIssues.filter(issue => issue.reason === 'max-files');
405
+ if (!Array.isArray(pattern) && maxFileIssues.length > 0) {
406
+ this.truncated = { indexed: files.length, maxFiles, skipped: maxFileIssues.length };
282
407
  } else {
283
408
  this.truncated = null;
284
409
  }
@@ -318,6 +443,7 @@ class ProjectIndex {
318
443
  // Always invalidate caches on rebuild
319
444
  this._completenessCache = null;
320
445
  this._attrTypeCache = null;
446
+ this._computedDispatchBlindspots = null;
321
447
  // Endpoints cache (server routes / client requests / bridges) becomes
322
448
  // stale when files change; clear on every rebuild.
323
449
  this._endpointsCache = null;
@@ -330,13 +456,43 @@ class ProjectIndex {
330
456
  const workersSetting = options.workers;
331
457
  const envWorkers = parseInt(process.env.UCN_WORKERS, 10);
332
458
  const disableParallel = workersSetting === 0 || envWorkers === 0;
459
+ const explicitWorkerCount = workersSetting > 0 || envWorkers > 0;
460
+ const requestedWorkerCount = workersSetting > 0
461
+ ? workersSetting : (envWorkers > 0 ? envWorkers : null);
462
+ this.lastBuildWorkerCount = 1;
463
+ this.lastBuildRequestedWorkers = requestedWorkerCount;
333
464
  let usedParallel = false;
334
465
 
335
- if (!disableParallel && files.length > 150) {
466
+ // C/C++ recovery performs materially heavier parser work per file
467
+ // (attribute normalization plus bounded preprocessor views). Medium
468
+ // native projects therefore benefit from workers well before the
469
+ // generic 150-file crossover; measured release repos cross over at
470
+ // roughly 25 C-family files.
471
+ const cFamilyFileCount = files.reduce((count, filePath) => {
472
+ const language = detectLanguage(filePath, this.root);
473
+ return count + (language === 'c' || language === 'cpp' ? 1 : 0);
474
+ }, 0);
475
+ // An explicit worker count is a request for a reproducible execution
476
+ // shape (not merely an upper bound). This is useful to users tuning a
477
+ // constrained host and lets the release gate compare like with like.
478
+ const parallelWorthwhile = explicitWorkerCount
479
+ ? files.length >= 2
480
+ : files.length > 150 || cFamilyFileCount >= 25;
481
+ this.lastBuildParallelEligible = !disableParallel && parallelWorthwhile;
482
+ if (!disableParallel && parallelWorthwhile) {
336
483
  try {
337
484
  const { parallelBuild } = require('./parallel-build');
338
485
  const result = parallelBuild(this, files, {
339
486
  workerCount: workersSetting > 0 ? workersSetting : (envWorkers > 0 ? envWorkers : undefined),
487
+ // After the recovery scorer stopped repeatedly walking
488
+ // every candidate tree, real-repo sweeps put the native
489
+ // throughput knee at 5-6 workers. Default to the lower
490
+ // knee (five) for RSS headroom; explicit user settings
491
+ // retain the ordinary eight-worker safety cap.
492
+ maxWorkers: cFamilyFileCount >= 25 && !explicitWorkerCount
493
+ ? 5 : 8,
494
+ minFilesPerWorker: explicitWorkerCount
495
+ ? 1 : (cFamilyFileCount >= 25 ? 10 : 100),
340
496
  quiet,
341
497
  });
342
498
  if (result !== false) {
@@ -345,6 +501,7 @@ class ProjectIndex {
345
501
  usedParallel = true;
346
502
  }
347
503
  } catch (e) {
504
+ this.lastBuildWorkerCount = 1;
348
505
  if (!quiet) {
349
506
  console.error(`Parallel build failed, falling back to sequential: ${e.message}`);
350
507
  }
@@ -384,9 +541,10 @@ class ProjectIndex {
384
541
  // avoiding the 2+ minute deferred cost when the first analysis command runs later.
385
542
  this.buildCalleeIndex();
386
543
 
387
- // buildCalleeIndex re-parses changed files via getCachedCalls, which
388
- // appends their entries at the callsCache TAIL — restore canonical
389
- // key order so iteration-order consumers match a fresh build.
544
+ // Keep persisted iteration deterministic. Fresh and changed files
545
+ // already populated callsCache through the shared IR ingestion path;
546
+ // unchanged entries may have arrived from a sharded cache in a
547
+ // different order.
390
548
  this.callsCache = new Map([...this.callsCache.entries()].sort((a, b) => compareNames(a[0], b[0])));
391
549
 
392
550
  this.buildTime = Date.now() - startTime;
@@ -435,16 +593,16 @@ class ProjectIndex {
435
593
  this.removeFileSymbols(filePath);
436
594
  }
437
595
 
438
- const language = detectLanguage(filePath);
596
+ const language = detectLanguage(filePath, this.root);
439
597
  if (!language) return;
440
598
 
441
- // Parse content once — the tree-sitter cache in safeParse ensures the tree
442
- // is shared across parse()/extractImports()/extractExports() (5→1 parse per file)
443
- const parsed = parse(content, language);
444
- parsed.filePath = filePath;
445
- parsed.relativePath = filePath;
446
- const { imports, dynamicCount, importAliases } = extractImports(content, language);
447
- const { exports } = extractExports(content, language);
599
+ const adapter = getLanguageAdapter(language);
600
+ const parser = getParser(language);
601
+ const ir = adapter.analyze(content, parser, filePath);
602
+ const irFailures = validateFileIR(ir);
603
+ if (irFailures.length > 0) {
604
+ throw new Error(`Invalid ${language} IR: ${irFailures.join('; ')}`);
605
+ }
448
606
 
449
607
  // Detect bundled/minified files (webpack bundles, minified code)
450
608
  // These are build artifacts, not user-written source code
@@ -470,8 +628,12 @@ class ProjectIndex {
470
628
  const isBundled = (() => {
471
629
  // Webpack bundles contain __webpack_require__ or __webpack_modules__
472
630
  if (content.includes('__webpack_require__') || content.includes('__webpack_modules__')) return true;
473
- // Minified files: very few lines but large content (avg > 500 chars/line)
474
- if (lineCount > 0 && lineCount < 50 && content.length / lineCount > 500) return true;
631
+ // Minified files: large content with extreme bytes/line. Bundlers
632
+ // often insert 50-200 licence/chunk-separator lines, so an
633
+ // arbitrary <50 cap lets the same bytes flip classification when
634
+ // a banner is added. The size floor protects authored small files.
635
+ if (lineCount > 0 && content.length >= 100 * 1024 &&
636
+ content.length / lineCount > 500) return true;
475
637
  // Very long single lines (> 1000 chars) in most of the file suggest minification
476
638
  if (lineCount > 0 && longLineCount > 0 && longLineCount / lineCount > 0.3) return true;
477
639
  return false;
@@ -484,150 +646,26 @@ class ProjectIndex {
484
646
  content.slice(0, 500)
485
647
  );
486
648
 
487
- const fileEntry = {
488
- path: filePath,
649
+ const fileEntry = createFileEntryFromIR({
650
+ ir,
651
+ filePath,
489
652
  relativePath: path.relative(this.root, filePath),
490
- language,
491
- lines: lineCount,
492
653
  hash,
493
654
  mtime: stat.mtimeMs,
494
655
  size: stat.size,
495
- imports: imports.map(i => i.module),
496
- importNames: imports.flatMap(i => i.names || []),
497
- // Paired name↔module bindings (fix #209): importNames flattens the
498
- // pairing away, but name-level shadow detection needs to know WHICH
499
- // module bound a name (`from urllib.parse import unquote` rebinds
500
- // the bare name for the whole file).
501
- importBindings: imports.flatMap(i => (i.names || [])
502
- .filter(n => n && n !== '*' && n !== '_' && n !== '.')
503
- .map(n => {
504
- // Rename pairing (fix #269): `{ validate: validateSchema }
505
- // = require('./validation')` — the record's local alias
506
- // pins to ITS module, not any module exporting the name.
507
- const rn = (i.renames || []).find(r => r.original === n);
508
- return {
509
- name: n,
510
- module: i.module,
511
- ...(rn && { alias: rn.local }),
512
- ...(i.defaultLike && { defaultLike: true }),
513
- };
514
- })),
515
- exports: exports.map(e => e.name),
516
- exportDetails: exports,
517
- symbols: [],
518
- bindings: [],
519
- ...(parsed.parseRecovery && { parseRecovery: true }),
520
- ...(importAliases && { importAliases }),
521
- // Module-scope assignment targets (fix #217): names a module can
522
- // expose WITHOUT a def/class/import binding (`render = impl`,
523
- // `global name`). The import-binding name-chase treats these as
524
- // undetermined — a dead-end verdict would be unsound.
525
- ...(parsed.moduleAssignedNames && { moduleAssignedNames: parsed.moduleAssignedNames }),
526
- ...(isBundled && { isBundled: true }),
527
- ...(isGenerated && { isGenerated: true })
528
- };
529
- fileEntry.dynamicImports = dynamicCount || 0;
530
-
531
- // Add symbols
532
- const addSymbol = (item, type) => {
533
- const symbol = {
534
- name: item.name,
535
- type,
536
- file: filePath,
537
- relativePath: fileEntry.relativePath,
538
- startLine: item.startLine,
539
- endLine: item.endLine,
540
- params: item.params,
541
- paramsStructured: item.paramsStructured,
542
- returnType: item.returnType,
543
- ...(item.returnedFunctionResult && { returnedFunctionResult: item.returnedFunctionResult }),
544
- ...(item.isFunctionVariable && { isFunctionVariable: true }),
545
- ...(item.paramTypes && { paramTypes: item.paramTypes }),
546
- ...(item.isAsync && { isAsync: true }),
547
- ...(item.isGenerator && { isGenerator: true }),
548
- modifiers: item.modifiers,
549
- docstring: item.docstring,
550
- bindingId: `${fileEntry.relativePath}:${type}:${item.startLine}`,
551
- ...(item.generics && { generics: item.generics }),
552
- ...(item.extends && { extends: item.extends }),
553
- ...(item.implements && { implements: item.implements }),
554
- ...(item.indent !== undefined && { indent: item.indent }),
555
- ...(item.isNested && { isNested: item.isNested }),
556
- ...(item.enclosingType && { enclosingType: item.enclosingType }),
557
- ...(item.isMethod && { isMethod: item.isMethod }),
558
- ...(item.receiver && { receiver: item.receiver }),
559
- ...(item.className && { className: item.className }),
560
- ...(item.memberType && { memberType: item.memberType }),
561
- ...(item.fieldType && { fieldType: item.fieldType }),
562
- ...(item.aliasOf && { aliasOf: item.aliasOf }),
563
- ...(item.derefTarget && { derefTarget: item.derefTarget }),
564
- ...(item.decorators && item.decorators.length > 0 && { decorators: item.decorators }),
565
- // Decorator/annotation/attribute argument capture for endpoints command:
566
- // these fields hold the parsed first-string-arg of each route-style annotation.
567
- // Only populated when at least one entry has a string-literal arg, keeping memory
568
- // overhead minimal for non-route code.
569
- ...(item.decoratorsWithArgs && item.decoratorsWithArgs.length > 0 && { decoratorsWithArgs: item.decoratorsWithArgs }),
570
- ...(item.annotationsWithArgs && item.annotationsWithArgs.length > 0 && { annotationsWithArgs: item.annotationsWithArgs }),
571
- ...(item.attributesWithArgs && item.attributesWithArgs.length > 0 && { attributesWithArgs: item.attributesWithArgs }),
572
- ...(item.nameLine && { nameLine: item.nameLine }),
573
- ...(item.traitImpl && { traitImpl: true }),
574
- // Trait the impl block implements (rust `impl Trait for X`
575
- // members) — external-contract detection needs the NAME, not
576
- // just the traitImpl flag (fix #210).
577
- ...(item.traitName && { traitName: item.traitName }),
578
- ...(item.isSignature && { isSignature: true }),
579
- ...(item.memberAssigned && { memberAssigned: true }),
580
- ...(item.bodyScopedName && { bodyScopedName: true }),
581
- ...(item.registryMember && { registryMember: true }),
582
- ...(item.registryContainer && { registryContainer: item.registryContainer })
583
- };
584
- fileEntry.symbols.push(symbol);
585
- // Property-assignment defs (fix #269: Reply.prototype.serialize
586
- // = function) declare no lexical name — a bare reference in the
587
- // file can never bind them, so they never enter the bindings
588
- // table (the symbol stays indexed and method-reachable). Named
589
- // function expressions (bodyScopedName) bind only inside their
590
- // own body (ECMA-262) — same consequence.
591
- if (!item.memberAssigned && !item.bodyScopedName) {
592
- fileEntry.bindings.push({
593
- id: symbol.bindingId,
594
- name: symbol.name,
595
- type: symbol.type,
596
- startLine: symbol.startLine
597
- });
598
- }
599
-
600
- if (!this.symbols.has(item.name)) {
601
- this.symbols.set(item.name, []);
602
- }
603
- this.symbols.get(item.name).push(symbol);
604
- };
605
-
606
- for (const fn of parsed.functions) {
607
- // Go/Rust methods: set className from receiver for consistent method resolution.
608
- // Go/Rust methods are standalone functions with receiver, not class members,
609
- // so className is never set by the class member loop below.
610
- if (fn.receiver && !fn.className) {
611
- fn.className = fn.receiver.replace(/^\*/, '');
612
- }
613
- addSymbol(fn, fn.isConstructor ? 'constructor' : 'function');
614
- }
615
-
616
- for (const cls of parsed.classes) {
617
- addSymbol(cls, cls.type || 'class');
618
- if (cls.members) {
619
- for (const m of cls.members) {
620
- const memberType = m.memberType || 'method';
621
- addSymbol({ ...m, className: cls.name, ...(cls.traitName && { traitImpl: true, traitName: cls.traitName }) }, memberType);
622
- }
623
- }
624
- }
625
-
626
- for (const state of parsed.stateObjects) {
627
- addSymbol(state, 'state');
628
- }
656
+ lineCount,
657
+ isBundled,
658
+ isGenerated,
659
+ });
660
+ populateFileEntryFromIR(fileEntry, ir, this.symbols);
629
661
 
630
662
  this.files.set(filePath, fileEntry);
663
+ this.callsCache.set(filePath, {
664
+ mtime: stat.mtimeMs,
665
+ hash,
666
+ calls: ir.calls,
667
+ });
668
+ this.callsCacheDirty = true;
631
669
  return true;
632
670
  }
633
671
 
@@ -638,6 +676,8 @@ class ProjectIndex {
638
676
  const existing = this.files.get(filePath);
639
677
  if (!existing) return;
640
678
 
679
+ this._evictParsedTree(filePath);
680
+
641
681
  for (const symbol of existing.symbols) {
642
682
  const entries = this.symbols.get(symbol.name);
643
683
  if (entries) {
@@ -662,6 +702,8 @@ class ProjectIndex {
662
702
 
663
703
  // Invalidate lazy Java file index (will be rebuilt on next use)
664
704
  this._javaFileIndex = null;
705
+ // Computed-dispatch diagnostics are project-wide and read source text.
706
+ this._computedDispatchBlindspots = null;
665
707
  // Endpoints cache is project-wide; safest to clear on any file removal.
666
708
  this._endpointsCache = null;
667
709
  }
@@ -758,6 +800,17 @@ class ProjectIndex {
758
800
  }
759
801
  }
760
802
  }
803
+ // A compiler-recognized type qualifier is also a semantic use of
804
+ // the receiver type: `JValue.Compare(...)` references both
805
+ // JValue and Compare. Index the receiver so class/type queries do
806
+ // not fall back to a full project scan or leave the occurrence as
807
+ // an unexplained call-not-resolved line.
808
+ if (call.receiverIsTypeQualified && call.receiver) {
809
+ if (!this.calleeIndex.has(call.receiver)) {
810
+ this.calleeIndex.set(call.receiver, new Set());
811
+ }
812
+ this.calleeIndex.get(call.receiver).add(filePath);
813
+ }
761
814
  }
762
815
  }
763
816
 
@@ -782,6 +835,9 @@ class ProjectIndex {
782
835
  if (rn !== call.name) removeName(rn);
783
836
  }
784
837
  }
838
+ if (call.receiverIsTypeQualified && call.receiver) {
839
+ removeName(call.receiver);
840
+ }
785
841
  }
786
842
  }
787
843
 
@@ -838,6 +894,10 @@ class ProjectIndex {
838
894
  return graphBuildModule._resolveJavaPackageImport(this, importModule, javaFileIndex);
839
895
  }
840
896
 
897
+ _resolveCSharpUsing(importModule) {
898
+ return graphBuildModule._resolveCSharpUsing(this, importModule);
899
+ }
900
+
841
901
  /**
842
902
  * Build import/export relationship graphs
843
903
  */
@@ -1048,9 +1108,11 @@ class ProjectIndex {
1048
1108
 
1049
1109
  // Filter by file if specified
1050
1110
  if (options.file) {
1051
- const filtered = definitions.filter(d =>
1052
- d.relativePath && d.relativePath.includes(options.file)
1053
- );
1111
+ const resolvedFile = this.resolveFilePathForQuery(options.file);
1112
+ const filtered = typeof resolvedFile === 'string'
1113
+ ? definitions.filter(d => d.file === resolvedFile)
1114
+ : definitions.filter(d =>
1115
+ d.relativePath && d.relativePath.includes(options.file));
1054
1116
  if (filtered.length > 0) {
1055
1117
  definitions = filtered;
1056
1118
  }
@@ -1058,15 +1120,23 @@ class ProjectIndex {
1058
1120
 
1059
1121
  // Filter by exact startLine when a handle was supplied. This pins
1060
1122
  // the resolution to one specific definition — no ambiguity allowed.
1061
- if (options.line && Number.isFinite(options.line)) {
1062
- const filtered = definitions.filter(d => d.startLine === options.line);
1063
- if (filtered.length > 0) {
1064
- definitions = filtered;
1123
+ if (options.line != null && options.line !== '') {
1124
+ const exactLine = Number(options.line);
1125
+ if (Number.isFinite(exactLine)) {
1126
+ // A line is an exact identity pin, never a ranking hint.
1127
+ // An unsatisfied pin must not fall back to another definition.
1128
+ definitions = definitions.filter(d =>
1129
+ d.startLine === exactLine || d.nameLine === exactLine);
1065
1130
  }
1066
1131
  }
1132
+ if (definitions.length === 0) {
1133
+ return { def: null, definitions: [], warnings: [] };
1134
+ }
1067
1135
 
1068
1136
  // Score each definition for selection
1069
- const typeOrder = new Set(['class', 'struct', 'interface', 'type', 'impl']);
1137
+ const typeOrder = new Set([
1138
+ 'class', 'struct', 'interface', 'type', 'impl', 'enum', 'record',
1139
+ ]);
1070
1140
  const { isTestPath } = require('./shared');
1071
1141
  const scored = definitions.map(d => {
1072
1142
  let score = 0;
@@ -1313,7 +1383,15 @@ class ProjectIndex {
1313
1383
  }
1314
1384
 
1315
1385
  const total = calls + definitions + imports;
1316
- const result = { total, calls, definitions, imports, references: 0 };
1386
+ const result = {
1387
+ total,
1388
+ calls,
1389
+ definitions,
1390
+ imports,
1391
+ references: 0,
1392
+ complete: false,
1393
+ countKind: 'fast-disambiguation-excludes-references',
1394
+ };
1317
1395
  if (memoKey) this._opUsageTotalsCache.set(memoKey, result);
1318
1396
  return result;
1319
1397
  }
@@ -1422,7 +1500,9 @@ class ProjectIndex {
1422
1500
  calls,
1423
1501
  definitions,
1424
1502
  imports,
1425
- references
1503
+ references,
1504
+ complete: true,
1505
+ countKind: 'detailed-ast-usages',
1426
1506
  };
1427
1507
  }
1428
1508
 
@@ -1444,11 +1524,53 @@ class ProjectIndex {
1444
1524
  * @param {string} typeName - The class/struct/interface name
1445
1525
  * @returns {Array} Methods belonging to this type
1446
1526
  */
1447
- findMethodsForType(typeName) {
1527
+ findMethodsForType(typeName, definition = null) {
1448
1528
  const methods = [];
1449
1529
  // Match both "TypeName" and "*TypeName" receivers (for Go/Rust pointer receivers)
1450
1530
  const baseTypeName = typeName.replace(/^\*/, '');
1451
1531
 
1532
+ const targetLanguage = definition?.file
1533
+ ? this.files.get(definition.file)?.language : null;
1534
+ const targetNamespace = definition?.namespace || null;
1535
+ const targetDir = definition?.file ? path.dirname(definition.file) : null;
1536
+ const sameCompilerType = symbol => {
1537
+ if (!definition || !targetLanguage) return true;
1538
+ const language = this.files.get(symbol.file)?.language;
1539
+ if (language !== targetLanguage) return false;
1540
+ if (['javascript', 'typescript', 'tsx', 'python', 'html']
1541
+ .includes(targetLanguage)) return symbol.file === definition.file;
1542
+ if (targetLanguage === 'go') return path.dirname(symbol.file) === targetDir;
1543
+ if (targetLanguage === 'java' || targetLanguage === 'csharp') {
1544
+ return (symbol.namespace || null) === targetNamespace;
1545
+ }
1546
+ if (targetLanguage === 'c' || targetLanguage === 'cpp') {
1547
+ if (symbol.file === definition.file) return true;
1548
+ const reachable = (from, to) => {
1549
+ const queue = [from];
1550
+ const seen = new Set(queue);
1551
+ for (let depth = 0; queue.length > 0 && depth < 16; depth++) {
1552
+ const current = queue.shift();
1553
+ for (const imported of this.importGraph.get(current) || []) {
1554
+ if (imported === to) return true;
1555
+ if (!seen.has(imported)) {
1556
+ seen.add(imported);
1557
+ queue.push(imported);
1558
+ }
1559
+ }
1560
+ }
1561
+ return false;
1562
+ };
1563
+ return reachable(symbol.file, definition.file) ||
1564
+ reachable(definition.file, symbol.file);
1565
+ }
1566
+ if (targetLanguage === 'rust') {
1567
+ const typeDefs = (this.symbols.get(baseTypeName) || []).filter(candidate =>
1568
+ ['struct', 'enum', 'trait', 'type'].includes(candidate.type));
1569
+ return typeDefs.length === 1 || symbol.file === definition.file;
1570
+ }
1571
+ return symbol.file === definition.file;
1572
+ };
1573
+
1452
1574
  for (const [, symbols] of this.symbols) {
1453
1575
  for (const symbol of symbols) {
1454
1576
  // Skip non-method types (fields, properties, etc.)
@@ -1460,7 +1582,7 @@ class ProjectIndex {
1460
1582
  // Also matches Rust associated functions (have receiver but isMethod=false)
1461
1583
  if (symbol.receiver) {
1462
1584
  const receiverBase = symbol.receiver.replace(/^\*/, '');
1463
- if (receiverBase === baseTypeName) {
1585
+ if (receiverBase === baseTypeName && sameCompilerType(symbol)) {
1464
1586
  methods.push(symbol);
1465
1587
  continue;
1466
1588
  }
@@ -1469,7 +1591,8 @@ class ProjectIndex {
1469
1591
  // Check Python/Java/JS-style className (class members)
1470
1592
  // Must be a method type, not just any symbol with className
1471
1593
  if (symbol.className === baseTypeName &&
1472
- (symbol.isMethod || symbol.type === 'method' || symbol.type === 'constructor')) {
1594
+ (symbol.isMethod || symbol.type === 'method' || symbol.type === 'constructor') &&
1595
+ sameCompilerType(symbol)) {
1473
1596
  methods.push(symbol);
1474
1597
  continue;
1475
1598
  }
@@ -1506,7 +1629,7 @@ class ProjectIndex {
1506
1629
  * @returns {boolean} true if ALL occurrences of name are inside strings
1507
1630
  */
1508
1631
  isInsideStringAST(content, lineNum, line, name, filePath) {
1509
- const language = detectLanguage(filePath);
1632
+ const language = detectLanguage(filePath, this.root);
1510
1633
  if (!language) {
1511
1634
  return false; // Unsupported language - assume not inside string
1512
1635
  }
@@ -1627,6 +1750,18 @@ class ProjectIndex {
1627
1750
  parts.push(def.modifiers.join(' '));
1628
1751
  }
1629
1752
  parts.push(def.name);
1753
+ const dataMember = def.type === 'field' || def.type === 'state' ||
1754
+ def.memberType === 'field' || def.memberType === 'property';
1755
+ if (dataMember) {
1756
+ const declaredType = def.fieldType || def.returnType;
1757
+ if (declaredType) {
1758
+ parts.push(`: ${String(declaredType).replace(/\s+/g, ' ').trim()}`);
1759
+ }
1760
+ return parts.join(' ');
1761
+ }
1762
+ if (def.type === 'macro' && def.functionLike === false) {
1763
+ return parts.join(' ');
1764
+ }
1630
1765
  if (def.params !== undefined) {
1631
1766
  // Prefer typed rendering when paramTypes/paramsStructured carry annotations
1632
1767
  const { renderTypedParams } = require('./output/shared');
@@ -1651,12 +1786,12 @@ class ProjectIndex {
1651
1786
  * @returns {string} 'call', 'import', 'definition', or 'reference'
1652
1787
  */
1653
1788
  classifyUsageAST(content, lineNum, name, filePath) {
1654
- const language = detectLanguage(filePath);
1789
+ const language = detectLanguage(filePath, this.root);
1655
1790
  if (!language) {
1656
1791
  return null; // Signal to use fallback
1657
1792
  }
1658
1793
 
1659
- const langModule = getLanguageModule(language);
1794
+ const langModule = getLanguageAdapter(language);
1660
1795
  if (!langModule || typeof langModule.findUsagesInCode !== 'function') {
1661
1796
  return null;
1662
1797
  }
@@ -1690,7 +1825,7 @@ class ProjectIndex {
1690
1825
  * @returns {boolean}
1691
1826
  */
1692
1827
  isCommentOrStringAtPosition(content, lineNum, column, filePath) {
1693
- const language = detectLanguage(filePath);
1828
+ const language = detectLanguage(filePath, this.root);
1694
1829
  if (!language) {
1695
1830
  return false; // Can't determine, assume code
1696
1831
  }
@@ -1854,6 +1989,62 @@ class ProjectIndex {
1854
1989
  'FunctionalInterface', 'SafeVarargs',
1855
1990
  'Iterable', 'Comparable', 'AutoCloseable', 'Cloneable',
1856
1991
  'Enum', 'Record', 'Void'
1992
+ ]),
1993
+ c: new Set([
1994
+ 'auto', 'break', 'case', 'char', 'const', 'continue',
1995
+ 'default', 'do', 'double', 'else', 'enum', 'extern',
1996
+ 'float', 'for', 'goto', 'if', 'inline', 'int', 'long',
1997
+ 'register', 'restrict', 'return', 'short', 'signed',
1998
+ 'sizeof', 'static', 'struct', 'switch', 'typedef', 'union',
1999
+ 'unsigned', 'void', 'volatile', 'while', '_Bool',
2000
+ '_Complex', '_Atomic', '_Generic', '_Noreturn',
2001
+ 'NULL', 'true', 'false', 'size_t', 'ptrdiff_t',
2002
+ 'printf', 'fprintf', 'sprintf', 'snprintf', 'scanf',
2003
+ 'malloc', 'calloc', 'realloc', 'free', 'memcpy', 'memmove',
2004
+ 'memset', 'strlen', 'strcmp', 'strcpy', 'strncpy',
2005
+ 'fopen', 'fclose', 'fread', 'fwrite', 'exit', 'abort',
2006
+ ]),
2007
+ cpp: new Set([
2008
+ 'alignas', 'alignof', 'and', 'and_eq', 'asm', 'auto',
2009
+ 'bitand', 'bitor', 'bool', 'break', 'case', 'catch',
2010
+ 'char', 'char8_t', 'char16_t', 'char32_t', 'class',
2011
+ 'compl', 'concept', 'const', 'consteval', 'constexpr',
2012
+ 'constinit', 'const_cast', 'continue', 'co_await',
2013
+ 'co_return', 'co_yield', 'decltype', 'default', 'delete',
2014
+ 'do', 'double', 'dynamic_cast', 'else', 'enum', 'explicit',
2015
+ 'export', 'extern', 'false', 'float', 'for', 'friend',
2016
+ 'goto', 'if', 'inline', 'int', 'long', 'mutable',
2017
+ 'namespace', 'new', 'noexcept', 'not', 'nullptr',
2018
+ 'operator', 'or', 'private', 'protected', 'public',
2019
+ 'register', 'reinterpret_cast', 'requires', 'return',
2020
+ 'short', 'signed', 'sizeof', 'static', 'static_assert',
2021
+ 'static_cast', 'struct', 'switch', 'template', 'this',
2022
+ 'thread_local', 'throw', 'true', 'try', 'typedef',
2023
+ 'typeid', 'typename', 'union', 'unsigned', 'using',
2024
+ 'virtual', 'void', 'volatile', 'wchar_t', 'while',
2025
+ 'std', 'string', 'vector', 'map', 'unordered_map', 'set',
2026
+ 'unique_ptr', 'shared_ptr', 'weak_ptr', 'optional',
2027
+ 'variant', 'tuple', 'move', 'forward', 'make_shared',
2028
+ 'make_unique', 'cout', 'cerr', 'cin', 'endl',
2029
+ ]),
2030
+ csharp: new Set([
2031
+ 'abstract', 'as', 'base', 'bool', 'break', 'byte', 'case',
2032
+ 'catch', 'char', 'checked', 'class', 'const', 'continue',
2033
+ 'decimal', 'default', 'delegate', 'do', 'double', 'else',
2034
+ 'enum', 'event', 'explicit', 'extern', 'false', 'finally',
2035
+ 'fixed', 'float', 'for', 'foreach', 'goto', 'if',
2036
+ 'implicit', 'in', 'int', 'interface', 'internal', 'is',
2037
+ 'lock', 'long', 'namespace', 'new', 'null', 'object',
2038
+ 'operator', 'out', 'override', 'params', 'private',
2039
+ 'protected', 'public', 'readonly', 'record', 'ref',
2040
+ 'return', 'sbyte', 'sealed', 'short', 'sizeof',
2041
+ 'stackalloc', 'static', 'string', 'struct', 'switch',
2042
+ 'this', 'throw', 'true', 'try', 'typeof', 'uint', 'ulong',
2043
+ 'unchecked', 'unsafe', 'ushort', 'using', 'virtual',
2044
+ 'void', 'volatile', 'while', 'async', 'await', 'var',
2045
+ 'dynamic', 'yield', 'Console', 'Math', 'String', 'Object',
2046
+ 'Task', 'ValueTask', 'List', 'Dictionary', 'HashSet',
2047
+ 'IEnumerable', 'IDisposable', 'Exception',
1857
2048
  ])
1858
2049
  };
1859
2050
  // TypeScript/TSX share JavaScript keywords
@@ -2191,6 +2382,12 @@ class ProjectIndex {
2191
2382
  /** Load index from cache file */
2192
2383
  loadCache(cachePath) { return indexCache.loadCache(this, cachePath); }
2193
2384
 
2385
+ /** Return this project's default per-user cache file path. */
2386
+ getCachePath() { return indexCache.getProjectCachePath(this.root); }
2387
+
2388
+ /** Remove this project's per-user cache and any legacy project-local cache. */
2389
+ clearCache() { return indexCache.clearProjectCache(this.root); }
2390
+
2194
2391
  /** Load callsCache from separate file on demand (called by findCallers/findCallees) */
2195
2392
  loadCallsCache() { return indexCache.loadCallsCache(this); }
2196
2393