ucn 5.4.2 → 5.5.1

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 (85) hide show
  1. package/.claude/skills/ucn/SKILL.md +58 -236
  2. package/.claude/skills/ucn/references/commands.md +144 -68
  3. package/.claude/skills/ucn/references/resolution-rules.md +171 -0
  4. package/.claude/skills/ucn/references/trust-contract.md +27 -33
  5. package/README.md +27 -16
  6. package/cli/index.js +45 -14
  7. package/core/accessors.js +118 -3
  8. package/core/account.js +62 -11
  9. package/core/analysis.js +517 -266
  10. package/core/ast-analysis.js +76 -117
  11. package/core/async-producers.js +1729 -0
  12. package/core/bridge.js +1877 -268
  13. package/core/brief.js +6 -0
  14. package/core/build-worker.js +78 -14
  15. package/core/cache.js +529 -31
  16. package/core/callers.js +12228 -921
  17. package/core/check.js +30 -38
  18. package/core/class-identity.js +1028 -0
  19. package/core/command-contracts.js +6 -6
  20. package/core/configuration-items.js +86 -0
  21. package/core/contract-membership.js +2262 -0
  22. package/core/cpp-scope.js +532 -0
  23. package/core/deadcode.js +171 -49
  24. package/core/decorator-binding.js +260 -0
  25. package/core/discovery.js +281 -164
  26. package/core/errors.js +52 -0
  27. package/core/execute.js +21 -9
  28. package/core/external-macros.js +326 -0
  29. package/core/graph-build.js +277 -8
  30. package/core/graph.js +35 -2
  31. package/core/ignore-rules.js +472 -0
  32. package/core/imports.js +504 -42
  33. package/core/index-ir.js +94 -11
  34. package/core/inprocess-clients.js +636 -0
  35. package/core/ir.js +87 -8
  36. package/core/macro-expansion.js +1416 -0
  37. package/core/output/analysis.js +18 -2
  38. package/core/output/brief.js +3 -0
  39. package/core/output/doctor.js +4 -0
  40. package/core/output/endpoints.js +15 -6
  41. package/core/output/find.js +7 -2
  42. package/core/output/refactoring.js +121 -14
  43. package/core/output/reporting.js +31 -3
  44. package/core/output/shared.js +14 -0
  45. package/core/output-budget.js +305 -122
  46. package/core/parallel-build.js +124 -47
  47. package/core/parser.js +3 -2
  48. package/core/project.js +472 -66
  49. package/core/provenance-facts.js +156 -12
  50. package/core/provenance-overload.js +30 -3
  51. package/core/provenance.js +104 -30
  52. package/core/pytest-fixtures.js +434 -0
  53. package/core/python-fixture-flow.js +17 -3
  54. package/core/python-modules.js +173 -0
  55. package/core/reflection.js +877 -0
  56. package/core/registry.js +1 -1
  57. package/core/reporting.js +234 -52
  58. package/core/request-helpers.js +561 -0
  59. package/core/route-graph.js +1922 -0
  60. package/core/route-lists.js +2414 -0
  61. package/core/rust-macro-expansion.js +3026 -0
  62. package/core/rust-modules.js +203 -0
  63. package/core/search.js +121 -29
  64. package/core/shared.js +19 -1
  65. package/core/test-name-bindings.js +256 -0
  66. package/core/type-denotation.js +1185 -0
  67. package/core/type-references.js +3229 -0
  68. package/core/verify.js +3360 -672
  69. package/languages/adapter.js +31 -5
  70. package/languages/c-family.js +4061 -212
  71. package/languages/c-macro-invocations.js +380 -0
  72. package/languages/c-preprocessor.js +254 -0
  73. package/languages/csharp.js +1406 -49
  74. package/languages/go.js +1243 -70
  75. package/languages/index.js +728 -6
  76. package/languages/java.js +473 -61
  77. package/languages/javascript.js +1270 -197
  78. package/languages/lexical-scope.js +1280 -0
  79. package/languages/python.js +1557 -72
  80. package/languages/rust-macro-rules.js +1020 -0
  81. package/languages/rust-value-flow.js +416 -0
  82. package/languages/rust.js +2265 -112
  83. package/languages/utils.js +298 -4
  84. package/mcp/server.js +22 -8
  85. package/package.json +1 -1
package/core/project.js CHANGED
@@ -6,11 +6,13 @@
6
6
  */
7
7
 
8
8
  const fs = require('fs');
9
+ const os = require('os');
9
10
  const path = require('path');
10
11
  const crypto = require('crypto');
12
+ const { UcnError } = require('./errors');
11
13
  const {
12
14
  expandGlob, findProjectRoot, detectProjectPattern, isTestFile,
13
- parseGitignore, gitTrackedPaths, DEFAULT_IGNORES, compareNames, classifyUnsupportedSourceFile,
15
+ parseGitignore, gitTrackedPaths, gitListing, DEFAULT_IGNORES, compareNames, classifyUnsupportedSourceFile,
14
16
  } = require('./discovery');
15
17
  const { cleanHtmlScriptTags } = require('./parser');
16
18
  const { detectLanguage, getParser, getLanguageAdapter, safeParse, langTraits, PARSE_OPTIONS } = require('../languages');
@@ -20,7 +22,10 @@ const { getTokenTypeAtPosition } = require('../languages/utils');
20
22
  const { escapeRegExp, NON_CALLABLE_TYPES, codeUnitCompare, literalNameRegex } = require('./shared');
21
23
  const stacktrace = require('./stacktrace');
22
24
  const indexCache = require('./cache');
23
- const deadcodeModule = require('./deadcode');
25
+ // Loaded on first use (fix #375): most commands never reach dead-code
26
+ // analysis, and loading it (with entry-point detection) was a fixed cost of
27
+ // every CLI command.
28
+ const deadcodeModule = () => require('./deadcode');
24
29
  const verifyModule = require('./verify');
25
30
  const callersModule = require('./callers');
26
31
  const tracingModule = require('./tracing');
@@ -41,6 +46,15 @@ let LANGUAGE_KEYWORDS = null;
41
46
  // memory instead of waiting for N-API finalizers.
42
47
  const PARSED_TREE_CACHE_MAX_ENTRIES = 128;
43
48
  const PARSED_TREE_CACHE_MAX_SOURCE_BYTES = 32 * 1024 * 1024;
49
+ // Automatic build parallelism (fix #375): source bytes to (re)parse before a
50
+ // build uses workers, and per worker. A worker's fixed cost (thread start
51
+ // and its own JIT warm-up of the parsers) is about the CPU of parsing 300KB
52
+ // on a warm thread (fix #388: at 128KB per worker from 256KB, start-up cost
53
+ // more CPU than the parsing it moved off the main thread).
54
+ const PARALLEL_MIN_WORK_BYTES = 2 * 1024 * 1024;
55
+ const PARALLEL_BYTES_PER_WORKER = 1024 * 1024;
56
+ // fileEntry.symbols array -> { length, byName } (see _symbolsNamedAt).
57
+ const symbolsByNameAndLine = new WeakMap();
44
58
 
45
59
  /**
46
60
  * ProjectIndex - Manages symbol table for a project
@@ -66,9 +80,14 @@ class ProjectIndex {
66
80
  this.lastBuildWorkerCount = 1;
67
81
  this.lastBuildParallelEligible = false;
68
82
  this.lastBuildRequestedWorkers = null;
83
+ // Parse workers the last build kept for Rust macro expansion (0: the
84
+ // expansion ran in place or had nothing to expand).
85
+ this.lastBuildExpansionWorkers = 0;
69
86
  this.callsCache = new Map(); // filePath -> { mtime, hash, calls, content }
70
87
  this.callsCacheDirty = false; // set by getCachedCalls when entries are added or mutated
71
88
  this.computedDispatchDirty = false; // persisted project-wide AST blind-spot inventory
89
+ this.macroExpansionDirty = false; // persisted C/C++ macro-expansion records (core/macro-expansion.js)
90
+ this._macroExpansionPersisted = null;
72
91
  this.failedFiles = new Set(); // files that failed to index (e.g. large minified bundles)
73
92
  this.unsupportedFiles = []; // common source files skipped by the parser registry
74
93
  this.discoveryIssues = []; // explicit reasons the index may be partial
@@ -80,7 +99,6 @@ class ProjectIndex {
80
99
  this._opInnerSymbolRangesCache = null; // per-operation sorted class-method ranges by file
81
100
  this._opFlowTypeOriginCache = null; // per-operation annotation type identity results
82
101
  this._opCppTypeCategoryCache = null; // per-operation normalized C++ parameter categories
83
- this._opCppPathReceiverTypeCache = null; // per-operation C++ qualified receiver identity
84
102
  this._opDerefPairs = null; // per-operation Rust Deref identity pairs
85
103
  this._opAliasPairs = null; // per-operation language type-alias identity pairs
86
104
  this._parsedTreeCache = new Map(); // cross-operation LRU: filePath -> immutable tree entry
@@ -106,6 +124,16 @@ class ProjectIndex {
106
124
  // deliberately never persisted. It is safe across commands only
107
125
  // until the next build, which resets it below.
108
126
  this._returnTypeFlowCache = new Map();
127
+ this._classIdentity = null; // class-definition identity memo, reset at every build/load
128
+ this._macroExpansion = null; // C/C++ macro-expansion memo (core/macro-expansion.js), reset with _classIdentity
129
+ this._macroSpellable = null; // fix #385: paste-spellability memo over the same symbols
130
+ this._rustMacroState = null; // Rust macro_rules! expansion state (core/rust-macro-expansion.js)
131
+ this._cppScope = null; // C++ qualified-name memo (core/cpp-scope.js)
132
+ this._contractIndex = null; // contract-membership index memo (core/contract-membership.js)
133
+ this._typeDenotation = null; // type-name denotation memo (core/type-denotation.js), reset with _classIdentity
134
+ this._languageFeatureMemo = null; // fix #380 protocol-type feature memo
135
+ this._rustScopeMemo = null; // fix #368: Rust glob/bound resolution memo, index-lifetime
136
+ require('./imports').resetRustResolveMemo(); // fix #372: the tree may have changed
109
137
  this.calleeIndex = null; // name -> Set<filePath> — inverted call index (built lazily)
110
138
  }
111
139
 
@@ -146,10 +174,18 @@ class ProjectIndex {
146
174
  this._opFlowTypeOriginCache = new Map();
147
175
  this._opImportReachCache = new Map();
148
176
  this._opCppTypeCategoryCache = new Map();
149
- this._opCppPathReceiverTypeCache = new Map();
177
+ // Literal-name ground sets (name -> groundSet): a rename plan
178
+ // sweeps every slot member of one name (fix #376).
179
+ this._opGroundSetCache = new Map();
150
180
  this._opDerefPairs = undefined;
151
181
  this._opAliasPairs = undefined;
152
182
  this._opFindCallersCaches = null; // fix #340: findCallers per-file derivations
183
+ // Index-derived lookups that are pure functions of the symbol
184
+ // table and graphs, memoized for one operation (fix #382):
185
+ // ancestor closures, class-file resolution, same-name definitions
186
+ // grouped by owner. An exact HOT ranking asks them once per
187
+ // candidate for every same-name definition.
188
+ this._opMemos = new Map();
153
189
  this._opDepth = 0;
154
190
  }
155
191
  this._opDepth++;
@@ -176,10 +212,11 @@ class ProjectIndex {
176
212
  this._opFlowTypeOriginCache = null;
177
213
  this._opImportReachCache = null;
178
214
  this._opCppTypeCategoryCache = null;
179
- this._opCppPathReceiverTypeCache = null;
215
+ this._opGroundSetCache = null;
180
216
  this._opDerefPairs = null;
181
217
  this._opAliasPairs = null;
182
218
  this._opFindCallersCaches = null;
219
+ this._opMemos = null;
183
220
  // Free cached file content from callsCache entries (retained during
184
221
  // operation for _readFile caching, not needed between operations)
185
222
  for (const entry of this.callsCache.values()) {
@@ -189,6 +226,28 @@ class ProjectIndex {
189
226
  }
190
227
  }
191
228
 
229
+ /**
230
+ * Run `fn` in an operation of its own, with fresh per-operation caches,
231
+ * and give the enclosing operation (if any) its caches back afterwards
232
+ * (fix #394). For work that queries the index under a temporary view of
233
+ * a definition: no memo of the enclosing operation sees the view.
234
+ */
235
+ _isolatedOperation(fn) {
236
+ const keys = Object.keys(this).filter(key => key.startsWith('_op') &&
237
+ !['_opMemo'].includes(key) && typeof this[key] !== 'function');
238
+ const saved = new Map(keys.map(key => [key, this[key]]));
239
+ for (const key of keys) this[key] = key === '_opDepth' ? 0 : null;
240
+ this._opDerefPairs = undefined;
241
+ this._opAliasPairs = undefined;
242
+ this._beginOp();
243
+ try {
244
+ return fn();
245
+ } finally {
246
+ this._endOp();
247
+ for (const [key, value] of saved) this[key] = value;
248
+ }
249
+ }
250
+
192
251
  /** Remove one persistent parsed tree, deferring disposal if an operation still uses it. */
193
252
  _evictParsedTree(filePath) {
194
253
  const entry = this._parsedTreeCache.get(filePath);
@@ -252,7 +311,12 @@ class ProjectIndex {
252
311
  let tree;
253
312
  try {
254
313
  const parser = getParser(language);
255
- tree = parser ? safeParse(parser, content, undefined, PARSE_OPTIONS) : null;
314
+ // A language may read files through its own view of the source
315
+ // (C# conditional compilation, fix #391); queries read the same.
316
+ const queryTree = parser && getLanguageAdapter(language)?.queryTree;
317
+ tree = !parser ? null : queryTree
318
+ ? queryTree(content, parser, this.files.get(filePath))
319
+ : safeParse(parser, content, undefined, PARSE_OPTIONS);
256
320
  } catch (e) {
257
321
  tree = null;
258
322
  }
@@ -303,6 +367,7 @@ class ProjectIndex {
303
367
  // account classification can never poison the full `usages` result.
304
368
  const mode = [
305
369
  options.skipCallRecovery ? 'skip-call-recovery' : '',
370
+ options.lexicalScopes ? 'lexical-scopes' : '',
306
371
  ].filter(Boolean).join('+');
307
372
  const modeSuffix = mode ? `\0${mode}` : '';
308
373
  const cacheKey = `${filePath}\0${name}${modeSuffix}`;
@@ -408,6 +473,18 @@ class ProjectIndex {
408
473
  this._groundSetCacheLines = 0;
409
474
  this._nameBindingReachCache = new Map();
410
475
  this._returnTypeFlowCache = new Map();
476
+ this._classIdentity = null; // class-definition identity memo (core/class-identity.js)
477
+ this._macroExpansion = null; // macro-expansion memo (core/macro-expansion.js)
478
+ this._macroSpellable = null; // fix #385: paste-spellability memo over the same symbols
479
+ this._rustMacroState = null; // Rust macro_rules! expansion state (core/rust-macro-expansion.js)
480
+ this._cppScope = null; // C++ qualified-name memo (core/cpp-scope.js)
481
+ this._contractIndex = null; // contract-membership memo (core/contract-membership.js)
482
+ this._typeDenotation = null; // type-name denotation memo (core/type-denotation.js)
483
+ this._languageFeatureMemo = null; // fix #380 protocol-type feature memo
484
+ this._rustScopeMemo = null; // fix #368: Rust glob/bound resolution memo, index-lifetime
485
+ // Rust module resolutions and the directory listings behind them
486
+ // describe the tree this build reads (fix #375).
487
+ require('./imports').resetRustResolveMemo();
411
488
 
412
489
  // A (re)build invalidates any cache-loaded reachability set — the
413
490
  // fingerprint guard in computeReachability is content-shaped and
@@ -421,6 +498,7 @@ class ProjectIndex {
421
498
 
422
499
  // Accept pre-expanded file array (glob mode) or a pattern string
423
500
  let files;
501
+ let discoveredCargoManifests = null;
424
502
  const implicitProjectDiscovery = !Array.isArray(pattern) && !pattern;
425
503
  this.unsupportedFiles = [];
426
504
  this.discoveryIssues = [];
@@ -456,9 +534,20 @@ class ProjectIndex {
456
534
  };
457
535
 
458
536
  // Merge .gitignore and .ucn.json exclude into file discovery
459
- const gitignorePatterns = parseGitignore(this.root);
460
- globOpts.gitignorePatterns = gitignorePatterns;
461
- globOpts.trackedPaths = gitTrackedPaths(this.root);
537
+ const listing = gitListing(this.root);
538
+ const gitignoreRules = parseGitignore(this.root, listing);
539
+ globOpts.gitignoreRules = gitignoreRules;
540
+ globOpts.trackedPaths = gitTrackedPaths(this.root, listing);
541
+ this._discoveryHash = indexCache.discoveryFingerprint(gitignoreRules, listing);
542
+ // Git could not list the tracked files (fix #382): .gitignore
543
+ // rules then apply to every path, so a tracked file matching a
544
+ // rule is not indexed. Disclosed, never a silent file-set change.
545
+ if (listing?.failure) {
546
+ recordDiscoveryIssue({
547
+ path: this.root, kind: 'directory', reason: 'git-listing-failed',
548
+ detail: `${listing.failure.detail}; tracked files that match .gitignore rules could not be identified and are not indexed`,
549
+ });
550
+ }
462
551
  const configExclude = Array.isArray(this.config.exclude)
463
552
  ? this.config.exclude
464
553
  : (this.config.exclude ? [String(this.config.exclude)] : []);
@@ -469,7 +558,11 @@ class ProjectIndex {
469
558
  globOpts.onDiscoveryIssue = recordDiscoveryIssue;
470
559
 
471
560
  if (implicitProjectDiscovery) {
561
+ discoveredCargoManifests = [];
472
562
  globOpts.onSkippedFile = (filePath) => {
563
+ if (path.basename(filePath) === 'Cargo.toml') {
564
+ discoveredCargoManifests.push(path.relative(this.root, filePath));
565
+ }
473
566
  const kind = classifyUnsupportedSourceFile(filePath);
474
567
  if (!kind) return;
475
568
  this.unsupportedFiles.push({
@@ -528,7 +621,11 @@ class ProjectIndex {
528
621
  this._completenessCache = null;
529
622
  this._attrTypeCache = null;
530
623
  this._computedDispatchBlindspots = null;
624
+ this._reflectionPatternIndex = null;
531
625
  this._cppVisibleFilesCache?.clear();
626
+ this._textualIncludeClosureCache?.clear();
627
+ this._translationUnitLanguageCache?.clear();
628
+ if (this._compiledTranslationUnits !== undefined) this._compiledTranslationUnits = undefined;
532
629
  this._cppTargetVisibilityCache?.clear();
533
630
  this._cppMacroParamOutcomesCache?.clear();
534
631
  // Endpoints cache (server routes / client requests / bridges) becomes
@@ -555,31 +652,79 @@ class ProjectIndex {
555
652
  // native projects therefore benefit from workers well before the
556
653
  // generic 150-file crossover; measured release repos cross over at
557
654
  // roughly 25 C-family files.
558
- const cFamilyFileCount = files.reduce((count, filePath) => {
655
+ let cFamilyFileCount = 0;
656
+ let rustFileCount = 0;
657
+ for (const filePath of files) {
559
658
  const language = detectLanguage(filePath, this.root);
560
- return count + (language === 'c' || language === 'cpp' ? 1 : 0);
561
- }, 0);
659
+ if (language === 'c' || language === 'cpp') cFamilyFileCount++;
660
+ else if (language === 'rust') rustFileCount++;
661
+ }
662
+ // The project's C/C++ macro definitions, so each recovery sees the
663
+ // decoration, statement and attribute macros other headers define
664
+ // (fix #396). A sequential build reads them before its first file;
665
+ // parse workers receive them while they parse and wait only when a
666
+ // recovery consults them.
667
+ this._macroDictionary = undefined;
668
+ const readMacroDictionary = () => {
669
+ if (this._macroDictionary === undefined && cFamilyFileCount > 0) {
670
+ this._macroDictionary = require('./external-macros').projectMacroDictionary(this, files);
671
+ }
672
+ return this._macroDictionary;
673
+ };
562
674
  // An explicit worker count is a request for a reproducible execution
563
675
  // shape (not merely an upper bound). This is useful to users tuning a
564
676
  // constrained host and lets the release gate compare like with like.
677
+ // Without an explicit count, the worker count follows the parse work:
678
+ // the source bytes of the files this build must (re)parse (fix #375).
679
+ // A file count alone kept 100-file Rust projects (ripgrep: 1.8MB,
680
+ // ~1.8s of parsing) on one thread while a 151-file project of stubs
681
+ // paid for two workers, and an incremental rebuild of one changed
682
+ // file in a large project spawned the full pool.
683
+ const workSizes = new Map();
684
+ let workBytes = 0;
685
+ let workFiles = 0;
686
+ if (!disableParallel && !explicitWorkerCount && cFamilyFileCount < 25 && files.length >= 2) {
687
+ for (const filePath of files) {
688
+ let stat;
689
+ try { stat = fs.statSync(filePath); } catch (_) { continue; }
690
+ workSizes.set(filePath, stat.size);
691
+ const existing = this.files.get(filePath);
692
+ if (existing && existing.mtime === stat.mtimeMs && existing.size === stat.size) continue;
693
+ workBytes += stat.size;
694
+ workFiles++;
695
+ }
696
+ }
697
+ const availableCpus = typeof os.availableParallelism === 'function'
698
+ ? os.availableParallelism() : os.cpus().length;
699
+ const byteWorkers = Math.min(workFiles >> 1, Math.max(availableCpus - 1, 1),
700
+ Math.ceil(workBytes / PARALLEL_BYTES_PER_WORKER));
565
701
  const parallelWorthwhile = explicitWorkerCount
566
702
  ? files.length >= 2
567
- : files.length > 150 || cFamilyFileCount >= 25;
703
+ : cFamilyFileCount >= 25 || (workBytes >= PARALLEL_MIN_WORK_BYTES && byteWorkers >= 2);
568
704
  this.lastBuildParallelEligible = !disableParallel && parallelWorthwhile;
569
705
  if (!disableParallel && parallelWorthwhile) {
570
706
  try {
571
707
  const { parallelBuild } = require('./parallel-build');
708
+ const autoCFamily = cFamilyFileCount >= 25 && !explicitWorkerCount;
572
709
  const result = parallelBuild(this, files, {
573
- workerCount: workersSetting > 0 ? workersSetting : (envWorkers > 0 ? envWorkers : undefined),
710
+ macroDictionary: cFamilyFileCount > 0
711
+ ? () => require('./external-macros').serializeDictionary(readMacroDictionary()) : null,
712
+ workerCount: workersSetting > 0 ? workersSetting
713
+ : (envWorkers > 0 ? envWorkers : (autoCFamily ? undefined : byteWorkers)),
574
714
  // After the recovery scorer stopped repeatedly walking
575
715
  // every candidate tree, real-repo sweeps put the native
576
716
  // throughput knee at 5-6 workers. Default to the lower
577
717
  // knee (five) for RSS headroom; explicit user settings
578
718
  // retain the ordinary eight-worker safety cap.
579
- maxWorkers: cFamilyFileCount >= 25 && !explicitWorkerCount
580
- ? 5 : 8,
581
- minFilesPerWorker: explicitWorkerCount
582
- ? 1 : (cFamilyFileCount >= 25 ? 10 : 100),
719
+ maxWorkers: autoCFamily ? 5 : 8,
720
+ minFilesPerWorker: autoCFamily ? 10 : 1,
721
+ sizes: workSizes,
722
+ // Rust macro expansion runs in parse workers kept for it
723
+ // (fix #388; fix #375 started fresh threads, whose parser
724
+ // code ran cold again).
725
+ retainWorkers: rustFileCount > 0
726
+ ? require('./rust-macro-expansion').EXPANSION_WORKERS : 0,
727
+ onParsed: rustFileCount > 0 ? (pool) => { this._buildPool = pool || null; } : null,
583
728
  quiet,
584
729
  });
585
730
  if (result !== false) {
@@ -595,30 +740,88 @@ class ProjectIndex {
595
740
  }
596
741
  }
597
742
 
598
- if (!usedParallel) {
599
- for (const file of files) {
600
- try {
601
- if (this.indexFile(file)) changed++;
602
- indexed++;
603
- this.failedFiles.delete(file); // Succeeded now, remove from failed
604
- } catch (e) {
605
- this.failedFiles.add(file); // Track files that fail to index
606
- if (!quiet) {
607
- console.error(` Warning: Could not index ${file}: ${e.message}`);
743
+ // Parse workers kept for Rust macro expansion (fix #388); ended once
744
+ // the expansion no longer needs them.
745
+ const buildPool = this._buildPool || null;
746
+ this._buildPool = null;
747
+ try {
748
+ if (!usedParallel) {
749
+ readMacroDictionary();
750
+ for (const file of files) {
751
+ try {
752
+ if (this.indexFile(file)) changed++;
753
+ indexed++;
754
+ this.failedFiles.delete(file); // Succeeded now, remove from failed
755
+ } catch (e) {
756
+ this.failedFiles.add(file); // Track files that fail to index
757
+ if (!quiet) {
758
+ console.error(` Warning: Could not index ${file}: ${e.message}`);
759
+ }
608
760
  }
609
761
  }
610
762
  }
611
- }
612
763
 
613
- // Canonical order BEFORE derived indexes, so graphs / dir index /
614
- // callee index inherit it. This is what makes incremental rebuilds
615
- // byte-equivalent to fresh builds (see _canonicalizeOrder).
616
- this._canonicalizeOrder();
764
+ // Project macro_rules! invocations (fix #374): what they generate
765
+ // (functions, impls, calls) becomes ordinary facts of the invoking
766
+ // file before any derived index is built. Persisted per file and
767
+ // revalidated against the file, the macro definitions it used and
768
+ // its module ancestry; a stale expanded file is re-parsed first.
769
+ const rustMacroExpansion = require('./rust-macro-expansion');
770
+ const pendingMacroFiles = rustMacroExpansion.planRustMacroExpansion(this, {
771
+ reindex: file => { if (this.indexFile(file, { force: true })) changed++; },
772
+ });
617
773
 
618
- // Skip graph rebuild when incremental rebuild found no changes
619
- if (changed > 0 || deletedInRebuild > 0 || !options.forceRebuild) {
620
- this.buildImportGraph();
621
- this.buildInheritanceGraph();
774
+ // Canonical order BEFORE derived indexes, so graphs / dir index /
775
+ // callee index inherit it. This is what makes incremental rebuilds
776
+ // byte-equivalent to fresh builds (see _canonicalizeOrder).
777
+ this._canonicalizeOrder();
778
+
779
+ // Cargo manifests (fix #372): the workspace crate registry is scanned
780
+ // here, once, and persisted; a query never walks the tree for it. An
781
+ // incremental rebuild rescans only when a manifest changed, vanished
782
+ // or appeared, and then the import graph is rebuilt even when no
783
+ // source file changed (import resolution reads the manifests).
784
+ const manifestsChanged = this._refreshCargoManifests(options.forceRebuild, discoveredCargoManifests);
785
+
786
+ // Macro expansion workers run while the import graph is built (fix
787
+ // #375); their records are applied after it.
788
+ const macroOptions = { workers: options.workers, pool: buildPool };
789
+ const macroRun = rustMacroExpansion.startRustMacroExpansion(this, pendingMacroFiles, macroOptions);
790
+ this.lastBuildExpansionWorkers = macroRun?.results?.workerCount || 0;
791
+ if (!macroRun?.results?.usesPool) buildPool?.dispose();
792
+ try {
793
+ // Skip graph rebuild when incremental rebuild found no changes
794
+ if (changed > 0 || deletedInRebuild > 0 || manifestsChanged || !options.forceRebuild) {
795
+ this.buildImportGraph();
796
+ const touched = rustMacroExpansion.applyRustMacroExpansion(this, pendingMacroFiles, macroOptions, macroRun);
797
+ if (touched) this._canonicalizeTouched(touched);
798
+ // Project macro definitions a C/C++ recovery used are
799
+ // checked against the file's include closure, and files
800
+ // whose recorded definitions changed are read again
801
+ // (fix #396). Their #include lines do not change, so the
802
+ // import graph stands.
803
+ if (this._macroDictionary !== undefined) {
804
+ const dictionary = this._macroDictionary;
805
+ const recovered = require('./external-macros').verifyExternalMacros(this, dictionary, {
806
+ reindex: (file, externalMacros) => {
807
+ if (this.indexFile(file, { force: true, externalMacros })) changed++;
808
+ },
809
+ });
810
+ if (recovered) this._canonicalizeTouched(recovered);
811
+ }
812
+ this.buildInheritanceGraph();
813
+ } else {
814
+ const touched = rustMacroExpansion.applyRustMacroExpansion(this, pendingMacroFiles, macroOptions, macroRun);
815
+ if (touched) {
816
+ this._canonicalizeTouched(touched);
817
+ this.buildInheritanceGraph();
818
+ }
819
+ }
820
+ } finally {
821
+ macroRun?.results?.dispose();
822
+ }
823
+ } finally {
824
+ buildPool?.dispose();
622
825
  }
623
826
 
624
827
  // Build directory→files index for O(1) same-package lookups
@@ -641,6 +844,38 @@ class ProjectIndex {
641
844
  }
642
845
  }
643
846
 
847
+ /**
848
+ * Keep `this.cargoManifests` (persisted Cargo manifest snapshot) current.
849
+ * Rust projects only. Returns true when the snapshot changed on an
850
+ * incremental rebuild.
851
+ * @param {boolean} incremental
852
+ * @param {string[]|null} discovered - Cargo.toml paths discovery saw
853
+ */
854
+ _refreshCargoManifests(incremental, discovered) {
855
+ const {
856
+ snapshotWorkspaceManifests, workspaceManifestsCurrent, resetCargoCaches,
857
+ manifestSetFingerprint,
858
+ } = require('./imports');
859
+ let hasRust = false;
860
+ for (const entry of this.files.values()) {
861
+ if (entry.language === 'rust') { hasRust = true; break; }
862
+ }
863
+ const previous = this.cargoManifests || null;
864
+ if (!hasRust) {
865
+ this.cargoManifests = null;
866
+ this.cargoManifestsSeen = null;
867
+ if (previous) resetCargoCaches(this.root);
868
+ return incremental && !!previous;
869
+ }
870
+ if (incremental && workspaceManifestsCurrent(this.root, previous, discovered,
871
+ this.cargoManifestsSeen)) {
872
+ return false;
873
+ }
874
+ this.cargoManifests = snapshotWorkspaceManifests(this.root);
875
+ this.cargoManifestsSeen = discovered ? manifestSetFingerprint(discovered) : null;
876
+ return incremental && JSON.stringify(previous) !== JSON.stringify(this.cargoManifests);
877
+ }
878
+
644
879
  /**
645
880
  * Build a minimal index for a single file (no glob, no cache, no import graph).
646
881
  * Used by CLI file mode to route through execute().
@@ -648,21 +883,27 @@ class ProjectIndex {
648
883
  buildSingleFile(filePath) {
649
884
  const absPath = path.resolve(filePath);
650
885
  if (!fs.existsSync(absPath)) {
651
- throw new Error(`File not found: ${filePath}`);
886
+ throw new UcnError(`File not found: ${filePath}`);
652
887
  }
653
888
  this.indexFile(absPath);
889
+ const rustMacroExpansion = require('./rust-macro-expansion');
890
+ rustMacroExpansion.applyRustMacroExpansion(this,
891
+ rustMacroExpansion.planRustMacroExpansion(this, { files: [absPath] }));
654
892
  this.buildTime = 0;
655
893
  }
656
894
 
657
895
  /**
658
896
  * Index a single file
659
897
  */
660
- indexFile(filePath) {
898
+ indexFile(filePath, options = {}) {
661
899
  const stat = fs.statSync(filePath);
662
900
  const existing = this.files.get(filePath);
901
+ // `force` re-derives an unchanged file's parser facts (fix #374: a
902
+ // macro-expanded file whose macro definitions changed).
903
+ const force = options.force === true;
663
904
 
664
905
  // Fast path: skip read entirely when mtime+size both match
665
- if (existing && existing.mtime === stat.mtimeMs && existing.size === stat.size) {
906
+ if (!force && existing && existing.mtime === stat.mtimeMs && existing.size === stat.size) {
666
907
  return false;
667
908
  }
668
909
 
@@ -670,7 +911,7 @@ class ProjectIndex {
670
911
  const hash = crypto.createHash('md5').update(content).digest('hex');
671
912
 
672
913
  // Content-based skip: mtime changed but content didn't (touch, git checkout)
673
- if (existing && existing.hash === hash) {
914
+ if (!force && existing && existing.hash === hash) {
674
915
  existing.mtime = stat.mtimeMs;
675
916
  existing.size = stat.size;
676
917
  return false;
@@ -680,6 +921,9 @@ class ProjectIndex {
680
921
  // A changed file can alter either even before a full rebuild reaches
681
922
  // its graph phase, so no prior call-identity verdict may survive.
682
923
  this._cppVisibleFilesCache?.clear();
924
+ this._textualIncludeClosureCache?.clear();
925
+ this._translationUnitLanguageCache?.clear();
926
+ if (this._compiledTranslationUnits !== undefined) this._compiledTranslationUnits = undefined;
683
927
  this._cppTargetVisibilityCache?.clear();
684
928
  this._cppMacroParamOutcomesCache?.clear();
685
929
 
@@ -692,7 +936,12 @@ class ProjectIndex {
692
936
 
693
937
  const adapter = getLanguageAdapter(language);
694
938
  const parser = getParser(language);
695
- const ir = adapter.analyze(content, parser, filePath);
939
+ // C/C++: the project macro dictionary of the current build (fix
940
+ // #396), unless the caller supplies the view.
941
+ const externalMacros = options.externalMacros !== undefined ? options.externalMacros
942
+ : this._macroDictionary ? require('./external-macros').contextFor(this._macroDictionary, language) : null;
943
+ const ir = adapter.analyze(content, parser, filePath,
944
+ externalMacros ? { externalMacros } : undefined);
696
945
  const irFailures = validateFileIR(ir);
697
946
  if (irFailures.length > 0) {
698
947
  throw new Error(`Invalid ${language} IR: ${irFailures.join('; ')}`);
@@ -787,7 +1036,9 @@ class ProjectIndex {
787
1036
  // Incrementally update callee index before deleting cached calls
788
1037
  const oldCached = this.callsCache.get(filePath);
789
1038
  if (oldCached) {
790
- this._removeFromCalleeIndex(filePath, oldCached.calls);
1039
+ const { withMacroExpansion } = require('./macro-expansion');
1040
+ this._removeFromCalleeIndex(filePath, withMacroExpansion(this, filePath, oldCached.calls,
1041
+ { materialize: false }));
791
1042
  }
792
1043
  this.callsCache.delete(filePath);
793
1044
 
@@ -798,10 +1049,59 @@ class ProjectIndex {
798
1049
  this._javaFileIndex = null;
799
1050
  // Computed-dispatch diagnostics are project-wide and read source text.
800
1051
  this._computedDispatchBlindspots = null;
1052
+ this._reflectionPatternIndex = null;
801
1053
  // Endpoints cache is project-wide; safest to clear on any file removal.
802
1054
  this._endpointsCache = null;
803
1055
  }
804
1056
 
1057
+ /**
1058
+ * _canonicalizeOrder() after a change confined to `files` (their symbols
1059
+ * and bindings) and the symbol `names` involved (fix #375: Rust macro
1060
+ * expansion replaces a few files' declarations). Everything else is
1061
+ * already canonical, so the result equals a full pass.
1062
+ */
1063
+ _canonicalizeTouched({ files, names }) {
1064
+ this._classIdentity = null;
1065
+ this._macroExpansion = null;
1066
+ this._macroSpellable = null; // fix #385: paste-spellability memo over the same symbols
1067
+ this._rustMacroState = null;
1068
+ this._cppScope = null;
1069
+ this._contractIndex = null;
1070
+ this._typeDenotation = null; // type-name denotation memo (core/type-denotation.js)
1071
+ this._languageFeatureMemo = null; // fix #380 protocol-type feature memo
1072
+ this._rustScopeMemo = null;
1073
+ const feCmp = (a, b) => (a.startLine - b.startLine)
1074
+ || codeUnitCompare(String(a.type), String(b.type))
1075
+ || codeUnitCompare(String(a.className || ''), String(b.className || ''))
1076
+ || codeUnitCompare(String(a.name), String(b.name));
1077
+ for (const file of files) {
1078
+ const fe = this.files.get(file);
1079
+ if (Array.isArray(fe?.symbols)) fe.symbols.sort(feCmp);
1080
+ if (Array.isArray(fe?.bindings)) fe.bindings.sort(feCmp);
1081
+ }
1082
+ const defCmp = (a, b) => compareNames(a.relativePath || '', b.relativePath || '')
1083
+ || (a.startLine - b.startLine)
1084
+ || codeUnitCompare(String(a.type), String(b.type))
1085
+ || codeUnitCompare(String(a.className || ''), String(b.className || ''))
1086
+ || ((a.endLine || 0) - (b.endLine || 0));
1087
+ const present = [...names].filter(name => this.symbols.has(name)).sort();
1088
+ for (const name of present) this.symbols.get(name).sort(defCmp);
1089
+ // Untouched names are still in canonical order; merge the touched ones in.
1090
+ const merged = new Map();
1091
+ let t = 0;
1092
+ for (const [name, defs] of this.symbols) {
1093
+ if (names.has(name)) continue;
1094
+ while (t < present.length && present[t] < name) {
1095
+ merged.set(present[t], this.symbols.get(present[t]));
1096
+ t++;
1097
+ }
1098
+ merged.set(name, defs);
1099
+ }
1100
+ for (; t < present.length; t++) merged.set(present[t], this.symbols.get(present[t]));
1101
+ this.symbols = merged;
1102
+ if (this.calleeIndex) this._canonicalizeOrder();
1103
+ }
1104
+
805
1105
  /**
806
1106
  * Put every order-bearing index structure into ONE canonical order, so the
807
1107
  * same repository content produces the same index state — and therefore
@@ -822,6 +1122,16 @@ class ProjectIndex {
822
1122
  * by name with file sets in path order.
823
1123
  */
824
1124
  _canonicalizeOrder() {
1125
+ // Symbol arrays are rebuilt below; identity memos keyed on them are stale.
1126
+ this._classIdentity = null;
1127
+ this._macroExpansion = null;
1128
+ this._macroSpellable = null; // fix #385: paste-spellability memo over the same symbols
1129
+ this._rustMacroState = null;
1130
+ this._cppScope = null;
1131
+ this._contractIndex = null;
1132
+ this._typeDenotation = null; // type-name denotation memo (core/type-denotation.js)
1133
+ this._languageFeatureMemo = null; // fix #380 protocol-type feature memo
1134
+ this._rustScopeMemo = null; // fix #368: Rust glob/bound resolution memo, index-lifetime
825
1135
  // Plain code-unit comparison — canonical order must not depend on the
826
1136
  // host ICU locale (localeCompare does).
827
1137
  const cmpStr = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
@@ -943,13 +1253,15 @@ class ProjectIndex {
943
1253
  buildCalleeIndex() {
944
1254
  const { getCachedCalls } = require('./callers');
945
1255
  const { ensureCallsCacheLoaded } = require('./cache');
1256
+ const { withMacroExpansion } = require('./macro-expansion');
946
1257
  ensureCallsCacheLoaded(this);
947
1258
  this.calleeIndex = new Map();
948
1259
 
949
1260
  for (const [filePath] of this.files) {
950
1261
  // Fast path: use pre-populated callsCache (avoids stat per file)
951
1262
  const cached = this.callsCache.get(filePath);
952
- const calls = cached ? cached.calls : getCachedCalls(this, filePath);
1263
+ const calls = cached ? withMacroExpansion(this, filePath, cached.calls, { materialize: false })
1264
+ : getCachedCalls(this, filePath);
953
1265
  this._addToCalleeIndex(filePath, calls);
954
1266
  }
955
1267
  }
@@ -1018,6 +1330,13 @@ class ProjectIndex {
1018
1330
  // Prefer same-file match
1019
1331
  const match = entries.find(e => e.file === contextFile);
1020
1332
  if (match) return match.parents;
1333
+ // A file that declares the class itself without a base clause
1334
+ // names no parents (fix #390): a same-name class elsewhere
1335
+ // (another package's `ListTest extends TestCase`) is a different
1336
+ // class. Only partial types gather bases from other files, and a
1337
+ // C/C++ declaration may be a forward declaration of a class
1338
+ // defined in another file.
1339
+ if (contextFile && require('./class-identity').fileDeclaresClass(this, className, contextFile)) return null;
1021
1340
 
1022
1341
  // Try imported file
1023
1342
  if (contextFile) {
@@ -1066,6 +1385,34 @@ class ProjectIndex {
1066
1385
  * @returns {string|null} Resolved file path
1067
1386
  */
1068
1387
  _resolveClassFile(className, contextFile) {
1388
+ const memo = this._opMemo('resolveClassFile');
1389
+ if (!memo) return this._resolveClassFileUncached(className, contextFile);
1390
+ const key = `${className}\0${contextFile}`;
1391
+ let file = memo.get(key);
1392
+ if (file === undefined) {
1393
+ file = this._resolveClassFileUncached(className, contextFile);
1394
+ memo.set(key, file);
1395
+ }
1396
+ return file;
1397
+ }
1398
+
1399
+ /**
1400
+ * A memo for the current operation (fix #382), or null outside one. Only
1401
+ * for values that are pure functions of the index, which an operation
1402
+ * never changes.
1403
+ */
1404
+ _opMemo(key, make) {
1405
+ const memos = this._opMemos;
1406
+ if (!memos) return null;
1407
+ let memo = memos.get(key);
1408
+ if (!memo) {
1409
+ memo = make ? make() : new Map();
1410
+ memos.set(key, memo);
1411
+ }
1412
+ return memo;
1413
+ }
1414
+
1415
+ _resolveClassFileUncached(className, contextFile) {
1069
1416
  const symbols = this.symbols.get(className);
1070
1417
  if (!symbols) return contextFile;
1071
1418
  const classSymbols = symbols.filter(s =>
@@ -1703,28 +2050,38 @@ class ProjectIndex {
1703
2050
  return symbol.file === definition.file;
1704
2051
  };
1705
2052
 
2053
+ // A member declared in another same-name type's body (a
2054
+ // function-local class beside its module-level namesake, C#
2055
+ // `Outcome` beside `Outcome<T>`) is not this type's (fix #389).
2056
+ const { ownerDefinitionOf, classKeyOf } = require('./class-identity');
2057
+ const typeKey = definition && definition.name === baseTypeName ? classKeyOf(this, definition) : null;
2058
+ const ownedHere = symbol => {
2059
+ if (!typeKey) return true;
2060
+ const owner = ownerDefinitionOf(this, symbol);
2061
+ return !owner || classKeyOf(this, owner) === typeKey;
2062
+ };
2063
+
1706
2064
  for (const [, symbols] of this.symbols) {
1707
2065
  for (const symbol of symbols) {
1708
2066
  // Skip non-method types (fields, properties, etc.)
1709
2067
  if (symbol.type === 'field' || symbol.type === 'property') {
1710
2068
  continue;
1711
2069
  }
1712
-
1713
2070
  // Check Go/Rust-style receiver (e.g., func (r *Router) Method())
1714
2071
  // Also matches Rust associated functions (have receiver but isMethod=false)
1715
- if (symbol.receiver) {
1716
- const receiverBase = symbol.receiver.replace(/^\*/, '');
1717
- if (receiverBase === baseTypeName && sameCompilerType(symbol)) {
1718
- methods.push(symbol);
1719
- continue;
1720
- }
1721
- }
1722
-
2072
+ const receiverMatch = !!symbol.receiver &&
2073
+ symbol.receiver.replace(/^\*/, '') === baseTypeName;
1723
2074
  // Check Python/Java/JS-style className (class members)
1724
2075
  // Must be a method type, not just any symbol with className
1725
- if (symbol.className === baseTypeName &&
1726
- (symbol.isMethod || symbol.type === 'method' || symbol.type === 'constructor') &&
1727
- sameCompilerType(symbol)) {
2076
+ const classMatch = symbol.className === baseTypeName &&
2077
+ (symbol.isMethod || symbol.type === 'method' || symbol.type === 'constructor');
2078
+ // Ownership is resolved for members of the name only.
2079
+ if ((!receiverMatch && !classMatch) || !ownedHere(symbol)) continue;
2080
+ if (receiverMatch && sameCompilerType(symbol)) {
2081
+ methods.push(symbol);
2082
+ continue;
2083
+ }
2084
+ if (classMatch && sameCompilerType(symbol)) {
1728
2085
  methods.push(symbol);
1729
2086
  continue;
1730
2087
  }
@@ -1992,7 +2349,11 @@ class ProjectIndex {
1992
2349
  return false;
1993
2350
  }
1994
2351
 
1995
- const tree = safeParse(parser, content);
2352
+ // The operation's shared tree when one is active (fix #365): a
2353
+ // rename plan parses the same large files for its own edits.
2354
+ const tree = (this.files.has(filePath) &&
2355
+ this._getParsedTree(filePath, content, language)) ||
2356
+ safeParse(parser, content);
1996
2357
  if (!tree) return false;
1997
2358
  const tokenType = getTokenTypeAtPosition(tree.rootNode, lineNum, column);
1998
2359
  return tokenType === 'comment' || tokenType === 'string';
@@ -2212,6 +2573,31 @@ class ProjectIndex {
2212
2573
  return keywords ? keywords.has(name) : false;
2213
2574
  }
2214
2575
 
2576
+ /**
2577
+ * A file's symbols with this name declared on this line, in file order
2578
+ * (fix #375: generated calls look up their parser-given enclosing
2579
+ * function; a file with hundreds of generated functions made that a scan
2580
+ * per call).
2581
+ */
2582
+ _symbolsNamedAt(fileEntry, name, startLine) {
2583
+ // Keyed by the symbols array itself (replaced whenever a file's
2584
+ // symbols are rebuilt) and its length.
2585
+ const symbols = fileEntry.symbols;
2586
+ let memo = symbolsByNameAndLine.get(symbols);
2587
+ if (!memo || memo.length !== symbols.length) {
2588
+ const byName = new Map();
2589
+ for (const symbol of symbols) {
2590
+ const at = `${symbol.name}\0${symbol.startLine}`;
2591
+ const list = byName.get(at);
2592
+ if (list) list.push(symbol);
2593
+ else byName.set(at, [symbol]);
2594
+ }
2595
+ memo = { length: symbols.length, byName };
2596
+ symbolsByNameAndLine.set(symbols, memo);
2597
+ }
2598
+ return memo.byName.get(`${name}\0${startLine}`) || [];
2599
+ }
2600
+
2215
2601
  /**
2216
2602
  * Find the enclosing function at a line
2217
2603
  * @param {string} filePath - File path
@@ -2219,14 +2605,28 @@ class ProjectIndex {
2219
2605
  * @param {boolean} returnSymbol - If true, return full symbol info instead of just name
2220
2606
  * @returns {string|object|null} Function name, symbol object, or null
2221
2607
  */
2222
- findEnclosingFunction(filePath, lineNum, returnSymbol = false) {
2608
+ findEnclosingFunction(filePath, lineNum, returnSymbol = false, call = null) {
2223
2609
  const fileEntry = this.files.get(filePath);
2224
2610
  if (!fileEntry) return null;
2225
2611
 
2226
- // Per-operation cache: avoid rescanning symbols for same (file, line)
2227
- const cacheKey = filePath + '\0' + lineNum;
2612
+ // A call generated by a macro expansion (fix #374) shares its line
2613
+ // with every function that expansion generated there; the parser's
2614
+ // own enclosing function picks among them.
2615
+ const hint = call?.macroExpansion && call.enclosingFunction;
2616
+ if (hint?.name) {
2617
+ const exact = this._symbolsNamedAt(fileEntry, hint.name, hint.startLine)
2618
+ .find(symbol => !NON_CALLABLE_TYPES.has(symbol.type) &&
2619
+ symbol.startLine <= lineNum && symbol.endLine >= lineNum);
2620
+ if (exact) return returnSymbol ? exact : exact.name;
2621
+ }
2622
+
2623
+ // Per-operation cache: avoid rescanning symbols for same (file, line),
2624
+ // keyed per file then line (no composed key string per call).
2625
+ let byLine = null;
2228
2626
  if (this._opEnclosingFnCache) {
2229
- const cached = this._opEnclosingFnCache.get(cacheKey);
2627
+ byLine = this._opEnclosingFnCache.get(filePath);
2628
+ if (!byLine) this._opEnclosingFnCache.set(filePath, byLine = new Map());
2629
+ const cached = byLine.get(lineNum);
2230
2630
  if (cached !== undefined) {
2231
2631
  return cached === null ? null : (returnSymbol ? cached : cached.name);
2232
2632
  }
@@ -2243,9 +2643,7 @@ class ProjectIndex {
2243
2643
  }
2244
2644
  }
2245
2645
 
2246
- if (this._opEnclosingFnCache) {
2247
- this._opEnclosingFnCache.set(cacheKey, best);
2248
- }
2646
+ if (byLine) byLine.set(lineNum, best);
2249
2647
  return best ? (returnSymbol ? best : best.name) : null;
2250
2648
  }
2251
2649
 
@@ -2404,10 +2802,10 @@ class ProjectIndex {
2404
2802
 
2405
2803
 
2406
2804
  /** Build a usage index for all identifiers in the codebase (optimized for deadcode) */
2407
- buildUsageIndex() { return deadcodeModule.buildUsageIndex(this); }
2805
+ buildUsageIndex() { return deadcodeModule().buildUsageIndex(this); }
2408
2806
 
2409
2807
  /** Find dead code (unused functions/classes) */
2410
- deadcode(options) { return deadcodeModule.deadcode(this, options); }
2808
+ deadcode(options) { return deadcodeModule().deadcode(this, options); }
2411
2809
 
2412
2810
  /**
2413
2811
  * Get dependency graph for a file
@@ -2535,6 +2933,14 @@ class ProjectIndex {
2535
2933
  /** Save index to cache file */
2536
2934
  saveCache(cachePath) { return indexCache.saveCache(this, cachePath); }
2537
2935
 
2936
+ /**
2937
+ * Derive whole the Rust files whose macro-expansion calls were derived
2938
+ * in part (fix #405), so a saved cache holds them. Returns the count.
2939
+ */
2940
+ completeMacroCallDerivations() {
2941
+ return this._rustMacroState ? require('./rust-macro-expansion').completeRustMacroCalls(this) : 0;
2942
+ }
2943
+
2538
2944
  /** Load index from cache file */
2539
2945
  /**
2540
2946
  * Build under the cross-process build lock (fix #354) and save the cache.