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/cache.js CHANGED
@@ -10,7 +10,7 @@ const path = require('path');
10
10
  const os = require('os');
11
11
  const crypto = require('crypto');
12
12
  const {
13
- expandGlob, detectProjectPattern, parseGitignore, gitTrackedPaths, DEFAULT_IGNORES,
13
+ expandGlob, detectProjectPattern, parseGitignore, gitTrackedPaths, gitListing, DEFAULT_IGNORES,
14
14
  classifyUnsupportedSourceFile,
15
15
  } = require('./discovery');
16
16
  const { codeUnitCompare } = require('./shared');
@@ -26,9 +26,21 @@ const CACHE_PRUNE_INTERVAL_MS = 60 * 60 * 1000;
26
26
  const CACHE_MAX_PROJECTS = 128;
27
27
  const CACHE_MAX_BYTES = 1024 * 1024 * 1024;
28
28
 
29
- function discoveryRulesHash(root, patterns = null) {
30
- const rules = patterns || parseGitignore(root);
31
- return crypto.createHash('md5').update(rules.join('\0')).digest('hex');
29
+ /**
30
+ * Fingerprint of what decides the discovered file set besides the tree
31
+ * itself: the compiled .gitignore rules and whether git could list the
32
+ * tracked paths (a failed listing changes which files rules may exclude).
33
+ */
34
+ function discoveryFingerprint(rules, listing) {
35
+ return crypto.createHash('md5')
36
+ .update(rules.fingerprint())
37
+ .update(listing?.failure ? '\0git-listing-failed' : '')
38
+ .digest('hex');
39
+ }
40
+
41
+ function discoveryRulesHash(root) {
42
+ const listing = gitListing(root);
43
+ return discoveryFingerprint(parseGitignore(root, listing), listing);
32
44
  }
33
45
 
34
46
  /**
@@ -714,7 +726,206 @@ function clearAllCaches() {
714
726
  // v226: bundled/minified filename exclusions are disclosed in discoveryIssues.
715
727
  // v227: signature parameter/return text excludes AST comments in every language.
716
728
  // v228: Python keyword arguments retain the same callable-reference facts as positional arguments.
717
- const CACHE_FORMAT_VERSION = 228;
729
+ // v229 (fix #359): subscripts of declared containers type their element
730
+ // receivers (Python dict/list/Sequence, TS Array/Record/index signatures and
731
+ // `const c = xs[i]`, Java/C/C++ arrays, C# BCL indexers, C++ std containers,
732
+ // Rust Vec/HashMap/arrays).
733
+ // v230 (fix #360): Go embedded-field symbols carry `embedded`; Go files carry
734
+ // typeConversions (concrete values flowing into differently-typed slots);
735
+ // Rust `fn name(...)` inside macro token trees is no longer a call record.
736
+ // v231 (fix #361): C/C++ recovery keeps include-guarded bodies and
737
+ // access-specifier/statement macros inside class bodies; C++ macros carry
738
+ // namespaceScope, C++ files carry macroScopeMarkers and cppUsings, C++ path
739
+ // calls carry qualifierTemplateParam; angle-bracket project includes resolve
740
+ // through include directories and basename-resolved quoted includes are
741
+ // recorded in fileEntry.includeFallback.
742
+ // v232 (fix #362): C/C++ function-like macros carry their replacement-list
743
+ // template (ppParams/ppVariadic/ppBody); `#define`/`#include` directives the
744
+ // grammar places inside enumerator lists or initializers are indexed; the
745
+ // index cache carries per-file macro-expansion records (`macroExpansion`).
746
+ // v233 (fix #363): file entries carry `reflectionSites` (reflective member
747
+ // accesses: literal names, name patterns built from literal fragments,
748
+ // receiver facts), extracted from each file's own parse at index time.
749
+ // v234 (fix #364): Python functions whose own body yields and C# iterator
750
+ // methods (`yield return`) carry `isGenerator`, so audit-async classifies
751
+ // async generators as async iterators rather than coroutines.
752
+ // v235 (fix #366): JS/TS and Python call records carry `requestConfig`
753
+ // (object-literal / keyword `url`/`path` request configuration), C#
754
+ // attributes keep non-literal `args`, and Python absolute imports resolve
755
+ // against nested source roots (moduleResolved/importGraph content changed).
756
+ // v236 (fix #367): C/C++ definitions and calls inside `__cplusplus` blocks
757
+ // the file's own language skips are indexed with `languageBranch`.
758
+ // v238 (fix #369): Rust path calls carry `returnPosition` (the value the
759
+ // enclosing fn returns) and aliased `extern crate x as y;` declarations
760
+ // become import bindings.
761
+ // v239 (fix #370): Rust functions, methods and type aliases carry
762
+ // `futureReturn`; Rust qualifiers (async/unsafe/const/extern) come from the
763
+ // AST, and trait `async fn` members carry isAsync.
764
+ // v242 (fix #372): the index carries `cargoManifests` (the workspace crate
765
+ // registry's manifest snapshot, stat-validated for staleness).
766
+ // v243 (fix #372): Rust call records mark every value that flows on or is
767
+ // read from its local (`valueConsumed` 'flow'/'stored'), and a method taken
768
+ // on a stored value as `consumingMethod`; file entries carry `openCalls`
769
+ // (call shapes, replacing openCallNames); large directories' calls shards
770
+ // are split into path buckets.
771
+ // v244 (fix #374): Rust files invoking project macro_rules! carry
772
+ // `rustMacroExpansion` (expansion record, revalidated per build); their
773
+ // symbols and call records are those of the expanded file, and facts
774
+ // produced inside an invocation carry `macroExpansion`.
775
+ // v245 (fix #375): calls shards store each file's enclosing-function,
776
+ // macro-expansion and receiver-evidence objects once (`fx` / `mx` / `rx`
777
+ // tables, referenced by position).
778
+ // v246 (fix #377): Rust `mod` declarations resolve by the module layout
779
+ // rules (foo.rs + foo/ children, #[path], inline-module nesting); persisted
780
+ // moduleResolved/importGraph content changed.
781
+ // v247 (fix #378): Go generic instantiation calls (`f[T](x)`, `pkg.F[T](x)`)
782
+ // are call records, generic receivers/parameters are typed by their base
783
+ // type, Go aliases record `aliasOf` for generic bases and `aliasQualifier`
784
+ // for package-qualified ones, package-qualified function values are function
785
+ // references, and function-local Go types and JS classes carry their lexical
786
+ // scope.
787
+ // v248 (fix #379): C/C++ recovery blanks a decoration macro before a second
788
+ // type specifier and in `class EXPORT Name : bases` heads, member-list macro
789
+ // invocations carry their access effect (`accessAfterMacro`), and functions
790
+ // under a preprocessor conditional record `ppBranch`.
791
+ // v249 (fix #380): C# type definitions carry `typeArity` and their members
792
+ // `ownerTypeArity`; call records carry the written type-argument count of
793
+ // constructors (`typeArgs`), type-qualified and typed receivers
794
+ // (`receiverTypeArgs`, `receiverRootTypeArgs`); generic type receivers
795
+ // (`Outcome<int>.Create()`) are type-qualified; Java `implements` keeps
796
+ // qualified supertypes.
797
+ // v250 (fix #381): Python function-local classes carry their lexical scope,
798
+ // and Python/JS/Java/C#/Go/Rust call records on a one-hop local alias of a
799
+ // field path or typed local receive like the aliased expression.
800
+ // v251 (fix #383): route registrations carry `handlerArgs` (JS/TS, Go),
801
+ // Go method-first registrations (`Handle(method, path, h)`)
802
+ // `methodFirstRoute`, Fastify route-config objects their `handler`, Go request constructors
803
+ // `requestTarget` { method, url }, and axum `.route()` calls `methodRouter`;
804
+ // Go file entries carry `packageName` (the package clause).
805
+ // v252 (fix #384): JS/TS member-assigned functions carry `assignedObject`
806
+ // ('global' | 'object') and `selfNamed` (bare-name reachability).
807
+ // v253 (fix #385): C/C++ members inside preprocessor conditionals of a class
808
+ // body are indexed (with `ppBranch`), invocations of a file's own
809
+ // function-like macros read as declarations are recovered (members they
810
+ // declare carry `generatedByMacro`), and macro invocations read as functions
811
+ // are not indexed.
812
+ // v254 (fix #386): specs of a grouped Go `type ( ... )` declaration carry
813
+ // `nameLine` (the line naming the type).
814
+ // v255 (fix #387): C/C++ recovery blanks declaration-specifier macros, never
815
+ // the type, scope or keyword next to them (return types, members and line
816
+ // numbers of recovered declarations changed); C/C++ file entries carry
817
+ // `recoveryBlanks` (the byte ranges the recovery blanked).
818
+ // v256 (fix #389): Java annotation types (`@interface`, `annotationType`) with
819
+ // their elements and interface constants, Java type `nameLine`, Rust unions,
820
+ // struct/variant `valueShape` ('unit' | 'tuple'), C/C++ function-local class
821
+ // scopes, file-level `moduleValueAliases` (Python/JS/TS), Rust call records'
822
+ // `receiverValuePath` for unbound identifiers / struct expressions / one-hop
823
+ // locals (`receiverValueKind`), Java annotation element call records
824
+ // (`annotationElement`, `implicitName`), C# typeof reflection `arity`.
825
+ // v257 (fix #390): C# declarations carry `nameLine` when attributes start
826
+ // them and `generics` (type parameter names) on generic types, methods and
827
+ // local functions; C++ class templates carry `generics`; Java fields and
828
+ // TS/JS decorated fields carry `nameLine`; Java/C# call records read a `+`
829
+ // with a string operand as a `string` argument.
830
+ // v259 (fix #391): C++ friend functions are namespace-scope functions
831
+ // (`friendOf` names the befriending class) instead of members, and member
832
+ // templates carry their return type and `templateDependent`; namespace-scope
833
+ // macro invocations followed by a body are generated callables
834
+ // (`generatedByMacro`); C# files whose `#if` splits a declaration are read
835
+ // through configuration views, recorded per file as `conditionalViews`;
836
+ // C# explicit interface properties, indexers and events carry
837
+ // `explicitInterface`; large C/C++ files resolve the conditionals the
838
+ // grammar could not place; C#/Java call records carry explicit method type
839
+ // arguments (`methodTypeArgs`) and lambda parameter counts (`lambda:N`), and
840
+ // C# argument kinds type `params` parameters, fields of the enclosing type
841
+ // and array creations by rank.
842
+ // v260 (fix #392): Rust top-level const/static items carry their declared
843
+ // type (`valueType`); Python module aliases bound on several module-level
844
+ // paths carry `alternatives`, JS/TS `let`/`var` aliases never reassigned are
845
+ // aliases; Java enums record `implements` and their methods' `nameLine` /
846
+ // annotations; Rust call records on locals destructured from `self` carry
847
+ // the field-hop receiver; Python usage records classify default values as
848
+ // references and bare/lambda parameters as definitions (plan's usage mode
849
+ // adds each bare reference's lexical scope verdict).
850
+ // v261 (fix #393): C/C++ unnamed parameters carry `unnamed` with their type
851
+ // as the name; C++ `v->m()` call records carry `receiverArrow` (and the
852
+ // declared object type `receiverArrowObject` when `v` is an object); C++
853
+ // function templates carry `templateParams`, member templates with a body
854
+ // are definitions, members in skipped conditional branches are indexed,
855
+ // namespaces left as ERROR tokens or closed early by the parse scope their
856
+ // declarations, large files resolve brace-splitting conditionals, pointer
857
+ // arguments keep their pointer level in `type:` argument kinds; C#
858
+ // delegates carry `delegateParams` and target-typed `new(..)` records a
859
+ // constructor call (`targetTyped`).
860
+ // v262 (fix #394): Java argument kinds keep array dimensions
861
+ // (`type:java.lang.String[]`, `new:..[]`, `cast:..[]`) and receivers
862
+ // declared with a package-qualified type carry the package as
863
+ // `receiverTypeQualifier`.
864
+ // v263 (fix #395): C# declarations carry their full namespace (every
865
+ // enclosing `namespace` block), C# using directives record the namespace
866
+ // they sit in, `using static` and `global using`.
867
+ // v264 (fix #396): C/C++ anonymous typedef'd specifiers record
868
+ // `typedefName`, nested classes (in-class and `struct Outer::Inner`)
869
+ // `enclosingType`, generated callables their identifier arguments
870
+ // (`generatedByMacro.args`) and pasted member-list names `unspelled`; calls
871
+ // on `#define` continuation lines are replacement-list calls; recovery blanks
872
+ // decoration macros before parenthesized names and return-type macro
873
+ // invocations; files record the project macro names their recovery used
874
+ // (`externalMacroNames`, `externalMacroKey`, `recoveryCandidates`).
875
+ // v265 (fix #396): a replacement-list receiver declared with a macro
876
+ // parameter as its type carries no receiver type.
877
+ // v266 (fix #396): a class body the grammar closed early is never read as
878
+ // the return type of the next member (no decoration blank there).
879
+ // v267 (fix #397): JS/TS bare records of names an object pattern binds carry
880
+ // `destructured` (property key, position, source receiver facts); local
881
+ // shadows carry the shadowing declaration's line as `localShadow` (-1 for a
882
+ // parameter) and plain calls carry them too; stored methods record `memberValue` references;
883
+ // `require('m').y` binds y under the local name; object-literal members
884
+ // carry `objectLiteralLine` (accessors their get/set kind); usages mark
885
+ // shorthand properties and import aliases; the index persists the
886
+ // in-repository JS packages (`jsWorkspacePackages`); JS call records carry
887
+ // `uncertain` only when true.
888
+ // v268 (fix #398): repeated import source names retain every local binding;
889
+ // an alias record applies to one specifier, not all same-name specifiers.
890
+ // v269 (fix #398F): Python files record `configurationItems` (names bound in
891
+ // one scope only in exclusive `if`/`try`/`match` branches).
892
+ // v270 (fix #399): Go files record `packageVars` (package-level variables
893
+ // with their declared static types) and their `buildConstraint`; Go method
894
+ // calls on a receiver no scope of the file binds carry `receiverPackageScope`.
895
+ // v271 (fix #400): Go imports that write no local name are marked
896
+ // `implicitName` (a project import's local name is its package clause) and
897
+ // carry the name the Go tools suggest (`go-json` is `json`, `core/v1` `v1`);
898
+ // `packageVars` also record constants and `valueFrom` initializers, and
899
+ // untyped package-level variables of the file carry `receiverPackageScope`.
900
+ // C# target-typed `new(..)` arguments and member assignments are `new`
901
+ // records with a `targetSlot` (files list their `targetTypedKeys`), C# types
902
+ // record `primaryParams`; Python bound-method reads assigned to a name
903
+ // (`loads = registry.loads`) are method-value records.
904
+ // v272 (fix #401): Rust receivers typed through std deref wrappers carry the
905
+ // target type with the wrapper chain on their evidence (`derefVia`), statics
906
+ // record `valueDerefVia`, array literal receivers are 'array'; C++ subscript
907
+ // receivers of class objects record `receiverSubscriptObject`, macro-body
908
+ // receivers declared with a macro parameter `receiverMacroParam`, and
909
+ // `T v(X());` initializer calls are `declarationReading` records; C#
910
+ // target-typed `new(..)` collection elements are typed, argument slots of
911
+ // generic receivers carry `receiverTypeText` (a `~` targetTypedKeys entry);
912
+ // Java `super` calls in enum-constant bodies carry `receiverSuperType`.
913
+ // v273 (fix #402): Python defs under compound statements of a class body
914
+ // are methods of the class and `Union[X, None]` annotations name X; JS/TS
915
+ // anonymous default exports and member-assigned functions record `isAsync`,
916
+ // a default import beside named or namespace imports is its own `default`
917
+ // import record; JS/TS and Python member-call records mark `awaited` and
918
+ // `passedAsArgument`.
919
+ // v274 (fix #403): a Python reference inside a lambda whose enclosing def
920
+ // binds the name records that def as its `localShadowScope`.
921
+ // v275 (fix #405): a Rust file whose macro-expansion calls are pending
922
+ // records every expansion's hygiene start and expanded length
923
+ // (`rustMacroExpansion.expansions`); its `words` leave out the text of
924
+ // literals and comments.
925
+ // v276 (fix #405b): a pending Rust file whose expansions the calls pass
926
+ // reads by groups records each group's lines, expanded length and the words
927
+ // its expansions output and its headers hold (`rustMacroExpansion.groups`).
928
+ const CACHE_FORMAT_VERSION = 276;
718
929
  const USAGE_CACHE_FILE = 'usage-results.json';
719
930
 
720
931
  /**
@@ -815,21 +1026,25 @@ function saveCache(index, cachePath) {
815
1026
 
816
1027
  const cacheFile = cachePath || path.join(cacheDir, 'index.json');
817
1028
 
1029
+ // A partially loaded (per-file) calls cache must be complete before it
1030
+ // is rewritten: the shard directory is replaced wholesale below.
1031
+ if (index._callsCachePrepared && !index._callsCacheLoaded) {
1032
+ ensureCallsCacheLoaded(index);
1033
+ }
1034
+
818
1035
  // Prepare callsCache for serialization (exclude content, use relative paths)
819
1036
  const callsCacheData = [];
820
1037
  for (const [filePath, entry] of index.callsCache) {
821
- callsCacheData.push([path.relative(index.root, filePath), {
822
- mtime: entry.mtime,
823
- hash: entry.hash,
824
- calls: entry.calls
825
- // content is not persisted - will be read on demand
826
- }]);
1038
+ callsCacheData.push([path.relative(index.root, filePath), entry]);
827
1039
  }
828
1040
 
829
1041
  // Hash config to detect when graph rebuild is needed on load
830
1042
  const configHash = crypto.createHash('md5')
831
1043
  .update(JSON.stringify(index.config || {})).digest('hex');
832
- const discoveryHash = discoveryRulesHash(index.root);
1044
+ // The rules the index was discovered with (fix #382: re-asking git at
1045
+ // save time cost a second listing and could describe newer rules).
1046
+ const discoveryHash = index._discoveryHash || index._loadedDiscoveryHash ||
1047
+ discoveryRulesHash(index.root);
833
1048
 
834
1049
  // Strip redundant fields from symbols and file entries to reduce cache size.
835
1050
  // v6: All paths stored as relative paths (saves ~60% on large codebases).
@@ -938,6 +1153,20 @@ function saveCache(index, cachePath) {
938
1153
  unsupportedFiles: Array.isArray(index.unsupportedFiles)
939
1154
  ? index.unsupportedFiles
940
1155
  : [],
1156
+ ...(Array.isArray(index.cargoManifests) && {
1157
+ cargoManifests: index.cargoManifests,
1158
+ cargoManifestsSeen: index.cargoManifestsSeen || null,
1159
+ }),
1160
+ // In-repository JS packages (fix #397), so a cache-loaded query
1161
+ // never re-reads the manifests.
1162
+ // Names and directories only; a manifest is read again when an
1163
+ // import resolves through it.
1164
+ ...(index._jsWorkspacePackages !== undefined && {
1165
+ jsWorkspacePackages: index._jsWorkspacePackages
1166
+ ? [...index._jsWorkspacePackages].map(([name, pkg]) =>
1167
+ [name, pkg.ambiguous ? null : path.relative(root, pkg.dir)])
1168
+ : null,
1169
+ }),
941
1170
  includeBundled: index.includeBundled === true,
942
1171
  discoveryIssues: Array.isArray(index.discoveryIssues)
943
1172
  ? index.discoveryIssues
@@ -947,6 +1176,12 @@ function saveCache(index, cachePath) {
947
1176
  reachableSymbols: reachableSymbolsRel,
948
1177
  reachableFingerprint,
949
1178
  }),
1179
+ // C/C++ macro-expansion records (fix #362), revalidated per file on use.
1180
+ ...(() => {
1181
+ const { persistableMacroExpansion } = require('./macro-expansion');
1182
+ const macroExpansion = persistableMacroExpansion(index);
1183
+ return macroExpansion ? { macroExpansion } : {};
1184
+ })(),
950
1185
  ...(index._computedDispatchBlindspots instanceof Map && {
951
1186
  computedDispatchBlindspots: [...index._computedDispatchBlindspots]
952
1187
  .map(([filePath, sites]) => [path.relative(root, filePath), sites]),
@@ -966,6 +1201,7 @@ function saveCache(index, cachePath) {
966
1201
  index.reachabilityDirty = false;
967
1202
  }
968
1203
  index.computedDispatchDirty = false;
1204
+ index.macroExpansionDirty = false;
969
1205
 
970
1206
  // Save callsCache sharded by directory for lazy loading.
971
1207
  // Write to a temp directory first, then atomic swap to avoid data loss on crash.
@@ -988,13 +1224,26 @@ function saveCache(index, cachePath) {
988
1224
  shards.get(dir).push([relPath, entry]);
989
1225
  }
990
1226
 
991
- // Write all shards to temp directory
1227
+ // Write all shards to temp directory. A large directory is split
1228
+ // into buckets by file path (fix #372), so a per-file consumer loads
1229
+ // roughly that file's records instead of its whole directory.
992
1230
  const shardManifest = [];
993
1231
  for (const [dir, entries] of shards) {
994
1232
  const hash = crypto.createHash('md5').update(dir).digest('hex').slice(0, 10);
995
- const shardFile = path.join(callsTmpDir, `${hash}.json`);
996
- fs.writeFileSync(shardFile, JSON.stringify(entries));
997
- shardManifest.push([dir, hash, entries.length]);
1233
+ const serialized = entries.map(([relPath, entry]) => serializeCallsEntry(relPath, entry));
1234
+ const bytes = serialized.reduce((total, text) => total + text.length + 1, 0);
1235
+ const buckets = Math.max(1, Math.min(entries.length, Math.ceil(bytes / CALLS_SHARD_TARGET_BYTES)));
1236
+ if (buckets === 1) {
1237
+ fs.writeFileSync(path.join(callsTmpDir, `${hash}.json`), `[${serialized.join(',')}]`);
1238
+ shardManifest.push([dir, hash, entries.length]);
1239
+ continue;
1240
+ }
1241
+ const parts = Array.from({ length: buckets }, () => []);
1242
+ entries.forEach(([relPath], i) => parts[callsShardBucket(relPath, buckets)].push(serialized[i]));
1243
+ parts.forEach((part, k) => {
1244
+ fs.writeFileSync(path.join(callsTmpDir, `${hash}.${k}.json`), `[${part.join(',')}]`);
1245
+ });
1246
+ shardManifest.push([dir, hash, entries.length, buckets]);
998
1247
  }
999
1248
 
1000
1249
  // Write manifest to temp directory
@@ -1084,6 +1333,14 @@ function loadCache(index, cachePath) {
1084
1333
  index._groundSetCacheLines = 0;
1085
1334
  index._nameBindingReachCache = new Map();
1086
1335
  index._returnTypeFlowCache = new Map();
1336
+ index._classIdentity = null;
1337
+ index._macroExpansion = null;
1338
+ index._macroSpellable = null; // fix #385: paste-spellability memo over the same symbols
1339
+ index._rustMacroState = null;
1340
+ index._cppScope = null;
1341
+ index._contractIndex = null;
1342
+ index._typeDenotation = null;
1343
+ index._languageFeatureMemo = null; // fix #380 protocol-type feature memo
1087
1344
 
1088
1345
  // Reconstruct files Map: relative key → absolute key, restore path and relativePath
1089
1346
  // Initialize symbols/bindings arrays (will be populated from top-level symbols)
@@ -1168,6 +1425,22 @@ function loadCache(index, cachePath) {
1168
1425
  index.unsupportedFiles = Array.isArray(cacheData.unsupportedFiles)
1169
1426
  ? cacheData.unsupportedFiles
1170
1427
  : [];
1428
+ // Cargo manifest snapshot (fix #372): seeds the workspace crate
1429
+ // registry so queries never walk the tree for it.
1430
+ index.cargoManifests = Array.isArray(cacheData.cargoManifests)
1431
+ ? cacheData.cargoManifests
1432
+ : null;
1433
+ index.cargoManifestsSeen = cacheData.cargoManifestsSeen || null;
1434
+ if (index.cargoManifests) {
1435
+ require('./imports').seedWorkspaceManifests(index.root, index.cargoManifests);
1436
+ }
1437
+ if (cacheData.jsWorkspacePackages !== undefined) {
1438
+ index._jsWorkspacePackages = Array.isArray(cacheData.jsWorkspacePackages)
1439
+ ? new Map(cacheData.jsWorkspacePackages.map(([name, dir]) => [name, typeof dir === 'string'
1440
+ ? { name, dir: path.join(index.root, dir), workspace: true, manifestPending: true }
1441
+ : { ambiguous: true }]))
1442
+ : null;
1443
+ }
1171
1444
  index.includeBundled = cacheData.includeBundled === true;
1172
1445
  index.discoveryIssues = Array.isArray(cacheData.discoveryIssues)
1173
1446
  ? cacheData.discoveryIssues
@@ -1175,6 +1448,11 @@ function loadCache(index, cachePath) {
1175
1448
  index.truncated = cacheData.truncated || null;
1176
1449
  index._loadedConfigHash = cacheData.configHash || null;
1177
1450
  index._loadedDiscoveryHash = cacheData.discoveryHash || null;
1451
+ index._discoveryHash = null;
1452
+ index._macroExpansionPersisted = cacheData.macroExpansion &&
1453
+ typeof cacheData.macroExpansion.signature === 'string'
1454
+ ? cacheData.macroExpansion : null;
1455
+ index.macroExpansionDirty = false;
1178
1456
  if (Array.isArray(cacheData.computedDispatchBlindspots)) {
1179
1457
  index._computedDispatchBlindspots = new Map(
1180
1458
  cacheData.computedDispatchBlindspots.map(([relPath, sites]) => [
@@ -1184,6 +1462,7 @@ function loadCache(index, cachePath) {
1184
1462
  );
1185
1463
  index.computedDispatchDirty = false;
1186
1464
  }
1465
+ index._reflectionPatternIndex = null;
1187
1466
 
1188
1467
  // Restore calleeIndex if persisted (v7 caches only; v8+ rebuilds lazily)
1189
1468
  if (Array.isArray(cacheData.calleeIndex)) {
@@ -1253,11 +1532,13 @@ function isCacheStale(index) {
1253
1532
  // new-file walk below. On a fresh cache these used to run two identical
1254
1533
  // `git ls-files` subprocesses per one-shot CLI invocation, accounting for
1255
1534
  // roughly a third of warm-start staleness time on measured repositories.
1256
- let gitignorePatterns = null;
1535
+ let gitignoreRules = null;
1536
+ // One git subprocess answers both questions discovery asks (fix #375).
1537
+ let listing;
1538
+ const gitListingOnce = () => (listing === undefined ? (listing = gitListing(index.root)) : listing);
1257
1539
  if (index._loadedDiscoveryHash) {
1258
- gitignorePatterns = parseGitignore(index.root);
1259
- if (discoveryRulesHash(index.root, gitignorePatterns) !==
1260
- index._loadedDiscoveryHash) {
1540
+ gitignoreRules = parseGitignore(index.root, gitListingOnce());
1541
+ if (discoveryFingerprint(gitignoreRules, listing) !== index._loadedDiscoveryHash) {
1261
1542
  return true;
1262
1543
  }
1263
1544
  }
@@ -1296,6 +1577,12 @@ function isCacheStale(index) {
1296
1577
  }
1297
1578
  }
1298
1579
 
1580
+ // Cargo manifests steer Rust import resolution (fix #372).
1581
+ if (index.cargoManifests &&
1582
+ !require('./imports').workspaceManifestsCurrent(index.root, index.cargoManifests)) {
1583
+ return true;
1584
+ }
1585
+
1299
1586
  // Ultra-fast skip for the SLOW path only: last confirmed-fresh < 2s ago
1300
1587
  // (covers MCP burst calls). Uses _lastFreshAt (set at the end of a
1301
1588
  // successful full check), never the cache save timestamp.
@@ -1308,6 +1595,7 @@ function isCacheStale(index) {
1308
1595
  const pattern = detectProjectPattern(index.root);
1309
1596
  const currentUnsupported = [];
1310
1597
  const currentBundled = [];
1598
+ const currentManifests = [];
1311
1599
  const globOpts = {
1312
1600
  root: index.root,
1313
1601
  includeBundled: index.includeBundled === true,
@@ -1315,6 +1603,9 @@ function isCacheStale(index) {
1315
1603
  if (issue.reason === 'bundled') currentBundled.push(path.relative(index.root, issue.path));
1316
1604
  },
1317
1605
  onSkippedFile: (filePath) => {
1606
+ if (path.basename(filePath) === 'Cargo.toml') {
1607
+ currentManifests.push(path.relative(index.root, filePath));
1608
+ }
1318
1609
  const kind = classifyUnsupportedSourceFile(filePath);
1319
1610
  if (!kind) return;
1320
1611
  currentUnsupported.push({
@@ -1323,9 +1614,9 @@ function isCacheStale(index) {
1323
1614
  });
1324
1615
  },
1325
1616
  };
1326
- if (!gitignorePatterns) gitignorePatterns = parseGitignore(index.root);
1327
- globOpts.gitignorePatterns = gitignorePatterns;
1328
- globOpts.trackedPaths = gitTrackedPaths(index.root);
1617
+ if (!gitignoreRules) gitignoreRules = parseGitignore(index.root, gitListingOnce());
1618
+ globOpts.gitignoreRules = gitignoreRules;
1619
+ globOpts.trackedPaths = gitTrackedPaths(index.root, gitListingOnce());
1329
1620
  const configExclude = index.config.exclude || [];
1330
1621
  if (configExclude.length > 0) {
1331
1622
  globOpts.ignores = [...DEFAULT_IGNORES, ...configExclude];
@@ -1363,6 +1654,12 @@ function isCacheStale(index) {
1363
1654
  return true;
1364
1655
  }
1365
1656
 
1657
+ if (index.cargoManifests &&
1658
+ !require('./imports').workspaceManifestsCurrent(index.root, index.cargoManifests,
1659
+ currentManifests, index.cargoManifestsSeen)) {
1660
+ return true;
1661
+ }
1662
+
1366
1663
  // Record when we last confirmed the cache is fresh (enables 2s skip on burst calls)
1367
1664
  index._lastFreshAt = Date.now();
1368
1665
  return false;
@@ -1389,8 +1686,11 @@ function _prepareCallsCache(index, cacheFile) {
1389
1686
  try {
1390
1687
  const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf-8'));
1391
1688
  index._callsManifest = new Map();
1392
- for (const [dir, hash, count] of manifest) {
1393
- index._callsManifest.set(dir, { hash, count, loaded: false });
1689
+ for (const [dir, hash, count, buckets] of manifest) {
1690
+ index._callsManifest.set(dir, {
1691
+ hash, count, buckets: buckets || 1,
1692
+ loaded: false, loadedBuckets: buckets > 1 ? new Set() : null,
1693
+ });
1394
1694
  }
1395
1695
  index._callsCachePrepared = true;
1396
1696
  return;
@@ -1418,10 +1718,19 @@ function loadCallsCache(index) {
1418
1718
  if (index._callsCacheLoaded) return index.callsCache.size > 0;
1419
1719
  index._callsCacheLoaded = true;
1420
1720
 
1421
- // If manifest was prepared lazily, load all shards now
1721
+ // If manifest was prepared lazily, load all shards now (a shard already
1722
+ // loaded for a single file is not parsed twice).
1422
1723
  if (index._callsManifest) {
1423
- for (const [, { hash }] of index._callsManifest) {
1424
- _loadCallsShard(index, hash);
1724
+ for (const [, shard] of index._callsManifest) {
1725
+ if (shard.loaded) continue;
1726
+ shard.loaded = true;
1727
+ if (shard.buckets > 1) {
1728
+ for (let k = 0; k < shard.buckets; k++) {
1729
+ if (!shard.loadedBuckets.has(k)) _loadCallsShard(index, `${shard.hash}.${k}`);
1730
+ }
1731
+ } else {
1732
+ _loadCallsShard(index, shard.hash);
1733
+ }
1425
1734
  }
1426
1735
  return index.callsCache.size > 0;
1427
1736
  }
@@ -1438,7 +1747,7 @@ function loadCallsCache(index) {
1438
1747
  if (!relPath || !entry) continue;
1439
1748
  const absPath = path.isAbsolute(relPath) ? relPath : path.join(index.root, relPath);
1440
1749
  if (!index.callsCache.has(absPath)) {
1441
- index.callsCache.set(absPath, entry);
1750
+ index.callsCache.set(absPath, decodeCallsEntry(entry));
1442
1751
  }
1443
1752
  }
1444
1753
  return index.callsCache.size > 0;
@@ -1460,6 +1769,194 @@ function ensureCallsCacheLoaded(index) {
1460
1769
  }
1461
1770
  }
1462
1771
 
1772
+ /**
1773
+ * Make one file's persisted call records available without loading every
1774
+ * shard (fix #365): a per-file consumer (audit-async's prefilter, single-file
1775
+ * queries) used to pay for parsing the whole sharded calls cache. Shards are
1776
+ * per directory, as saveCache writes them; whole-cache consumers still call
1777
+ * ensureCallsCacheLoaded, which loads the remaining shards.
1778
+ * @param {object} index - ProjectIndex instance
1779
+ * @param {string} filePath - Absolute file path
1780
+ */
1781
+ function ensureCallsShardForFile(index, filePath) {
1782
+ if (!index._callsCachePrepared || index._callsCacheLoaded) return;
1783
+ if (!index._callsManifest) {
1784
+ loadCallsCache(index);
1785
+ return;
1786
+ }
1787
+ const relPath = path.relative(index.root, filePath);
1788
+ const shard = index._callsManifest.get(path.dirname(relPath) || '.');
1789
+ if (!shard || shard.loaded) return;
1790
+ if (shard.buckets > 1) {
1791
+ const bucket = callsShardBucket(relPath, shard.buckets);
1792
+ if (shard.loadedBuckets.has(bucket)) return;
1793
+ shard.loadedBuckets.add(bucket);
1794
+ if (shard.loadedBuckets.size === shard.buckets) shard.loaded = true;
1795
+ _loadCallsShard(index, `${shard.hash}.${bucket}`);
1796
+ return;
1797
+ }
1798
+ shard.loaded = true;
1799
+ _loadCallsShard(index, shard.hash);
1800
+ }
1801
+
1802
+ // Calls shards are split into buckets of about this many serialized bytes
1803
+ // (fix #372); small directories keep one shard.
1804
+ const CALLS_SHARD_TARGET_BYTES = 64 * 1024;
1805
+
1806
+ /** Stable bucket of a project-relative path among `buckets` (FNV-1a). */
1807
+ function callsShardBucket(relPath, buckets) {
1808
+ const key = String(relPath).split(path.sep).join('/');
1809
+ let h = 0x811c9dc5;
1810
+ for (let i = 0; i < key.length; i++) {
1811
+ h ^= key.charCodeAt(i);
1812
+ h = Math.imul(h, 0x01000193) >>> 0;
1813
+ }
1814
+ return h % buckets;
1815
+ }
1816
+
1817
+ /**
1818
+ * Record fields whose objects repeat within a file (the records of one
1819
+ * function share their `enclosingFunction`, those of one macro invocation
1820
+ * their `macroExpansion`, those of one typed local its receiver evidence),
1821
+ * persisted once per file in a table and referenced by position (fix #375:
1822
+ * they were a third of a large project's calls cache).
1823
+ */
1824
+ const CALL_OBJECT_TABLES = [
1825
+ ['enclosingFunction', 'fx'],
1826
+ ['macroExpansion', 'mx'],
1827
+ ['receiverTypeEvidence', 'rx'],
1828
+ ['destructured', 'dx'],
1829
+ ];
1830
+
1831
+ /**
1832
+ * JSON text of `[relPath, entry]` for a calls shard, the repeated record
1833
+ * objects tabled (CALL_OBJECT_TABLES). The records are swapped in place
1834
+ * while serializing and restored.
1835
+ */
1836
+ function serializeCallsEntry(relPath, entry) {
1837
+ const calls = entry.calls;
1838
+ const out = {
1839
+ mtime: entry.mtime,
1840
+ hash: entry.hash,
1841
+ calls,
1842
+ // content is not persisted - will be read on demand
1843
+ };
1844
+ if (!Array.isArray(calls)) return JSON.stringify([relPath, out]);
1845
+ const tables = CALL_OBJECT_TABLES.map(() => new SlotTable());
1846
+ const swapped = [];
1847
+ try {
1848
+ for (const call of calls) {
1849
+ for (let t = 0; t < CALL_OBJECT_TABLES.length; t++) {
1850
+ const field = CALL_OBJECT_TABLES[t][0];
1851
+ const value = call[field];
1852
+ if (value === null || typeof value !== 'object') continue;
1853
+ swapped.push(call, field, value);
1854
+ call[field] = tables[t].slot(value);
1855
+ }
1856
+ }
1857
+ CALL_OBJECT_TABLES.forEach(([, key], t) => {
1858
+ if (tables[t].values.length > 0) out[key] = tables[t].values;
1859
+ });
1860
+ return JSON.stringify([relPath, out]);
1861
+ } finally {
1862
+ for (let i = 0; i < swapped.length; i += 3) swapped[i][swapped[i + 1]] = swapped[i + 2];
1863
+ }
1864
+ }
1865
+
1866
+ /**
1867
+ * Distinct JSON values in first-seen order. Equal values (same keys in the
1868
+ * same order, same values) share a slot. Candidates are bucketed by name and
1869
+ * lines; a crowded bucket (minified code puts every function on one line)
1870
+ * switches to serialized keys. Consecutive records usually repeat the
1871
+ * previous value, which is checked first.
1872
+ */
1873
+ class SlotTable {
1874
+ constructor() {
1875
+ this.values = [];
1876
+ this.buckets = new Map();
1877
+ this.last = undefined;
1878
+ this.lastKey = -1;
1879
+ }
1880
+
1881
+ slot(value) {
1882
+ if (this.last !== undefined && (value === this.last || sameJsonValue(this.last, value))) {
1883
+ this.last = value;
1884
+ return this.lastKey;
1885
+ }
1886
+ const key = this._find(value);
1887
+ this.last = value;
1888
+ this.lastKey = key;
1889
+ return key;
1890
+ }
1891
+
1892
+ _find(value) {
1893
+ const values = this.values;
1894
+ const probe = `${value.name ?? value.macro}\0${value.startLine ?? value.line}\0${value.endLine}`;
1895
+ let bucket = this.buckets.get(probe);
1896
+ if (!bucket) this.buckets.set(probe, bucket = { keys: [], byText: null });
1897
+ if (bucket.byText) {
1898
+ const text = JSON.stringify(value);
1899
+ let key = bucket.byText.get(text);
1900
+ if (key === undefined) {
1901
+ key = values.length;
1902
+ values.push(value);
1903
+ bucket.byText.set(text, key);
1904
+ }
1905
+ return key;
1906
+ }
1907
+ for (const key of bucket.keys) {
1908
+ if (sameJsonValue(values[key], value)) return key;
1909
+ }
1910
+ const key = values.length;
1911
+ values.push(value);
1912
+ bucket.keys.push(key);
1913
+ if (bucket.keys.length >= 32) {
1914
+ bucket.byText = new Map(bucket.keys.map(k => [JSON.stringify(values[k]), k]));
1915
+ }
1916
+ return key;
1917
+ }
1918
+ }
1919
+
1920
+ /** Whether two JSON values serialize identically (key order included). */
1921
+ function sameJsonValue(a, b) {
1922
+ if (a === b) return true;
1923
+ if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;
1924
+ if (Array.isArray(a)) {
1925
+ if (!Array.isArray(b) || a.length !== b.length) return false;
1926
+ for (let i = 0; i < a.length; i++) {
1927
+ if (!sameJsonValue(a[i], b[i])) return false;
1928
+ }
1929
+ return true;
1930
+ }
1931
+ if (Array.isArray(b)) return false;
1932
+ const keysA = Object.keys(a);
1933
+ const keysB = Object.keys(b);
1934
+ if (keysA.length !== keysB.length) return false;
1935
+ for (let i = 0; i < keysA.length; i++) {
1936
+ const key = keysA[i];
1937
+ if (key !== keysB[i] || !sameJsonValue(a[key], b[key])) return false;
1938
+ }
1939
+ return true;
1940
+ }
1941
+
1942
+ /** In-memory form of a persisted calls-cache entry (see serializeCallsEntry). */
1943
+ function decodeCallsEntry(entry) {
1944
+ let tabled = null;
1945
+ for (const [field, key] of CALL_OBJECT_TABLES) {
1946
+ if (!entry[key]) continue;
1947
+ (tabled ||= []).push(field, entry[key]);
1948
+ delete entry[key];
1949
+ }
1950
+ if (!tabled) return entry;
1951
+ for (const call of entry.calls || []) {
1952
+ for (let i = 0; i < tabled.length; i += 2) {
1953
+ const at = call[tabled[i]];
1954
+ if (typeof at === 'number') call[tabled[i]] = tabled[i + 1][at];
1955
+ }
1956
+ }
1957
+ return entry;
1958
+ }
1959
+
1463
1960
  /**
1464
1961
  * Load a single calls shard by hash.
1465
1962
  * @param {object} index - ProjectIndex instance
@@ -1483,7 +1980,7 @@ function _loadCallsShard(index, hash) {
1483
1980
  // parse of current disk content (or an earlier load) — never
1484
1981
  // clobber it with persisted shard data (fix #227).
1485
1982
  if (!index.callsCache.has(absPath)) {
1486
- index.callsCache.set(absPath, entry);
1983
+ index.callsCache.set(absPath, decodeCallsEntry(entry));
1487
1984
  }
1488
1985
  }
1489
1986
  } catch (e) {
@@ -1617,8 +2114,9 @@ function buildWithLock(index, buildOpts = {}, options = {}) {
1617
2114
  module.exports = {
1618
2115
  buildWithLock,
1619
2116
  saveCache, saveUsageCache, loadCache, loadCallsCache, isCacheStale, ensureCallsCacheLoaded,
2117
+ ensureCallsShardForFile,
1620
2118
  getUserCacheRoot, getProjectCacheDir, getProjectCachePath,
1621
2119
  getLegacyProjectCacheDir, migrateLegacyProjectCache, clearProjectCache,
1622
2120
  clearAllCaches, pruneUserCache,
1623
- _computeReachabilityFingerprint, CACHE_FORMAT_VERSION,
2121
+ _computeReachabilityFingerprint, CACHE_FORMAT_VERSION, discoveryFingerprint,
1624
2122
  };