codedeep-mcp 0.1.0

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 (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +177 -0
  3. package/dist/config.js +223 -0
  4. package/dist/git/analyzer.js +177 -0
  5. package/dist/git/git-service.js +568 -0
  6. package/dist/git/head-watcher.js +113 -0
  7. package/dist/git/runner.js +204 -0
  8. package/dist/index.js +138 -0
  9. package/dist/indexer/code-index.js +1801 -0
  10. package/dist/indexer/complexity.js +633 -0
  11. package/dist/indexer/extractor.js +354 -0
  12. package/dist/indexer/languages/cpp.js +934 -0
  13. package/dist/indexer/languages/csharp.js +854 -0
  14. package/dist/indexer/languages/dart.js +777 -0
  15. package/dist/indexer/languages/go.js +665 -0
  16. package/dist/indexer/languages/java.js +507 -0
  17. package/dist/indexer/languages/kotlin.js +709 -0
  18. package/dist/indexer/languages/objc.js +397 -0
  19. package/dist/indexer/languages/php.js +771 -0
  20. package/dist/indexer/languages/python.js +455 -0
  21. package/dist/indexer/languages/ruby.js +697 -0
  22. package/dist/indexer/languages/rust.js +754 -0
  23. package/dist/indexer/languages/swift.js +691 -0
  24. package/dist/indexer/languages/typescript.js +485 -0
  25. package/dist/indexer/parser.js +175 -0
  26. package/dist/indexer/pipeline.js +342 -0
  27. package/dist/indexer/scanner.js +279 -0
  28. package/dist/indexer/watcher.js +353 -0
  29. package/dist/logger.js +16 -0
  30. package/dist/server.js +170 -0
  31. package/dist/tools/common.js +207 -0
  32. package/dist/tools/find-references.js +224 -0
  33. package/dist/tools/find-symbol.js +94 -0
  34. package/dist/tools/get-context.js +370 -0
  35. package/dist/tools/impact.js +218 -0
  36. package/dist/tools/overview.js +482 -0
  37. package/dist/tools/search-structure.js +303 -0
  38. package/dist/types.js +61 -0
  39. package/grammars/tree-sitter-c.wasm +0 -0
  40. package/grammars/tree-sitter-c_sharp.wasm +0 -0
  41. package/grammars/tree-sitter-cpp.wasm +0 -0
  42. package/grammars/tree-sitter-dart.wasm +0 -0
  43. package/grammars/tree-sitter-go.wasm +0 -0
  44. package/grammars/tree-sitter-java.wasm +0 -0
  45. package/grammars/tree-sitter-javascript.wasm +0 -0
  46. package/grammars/tree-sitter-kotlin.wasm +0 -0
  47. package/grammars/tree-sitter-objc.wasm +0 -0
  48. package/grammars/tree-sitter-php.wasm +0 -0
  49. package/grammars/tree-sitter-python.wasm +0 -0
  50. package/grammars/tree-sitter-ruby.wasm +0 -0
  51. package/grammars/tree-sitter-rust.wasm +0 -0
  52. package/grammars/tree-sitter-swift.wasm +0 -0
  53. package/grammars/tree-sitter-tsx.wasm +0 -0
  54. package/grammars/tree-sitter-typescript.wasm +0 -0
  55. package/package.json +67 -0
@@ -0,0 +1,1801 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { basename, dirname, join, posix } from 'node:path';
3
+ import MiniSearch from 'minisearch';
4
+ import { errMsg, log } from '../logger.js';
5
+ import { IMPORT_DEFAULT, IMPORT_NAMESPACE, NON_CALLABLE_KINDS, RECEIVER_OPAQUE, classNameFromFqn, } from '../types.js';
6
+ // v22: per-symbol CYCLOMATIC + COGNITIVE complexity now also computed for the
7
+ // C-FAMILY extractors — cpp, c, AND objc (McCabe + whitepaper-pinned). The 3 shipped
8
+ // extractor-only; this POPULATES the existing `Symbol.complexity?` /
9
+ // `Symbol.cognitiveComplexity?` fields (added in v9/v10 for the prior languages)
10
+ // on cpp/c/objc symbols for the first time. `isUnchanged` (mtime/size/language)
11
+ // can't detect the extraction-logic change, so a bump is required. NOTE the bump
12
+ // is GLOBAL: a SCHEMA_VERSION mismatch rejects the ENTIRE persisted index
13
+ // (`isValidPersisted` checks `version === SCHEMA_VERSION` project-wide), so EVERY
14
+ // project fully re-indexes on upgrade — even a Java/TS-only one with no C-family
15
+ // files — the standard one-time cost of any schema bump. Closes the complexity
16
+ // campaign across all 14 languages.
17
+ // v21: file-scope `static` free functions/globals now extract `exported=false`
18
+ // for C AND C++ (internal-linkage privacy; also correct for C++ file-scope free
19
+ // functions). This is the C-extractor (13th language) slice: the new `.c`
20
+ // language alone needs NO bump (new files self-heal via the startup scan diff),
21
+ // but the SHARED cpp.ts `exported` flip is an extraction-logic change to the
22
+ // EXISTING cpp language that `isUnchanged` (mtime/size/language) can't detect, so
23
+ // the bump force-invalidates warm cpp caches to re-extract the corrected flags.
24
+ // v20: per-symbol CYCLOMATIC + COGNITIVE complexity now also computed for RUBY
25
+ // (`.rb`/`.rake`/`.gemspec`) — the LAST gap (the Ruby extractor shipped
26
+ // extractor-only), completing the per-language complexity campaign across all 11
27
+ // extractors. BOTH metrics pinned EXACT to sonar-ruby (SonarSource's SLANG-based
28
+ // analyzer), run as a per-function oracle: the sonar-ruby-plugin's `RubyConverter`
29
+ // (JRuby + whitequark/parser) builds the SLANG tree, then the shared
30
+ // `org.sonarsource.slang` `CyclomaticComplexityVisitor` / `CognitiveComplexity`
31
+ // score each function — MEASURED on a per-construct battery + the
32
+ // sinatra/rack/liquid/devise corpus (cyclomatic 99.86% exact, cognitive 99.73%,
33
+ // 0-unexplained). CYCLOMATIC: +1 per if/elsif/unless/ternary/modifier-if-unless,
34
+ // each loop (while/until/for + modifier), each `when` arm (NOT the `case` container
35
+ // or `else`), and `&&`/`||`/`and`/`or`; NOT counted (matching the pin): rescue,
36
+ // `case/in`, `&.`, recursion. COGNITIVE (SLANG): structural+1 + nesting surcharge per
37
+ // if/loop/whole-case/block-`rescue`; an if-with-else used as an EXPRESSION with no
38
+ // nested BlockTree is a ternary (`else` suppressed); booleans are TEXT-based
39
+ // source-order runs (no paren skip); blocks are transparent (no nesting). Reuses the
40
+ // collectionIfType→set widening + two new optional engine knobs (`isExpressionTernary`,
41
+ // `catchPredicate`), all inert for the other 10 languages. Adding the fields to Ruby
42
+ // symbols is an extraction-logic change `isUnchanged` (mtime/size/language) can't
43
+ // detect, so the bump force-invalidates warm caches.
44
+ // v19: per-symbol CYCLOMATIC + COGNITIVE complexity now also computed for PHP
45
+ // (`.php`) — the 11th and LAST extractor, BOTH metrics in one slice (the
46
+ // Java/Rust/Swift/Kotlin/Dart/C# pattern). BOTH are pinned to SonarPHP
47
+ // (`php-frontend` 3.38.0.12239's ComplexityVisitor / CognitiveComplexityVisitor),
48
+ // run as a per-function Maven oracle — every value MEASURED against the real
49
+ // analyzer. CYCLOMATIC: +1 per if/each loop (for/foreach/while/do)/ternary (incl
50
+ // elvis `?:`)/switch `case` (NOT default)/`&&`/`||`/`and`/`or`; NOT one-word
51
+ // `elseif` (3.38 quirk — see the project docs), `xor`, `??`, `|`, catch/try/finally, or
52
+ // `default`. COGNITIVE (SonarPHP 3.38): structural+1 + nesting surcharge per
53
+ // if/ternary/switch/loop/catch; elseif/else +1 flat (two-word `else if` gets an
54
+ // extra nesting bump); `&&`/`||` SOURCE-ORDER runs (paren-unwrap, NOT `and`/`or`);
55
+ // break/continue WITH a level arg + goto +1 flat; closures/arrow-fns roll in
56
+ // (nestOnly); no recursion. PHP FORKS on `match`: it counts each arm like a switch
57
+ // case (cyc) and the whole match +1 (cog) — a DELIBERATE divergence from SonarPHP
58
+ // (which counts match in neither metric). TWO new optional engine knobs: `elseChainsIf`
59
+ // (the PHP two-word `else if` = else-clause-contains-if hybrid) and `ternaryBranchFields`
60
+ // (nest only a ternary's branches so a chained elvis `a ?: b ?: c` doesn't compound).
61
+ // Both are PHP-only (the other 10 languages leave them unset). Adding the fields
62
+ // to PHP symbols is an extraction-logic change `isUnchanged` (mtime/size/language)
63
+ // can't detect, so the bump force-invalidates warm caches.
64
+ // v18: per-symbol CYCLOMATIC + COGNITIVE complexity now also computed for C#
65
+ // (`.cs`) — BOTH metrics in one slice (the Java/Rust/Swift/Kotlin/Dart pattern).
66
+ // BOTH are pinned EXACT to SonarC# (`SonarAnalyzer.CSharp`, the Roslyn-based
67
+ // open-source analyzer), run as a per-method oracle via its public
68
+ // CSharpCyclomaticComplexityMetric / CSharpCognitiveComplexityMetric — so every
69
+ // value is MEASURED against the real analyzer (the best oracle case after Dart).
70
+ // CYCLOMATIC (S1541): +1 per if/each loop (for/foreach/while/do)/ternary/each
71
+ // switch-EXPRESSION arm (incl `_`)/plain-constant switch-STATEMENT case (pattern
72
+ // + `default` cases excluded)/pattern combinator `and`/`or` (not `not`)/`&&`/`||`/
73
+ // `??`/`??=`/`?.`/`?[`; LINQ clauses and `catch`/`when`/`goto` don't. COGNITIVE
74
+ // (S3776, sonar-java-shaped): structural+1 + nesting surcharge per if/ternary/
75
+ // switch/loop/catch/`goto`(+goto case); else/else-if +1 flat; `&&`/`||` + pattern
76
+ // `and`/`or` count as SOURCE-ORDER runs (paren-unwrap) but `??` is FREE; recursion
77
+ // is +1 flat (like Go). C# is sonar-java-shaped (field-based `if`, contained
78
+ // `catch`, source-order booleans) so it reuses the existing engine fields + the Go
79
+ // `recursion` field; the ONE new optional engine knob is `surchargeTypes`
80
+ // (`goto`/`goto case` surcharge +1+nesting). Adding the fields to C# symbols is an
81
+ // extraction-logic change `isUnchanged` (mtime/size/language) can't detect, so the
82
+ // bump force-invalidates warm caches.
83
+ // v17: per-symbol CYCLOMATIC + COGNITIVE complexity now also computed for Dart
84
+ // (`.dart`) — BOTH metrics in one slice. BOTH are pinned for behavioral compatibility
85
+ // with SonarQube's Dart cyclomatic (S1541) and cognitive (S3776) rules, per the
86
+ // published Cognitive Complexity whitepaper (pinning the public spec directly, so the
87
+ // Kotlin "oracle the PIN, not a proxy" trap is structurally impossible). CYCLOMATIC
88
+ // (S1541): +1 per if/collection-if/ternary/each loop/each switch case+arm (incl `_`,
89
+ // excl `default`)/each `&&`/`||`/`??`/`?.`/`??=` (collection-if/for count; `?..` and
90
+ // `else`/`default` don't). COGNITIVE (S3776): structural+1 + nesting surcharge per
91
+ // if/ternary/switch/loop/catch/collection-if/for; `&&`/`||` runs count but `??` is FREE
92
+ // (the cyc/cog divergence); recursion is FREE (measured: a self-call adds 0). The
93
+ // nielsenko grammar + the SonarQube Dart cognitive model forced 4 additive engine knobs
94
+ // (conditionFromNamedChildren — the `if` condition is positional; tryType — catch bodies
95
+ // are siblings of the catch clause; collectionIfType — collection-`if` charges its else;
96
+ // booleanByTreeParent — a `&&`/`||` run is a tree-parent operator change, the SonarQube
97
+ // Dart model, distinct from sonar-java's source-order and SonarJS's `&&`-only). Adding the
98
+ // fields to Dart symbols is an
99
+ // extraction-logic change `isUnchanged` (mtime/size/language) can't detect, so the bump
100
+ // force-invalidates warm caches.
101
+ // v16: per-symbol CYCLOMATIC + COGNITIVE complexity now also computed for Kotlin
102
+ // (`.kt`/`.kts`) — BOTH metrics in one slice (the Java/Rust/Swift pattern). BOTH are
103
+ // pinned to sonar-kotlin (source-available, the "compare to SonarQube" north-star, the
104
+ // Java/Python precedent — chosen over detekt, which counts Elvis/break/continue/catch
105
+ // AND +1 per scope function). CYCLOMATIC: +1 per if, per EACH `when` entry INCLUDING the
106
+ // `else` entry (a deliberate divergence from the `default`/`else`-excluded rule in the
107
+ // C-family langs — sonar-kotlin visits every whenEntry), per loop, per `&&`/`||`; Elvis
108
+ // `?:` is NOT counted. COGNITIVE is the SonarSource whitepaper (the engine default): NO
109
+ // recursion, NO Elvis, paren-unwrap; Kotlin's `if` is positional with an anonymous
110
+ // `else` and possibly brace-less branches (a new engine path,
111
+ // ifConsequenceFromNamedChildren). Adding the fields to Kotlin symbols is an
112
+ // extraction-logic change `isUnchanged` (mtime/size/language) can't detect, so the bump
113
+ // force-invalidates warm caches.
114
+ // v15: per-symbol CYCLOMATIC + COGNITIVE complexity now also computed for Swift
115
+ // (`.swift`) — BOTH metrics in one slice (the Java/Rust pattern). CYCLOMATIC is
116
+ // pinned to SwiftLint's `cyclomatic_complexity` (the open, runnable oracle — the
117
+ // gocyclo/rust-code-analysis precedent of pinning the community tool; SwiftLint counts `guard`/`catch`/the 3
118
+ // loops/every switch case incl. `default`, `fallthrough` −1, and skips nested
119
+ // func/init; it does NOT count `&&`/`||`/ternary/`??`, so Swift is the only codedeep-mcp
120
+ // language without cyclomatic booleans). COGNITIVE is SonarSource-whitepaper-aligned
121
+ // (no published cognitive spec for Swift exists, so there is no
122
+ // tool oracle; validated against
123
+ // hand-computed whitepaper fixtures). `guard` is +1 FLAT (the Rust let-else analog /
124
+ // Swift's nesting-reducing idiom). Swift's `if` is positional (no consequence/
125
+ // alternative field — a new engine path) and its booleans are distinct
126
+ // conjunction/disjunction nodes with lhs/rhs operands. Adding the fields to Swift
127
+ // symbols is an extraction-logic change `isUnchanged` (mtime/size/language) can't
128
+ // detect, so the bump force-invalidates warm caches.
129
+ // v14: per-symbol CYCLOMATIC + COGNITIVE complexity now also computed for Rust
130
+ // (`.rs`) — the first of the remaining 6 languages to get BOTH metrics at once.
131
+ // CYCLOMATIC is pinned to Mozilla's `rust-code-analysis` (the `rust-code-analysis-cli`
132
+ // oracle): the `?` try operator, every match arm, match-arm guards, closures, and
133
+ // the 3 loops all count (McCabe-complete; verified exact on ripgrep + serde modulo
134
+ // macro-internal control flow, which codedeep-mcp's grammar treats as opaque token-trees,
135
+ // and nested fn/impl bodies, the per-symbol model). COGNITIVE is SonarSource-
136
+ // whitepaper/sonar-rust-aligned and DELIBERATELY does NOT replicate two
137
+ // rust-code-analysis cognitive bugs the oracle surfaced (it omits `loop` entirely,
138
+ // and carries boolean-run state across the whole function) — codedeep-mcp counts all 3
139
+ // loops and per-expression boolean runs, the defensible number. Adding the fields to
140
+ // Rust symbols is an extraction-logic change `isUnchanged` (mtime/size/language)
141
+ // can't detect, so the bump force-invalidates warm caches.
142
+ // v13: per-symbol COGNITIVE complexity now also computed for Python (`.py`),
143
+ // VERIFIED-EXACT against sonar-python's CognitiveComplexityVisitor (0 mismatches on
144
+ // all ~5034 functions WITHOUT a nested scope across flask + django; differs from
145
+ // complexipy — sonar-python-aligned: `except` surcharges, booleans count everywhere
146
+ // with no paren-unwrap, `with`/`try` bodies are not nested, `match` is 0 structural,
147
+ // loop bodies nest via loopBodyField). Nested fns/lambdas/classes are excluded (the
148
+ // per-symbol model). Adding the field to Python symbols is an extraction-logic change
149
+ // `isUnchanged` (mtime/size/language) can't detect, so the bump force-invalidates
150
+ // warm caches.
151
+ // v12: per-symbol COGNITIVE complexity now also computed for Go (`.go`),
152
+ // VERIFIED-EXACT against uudashr/gocognit (376/376 functions: cobra 157 + gin 213
153
+ // + a synthetic edge-case fixture 6; differs
154
+ // from sonar-go — gocognit-aligned, like Go cyclomatic is gocyclo-aligned: no
155
+ // plain-else nesting, if-init walked, no paren-unwrap in boolean chains, +1 per
156
+ // direct-recursion call-site). Adding the field to Go symbols is an
157
+ // extraction-logic change `isUnchanged` (mtime/size/language) can't detect, so the
158
+ // bump force-invalidates warm caches.
159
+ // v11: per-symbol COGNITIVE complexity now also computed for TS/JS
160
+ // (`.ts`/`.tsx`/`.js`), VERIFIED-EXACT against SonarJS S3776 (differs from
161
+ // sonar-java: `&&`-runs-only booleans, JSX short-circuit exclusion). Adding the
162
+ // field to TS symbols is an extraction-logic change `isUnchanged`
163
+ // (mtime/size/language) can't detect, so the bump force-invalidates warm caches.
164
+ // v10: per-symbol COGNITIVE complexity (`Symbol.cognitiveComplexity?`, the
165
+ // SonarSource whitepaper nesting-aware metric) is now computed at extract time
166
+ // for Java, alongside cyclomatic which also rolled to Java this slice. A new
167
+ // extraction-computed field `isUnchanged` (mtime/size/language) can't detect, so
168
+ // the bump force-invalidates warm caches to re-extract and populate it. v9:
169
+ // per-symbol cyclomatic complexity (`Symbol.complexity?`, 1 + decision
170
+ // points) is now computed at extract time for TS/JS, Python, and Go. An
171
+ // extraction-computed field `isUnchanged` (mtime/size/language) can't detect, so
172
+ // the bump force-invalidates warm caches to re-extract and populate it. v8:
173
+ // chained/computed member calls (`a.b().c()`, `foo().bar()`) are now
174
+ // captured as name-keyed member refs with an opaque receiver (RECEIVER_OPAQUE).
175
+ // A pure extraction-logic change that `isUnchanged` (mtime/size/language) can't
176
+ // detect, so the bump force-invalidates warm caches to re-extract. v7: symbol
177
+ // ids hash the FULL untruncated signature (the stored signature stays capped at
178
+ // 120 chars for display). Under v6, overloads differing only past the cap shared
179
+ // an id, silently merging their reference graphs — the bump forces the rebuild
180
+ // that re-keys every long-signature symbol. (v6 added enum + namespace-declaration
181
+ // extraction; v5 added persisted git enrichment: FileInfo.commitFrequency,
182
+ // co-change lists, hotspots, gitMeta; v4 added member-expression call refs; v3
183
+ // added ImportedName.kind.)
184
+ // Exported so shape-validation tests can build fixtures at the CURRENT version
185
+ // (they must pass the version gate to reach the shape validators). Hardcoding
186
+ // the number in tests silently neutered them on each bump — see the v9→v10
187
+ // regression where version:9 fixtures began short-circuiting at the version check.
188
+ export const SCHEMA_VERSION = 22;
189
+ // Below this length, names like `do`/`is`/`set` flood with false-positive
190
+ // AST name matches across files. find_references and getCallerCount both
191
+ // fall back to precise within-file resolution at or below this threshold.
192
+ export const SHORT_NAME_THRESHOLD = 4;
193
+ // Score multiplier for exported symbols in `searchSymbols` — public API
194
+ // is the more likely target when exploring by keyword.
195
+ const EXPORTED_BOOST = 1.5;
196
+ // Suffix candidates appended to a relative-import resolution to match an
197
+ // indexed file path. Order encodes language-specific resolution preference;
198
+ // each list is selected by `normalizeImportSpecifier` based on the
199
+ // importer's language and the specifier's explicit extension.
200
+ // Python imports cannot resolve to non-Python at runtime — strictly Python.
201
+ // Package directory wins over sibling module per CPython FileFinder
202
+ // semantics (`_find_spec` checks `_path_isdir(base)` before the suffix
203
+ // loop), so when both `pkg/b.py` and `pkg/b/__init__.py` exist,
204
+ // `from pkg import b` resolves to the package.
205
+ const PY_CANDIDATES = ['/__init__.py', '.py'];
206
+ // JS importer: prefer JS-native; fall back to TS source for projects using
207
+ // allowJs/checkJs where the `.ts` is the canonical implementation.
208
+ const JS_CANDIDATES = [
209
+ '.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx',
210
+ '/index.js', '/index.jsx', '/index.mjs', '/index.cjs',
211
+ '/index.ts', '/index.tsx',
212
+ ];
213
+ // TS/TSX importer, no explicit JS-family extension or `.js` stripped:
214
+ // TS source first.
215
+ const TS_CANDIDATES = [
216
+ '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
217
+ '/index.ts', '/index.tsx', '/index.js', '/index.jsx',
218
+ '/index.mjs', '/index.cjs',
219
+ ];
220
+ // TS/TSX importer with `.jsx` specifier: prefer `.tsx` (Node16/NodeNext
221
+ // emits `.jsx` for `.tsx` source), then `.jsx` (explicit user-written
222
+ // JSX target), then `.ts` fallback. The explicit `.jsx` extension is a
223
+ // strong user signal — it should beat an unrelated `.ts` sibling at the
224
+ // same stem.
225
+ const TS_FROM_JSX_CANDIDATES = [
226
+ '.tsx', '.jsx', '.ts', '.js', '.mjs', '.cjs',
227
+ '/index.tsx', '/index.jsx', '/index.ts', '/index.js',
228
+ '/index.mjs', '/index.cjs',
229
+ ];
230
+ // TS/TSX importer with `.js` specifier: prefer `.js` (explicit user
231
+ // signal — hand-written JS in mixed repos), then `.ts` (Node16/NodeNext
232
+ // emits `.js` for `.ts` source). Mirrors the `.jsx` precedence above.
233
+ const TS_FROM_JS_CANDIDATES = [
234
+ '.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs',
235
+ '/index.js', '/index.jsx', '/index.ts', '/index.tsx',
236
+ '/index.mjs', '/index.cjs',
237
+ ];
238
+ export const ENTRY_POINT_FILENAME_RE = /^(index|main|app|server|cli|__main__|__init__)\.(ts|tsx|js|mjs|cjs|jsx|py|java|go|rs|swift|kt|kts|dart|cs|php|rb|cpp|cc|cxx|c|m)$/i;
239
+ // Ordinal edge weights feeding the path-confidence product, plus a rank for
240
+ // "strongest edge wins" when one caller reaches the target several ways.
241
+ const EDGE_WEIGHT = Object.freeze({
242
+ resolved: 1.0,
243
+ 'import-connected': 0.8,
244
+ 'name-match': 0.5,
245
+ 'weak-member': 0.3,
246
+ });
247
+ const STRENGTH_RANK = Object.freeze({
248
+ 'weak-member': 0,
249
+ 'name-match': 1,
250
+ 'import-connected': 2,
251
+ resolved: 3,
252
+ });
253
+ // Per-hop decay so even a chain of strong-but-unverified edges loses
254
+ // confidence with distance from the changed symbol.
255
+ const DEPTH_DECAY = 0.85;
256
+ // Path-confidence floor below which an edge is shown but not expanded.
257
+ const MIN_EXPAND_CONFIDENCE = 0.35;
258
+ const DEFAULT_CALLER_TREE_DEPTH = 3;
259
+ const DEFAULT_CALLER_TREE_BREADTH = 25;
260
+ const DEFAULT_CALLER_TREE_NODES = 200;
261
+ // Edge classes allowed to recurse by default; weak classes are leaf-only.
262
+ const EXPANDABLE_BY_DEFAULT = new Set([
263
+ 'resolved',
264
+ 'import-connected',
265
+ ]);
266
+ // Risk Hotspots (churn × coupling) cost knobs. Hybrid ranking: rank a bounded
267
+ // candidate set by the O(1) cached fan-in, then run the expensive transitive
268
+ // getBlastRadius only for the rows actually displayed.
269
+ const RISK_CANDIDATE_FILES = 40; // most-churned files scanned
270
+ const RISK_BLAST_DEPTH = 2; // caller-tree depth for the displayed blast radius
271
+ const DEFAULT_RISK_HOTSPOTS = 10; // rows returned by getRiskHotspots
272
+ // Static disclosure rendered as a footnote — the caller tree is upstream-only
273
+ // and inheritance-blind by construction, so an empty/shallow tree is a blind
274
+ // spot, not an "all clear".
275
+ const CALLER_TREE_LIMITATIONS = Object.freeze([
276
+ 'Upstream callers only — cross-file callees (downstream) are not traversed (LSP, Phase 2).',
277
+ 'Inheritance/override edges are not modeled, so virtual-dispatch callers may be missing (LSP, Phase 2).',
278
+ 'Edges are heuristic AST name-matches, not compiler-verified; confidence is ordinal, not probabilistic.',
279
+ ]);
280
+ export const zeroSymbolsByKind = () => ({
281
+ function: 0,
282
+ class: 0,
283
+ interface: 0,
284
+ type: 0,
285
+ variable: 0,
286
+ method: 0,
287
+ module: 0,
288
+ enum: 0,
289
+ });
290
+ export class CodeIndex {
291
+ symbolById = new Map();
292
+ fileByPath = new Map();
293
+ importsByFile = new Map();
294
+ symbolsByName = new Map();
295
+ symbolsByFile = new Map();
296
+ callees = new Map();
297
+ callers = new Map();
298
+ // Indexed for find_references; covers both within-file and cross-file
299
+ // (targetId=null) calls.
300
+ referencesByTargetName = new Map();
301
+ // Lets removeFile prune in O(refsInFile) instead of scanning by name.
302
+ referencesBySourceFile = new Map();
303
+ sortedNames = [];
304
+ sortedNamesLower = [];
305
+ namesDirty = true;
306
+ searchIndex = null;
307
+ // Lazy-rebuilt cache for `getCallerCount` so find_symbol's
308
+ // `References: ~N` doesn't re-walk all imports per result. Mirrors
309
+ // the namesDirty/rebuildIndexesIfDirty pattern.
310
+ callerCountById = new Map();
311
+ callerCountsDirty = true;
312
+ // Inverted index of renaming value imports (`import { X as Y }`) keyed
313
+ // by the EXPORTED name X. Rebuilt lazily like the caches above — without
314
+ // it, every getReferencesByNameOrAlias call would scan every file's
315
+ // imports (and the caller-count rebuild multiplies that by symbol count).
316
+ renamingAliasesByName = new Map();
317
+ aliasIndexDirty = true;
318
+ // Sorted file paths for hasFileUnder's binary search — the watcher
319
+ // calls it per deleted path, so a linear scan would make bulk
320
+ // deletions O(deletedPaths × indexedFiles).
321
+ sortedFilePaths = [];
322
+ filePathsDirty = true;
323
+ // Git enrichment (schema v5). cochangesByFile is keyed by indexed
324
+ // paths only; partner values inside the records may be any repo path.
325
+ // hotspotList is <= 50 entries — linear scans are fine.
326
+ cochangesByFile = new Map();
327
+ hotspotList = [];
328
+ gitMetaState = null;
329
+ writeLock = Promise.resolve();
330
+ projectRoot;
331
+ constructor(projectRoot = '') {
332
+ this.projectRoot = projectRoot;
333
+ }
334
+ addFile(file, symbols, references, imports) {
335
+ this.fileByPath.set(file.path, file);
336
+ this.importsByFile.set(file.path, [...imports]);
337
+ this.symbolsByFile.set(file.path, [...symbols]);
338
+ for (const sym of symbols) {
339
+ this.symbolById.set(sym.id, sym);
340
+ pushOrInit(this.symbolsByName, sym.name, sym);
341
+ }
342
+ this.referencesBySourceFile.set(file.path, [...references]);
343
+ for (const ref of references) {
344
+ pushOrInit(this.referencesByTargetName, ref.targetName, ref);
345
+ // Module-level calls (sourceId=null) and cross-file unresolved refs
346
+ // (targetId=null) skip the id-keyed adjacency; they're queried by name
347
+ // via referencesByTargetName.
348
+ if (ref.sourceId && ref.targetId) {
349
+ addAdjacency(this.callees, ref.sourceId, ref.targetId);
350
+ addAdjacency(this.callers, ref.targetId, ref.sourceId);
351
+ }
352
+ }
353
+ this.namesDirty = true;
354
+ this.callerCountsDirty = true;
355
+ this.aliasIndexDirty = true;
356
+ this.filePathsDirty = true;
357
+ }
358
+ // Returns true when the file was actually in the index (cascade ran);
359
+ // false for a no-op so callers can tell mutation from idle work.
360
+ removeFile(path) {
361
+ return this.removeFileInternal(path, true);
362
+ }
363
+ // pruneGit=false is the re-index path (updateFile): the file still
364
+ // exists, so its co-change history and hotspot membership remain valid
365
+ // and must survive the remove+add cycle.
366
+ removeFileInternal(path, pruneGit) {
367
+ const symsInFile = this.symbolsByFile.get(path);
368
+ if (!symsInFile)
369
+ return false;
370
+ if (pruneGit)
371
+ this.pruneGitData(path);
372
+ const deletedIds = new Set();
373
+ for (const sym of symsInFile) {
374
+ deletedIds.add(sym.id);
375
+ this.symbolById.delete(sym.id);
376
+ const list = this.symbolsByName.get(sym.name);
377
+ if (!list)
378
+ continue;
379
+ const filtered = list.filter((s) => s.id !== sym.id);
380
+ if (filtered.length === 0)
381
+ this.symbolsByName.delete(sym.name);
382
+ else
383
+ this.symbolsByName.set(sym.name, filtered);
384
+ }
385
+ for (const id of deletedIds) {
386
+ this.callees.delete(id);
387
+ this.callers.delete(id);
388
+ }
389
+ pruneAdjacency(this.callers, deletedIds);
390
+ pruneAdjacency(this.callees, deletedIds);
391
+ const refsFromFile = this.referencesBySourceFile.get(path);
392
+ if (refsFromFile) {
393
+ // Group by targetName so each by-name list is filtered once even when
394
+ // a file has many refs to the same target.
395
+ const toRemove = new Map();
396
+ for (const ref of refsFromFile) {
397
+ let set = toRemove.get(ref.targetName);
398
+ if (!set) {
399
+ set = new Set();
400
+ toRemove.set(ref.targetName, set);
401
+ }
402
+ set.add(ref);
403
+ }
404
+ for (const [name, set] of toRemove) {
405
+ const list = this.referencesByTargetName.get(name);
406
+ if (!list)
407
+ continue;
408
+ const kept = list.filter((r) => !set.has(r));
409
+ if (kept.length === 0)
410
+ this.referencesByTargetName.delete(name);
411
+ else
412
+ this.referencesByTargetName.set(name, kept);
413
+ }
414
+ this.referencesBySourceFile.delete(path);
415
+ }
416
+ this.fileByPath.delete(path);
417
+ this.importsByFile.delete(path);
418
+ this.symbolsByFile.delete(path);
419
+ this.namesDirty = true;
420
+ this.callerCountsDirty = true;
421
+ this.aliasIndexDirty = true;
422
+ this.filePathsDirty = true;
423
+ return true;
424
+ }
425
+ updateFile(file, symbols, references, imports) {
426
+ // The pipeline never sets commitFrequency — carry it over from the
427
+ // previous FileInfo, or every watcher flush would silently zero the
428
+ // touched file's git data until the next analysis.
429
+ const prevFrequency = this.fileByPath.get(file.path)?.commitFrequency;
430
+ this.removeFileInternal(file.path, false);
431
+ this.addFile(file, symbols, references, imports);
432
+ if (file.commitFrequency === undefined && prevFrequency !== undefined) {
433
+ file.commitFrequency = prevFrequency;
434
+ }
435
+ }
436
+ // True deletion only (never the re-index path): drop the file's own
437
+ // co-change key and hotspot membership. Records naming this file as a
438
+ // PARTNER in other files' lists are deliberately retained — partner
439
+ // values are allowed to be non-indexed paths (config/auth.yaml), and a
440
+ // fresh analysis would re-derive exactly those records from history,
441
+ // so pruning them here would just disagree with the next refresh.
442
+ pruneGitData(path) {
443
+ this.cochangesByFile.delete(path);
444
+ if (this.hotspotList.includes(path)) {
445
+ this.hotspotList = this.hotspotList.filter((p) => p !== path);
446
+ }
447
+ }
448
+ // Swap in a completed analysis. Runs under the write lock so a save()
449
+ // chained behind it persists the new data and apply can never land in
450
+ // the middle of a save's snapshot. Membership may have drifted since
451
+ // the analyzer snapshotted hasFile — re-filter keys here.
452
+ applyGitAnalysis(result) {
453
+ return this.runLocked(async () => {
454
+ for (const [path, fi] of this.fileByPath) {
455
+ fi.commitFrequency = result.counts.get(path) ?? 0;
456
+ }
457
+ const cochanges = new Map();
458
+ for (const [path, list] of result.cochanges) {
459
+ if (this.fileByPath.has(path))
460
+ cochanges.set(path, [...list]);
461
+ }
462
+ this.cochangesByFile = cochanges;
463
+ this.hotspotList = result.hotspots.filter((p) => this.fileByPath.has(p));
464
+ this.gitMetaState = result.meta;
465
+ });
466
+ }
467
+ getCoChanges(path) {
468
+ const list = this.cochangesByFile.get(path);
469
+ return list ? [...list] : [];
470
+ }
471
+ // Ranked hotspot files with their window commit counts, strongest
472
+ // first. Counts come from the live FileInfo so a just-deleted file
473
+ // can't resurface (pruneGitData removed it from the list).
474
+ getHotspots(limit = 10) {
475
+ return this.hotspotList.slice(0, Math.max(0, limit)).map((path) => ({
476
+ path,
477
+ commits: this.fileByPath.get(path)?.commitFrequency ?? 0,
478
+ }));
479
+ }
480
+ // Non-null once a git analysis has landed (live or from cache). Tools
481
+ // gate analysis-derived sections on this; per-call git queries gate on
482
+ // their own null returns instead.
483
+ getGitMeta() {
484
+ return this.gitMetaState;
485
+ }
486
+ // Kill-switch / repo-gone path: when git is disabled (CODEDEEP_GIT=0) or
487
+ // the repo disappeared, persisted enrichment from an earlier enabled
488
+ // session must not keep rendering forever — it could never refresh.
489
+ // No-op when no git data is present.
490
+ clearGitData() {
491
+ return this.runLocked(async () => {
492
+ const hadData = this.gitMetaState !== null ||
493
+ this.cochangesByFile.size > 0 ||
494
+ this.hotspotList.length > 0;
495
+ if (!hadData)
496
+ return false;
497
+ for (const fi of this.fileByPath.values()) {
498
+ delete fi.commitFrequency;
499
+ }
500
+ this.cochangesByFile = new Map();
501
+ this.hotspotList = [];
502
+ this.gitMetaState = null;
503
+ return true;
504
+ });
505
+ }
506
+ findSymbolByName(name, kind, scope) {
507
+ const list = this.symbolsByName.get(name);
508
+ if (!list)
509
+ return [];
510
+ return list.filter((s) => matchesKindScope(s, kind, scope));
511
+ }
512
+ findSymbolsByPrefix(prefix, limit, kind, scope) {
513
+ if (!prefix || limit <= 0)
514
+ return [];
515
+ this.rebuildIndexesIfDirty();
516
+ const prefixLower = prefix.toLowerCase();
517
+ const start = lowerBound(this.sortedNamesLower, prefixLower);
518
+ const out = [];
519
+ for (let i = start; i < this.sortedNamesLower.length && out.length < limit; i++) {
520
+ if (!this.sortedNamesLower[i].startsWith(prefixLower))
521
+ break;
522
+ const syms = this.symbolsByName.get(this.sortedNames[i]);
523
+ if (!syms)
524
+ continue;
525
+ for (const s of syms) {
526
+ if (out.length >= limit)
527
+ break;
528
+ if (!matchesKindScope(s, kind, scope))
529
+ continue;
530
+ out.push(s);
531
+ }
532
+ }
533
+ return out;
534
+ }
535
+ suggest(query, limit, kind, scope) {
536
+ if (!query || limit <= 0)
537
+ return [];
538
+ this.rebuildIndexesIfDirty();
539
+ if (!this.searchIndex)
540
+ return [];
541
+ const results = this.searchIndex.search(query);
542
+ const out = [];
543
+ for (const r of results) {
544
+ if (out.length >= limit)
545
+ break;
546
+ const sym = this.symbolById.get(r.id);
547
+ if (!sym)
548
+ continue;
549
+ if (!matchesKindScope(sym, kind, scope))
550
+ continue;
551
+ out.push(sym);
552
+ }
553
+ return out;
554
+ }
555
+ // Keyword search across names, signatures, and docstrings for
556
+ // `search_structure`. Unlike `suggest` (did-you-mean, name-focused),
557
+ // this widens to all indexed fields and boosts exported symbols.
558
+ // `total` is the full match count so callers can report exactly how
559
+ // many results the limit cut.
560
+ searchSymbols(query, opts) {
561
+ if (!query || opts.limit <= 0)
562
+ return { symbols: [], total: 0 };
563
+ this.rebuildIndexesIfDirty();
564
+ if (!this.searchIndex)
565
+ return { symbols: [], total: 0 };
566
+ const { languages } = opts;
567
+ const results = this.searchIndex.search(query, {
568
+ fields: ['name', 'signature', 'doc', 'fqn'],
569
+ boost: { name: 3, signature: 1.5, doc: 1, fqn: 1 },
570
+ fuzzy: 0.2,
571
+ prefix: true,
572
+ // Equivalent to post-multiplying the total score (each term's
573
+ // contribution is scaled), but lets MiniSearch do the re-ranking
574
+ // so the limit slice below stays correct.
575
+ boostDocument: (id) => {
576
+ const sym = this.symbolById.get(id);
577
+ if (!sym)
578
+ return 1;
579
+ return ((sym.exported ? EXPORTED_BOOST : 1) *
580
+ (opts.boostByFile?.get(sym.file) ?? 1));
581
+ },
582
+ // Filter inside search (not after the limit slice) so results
583
+ // under-fill only when there genuinely aren't enough matches.
584
+ filter: languages
585
+ ? (r) => {
586
+ const sym = this.symbolById.get(r.id);
587
+ return sym !== undefined && languages.has(sym.language);
588
+ }
589
+ : undefined,
590
+ });
591
+ const symbols = [];
592
+ for (const r of results) {
593
+ if (symbols.length >= opts.limit)
594
+ break;
595
+ const sym = this.symbolById.get(r.id);
596
+ if (sym)
597
+ symbols.push(sym);
598
+ }
599
+ return { symbols, total: results.length };
600
+ }
601
+ getSymbolsInFile(path) {
602
+ const list = this.symbolsByFile.get(path);
603
+ return list ? [...list] : [];
604
+ }
605
+ getReferencesBySourceFile(path) {
606
+ const list = this.referencesBySourceFile.get(path);
607
+ return list ? [...list] : [];
608
+ }
609
+ getAllFiles() {
610
+ return [...this.fileByPath.values()];
611
+ }
612
+ hasFile(path) {
613
+ return this.fileByPath.has(path);
614
+ }
615
+ getFile(path) {
616
+ return this.fileByPath.get(path);
617
+ }
618
+ get fileCount() {
619
+ return this.fileByPath.size;
620
+ }
621
+ // True when any indexed file lives under `dirPrefix` (must end with '/').
622
+ // Binary search over the lazily-sorted path list: any key under the
623
+ // directory sorts >= the prefix and shares it, so checking the first
624
+ // key at the insertion point suffices.
625
+ hasFileUnder(dirPrefix) {
626
+ this.rebuildFilePathsIfDirty();
627
+ const at = lowerBound(this.sortedFilePaths, dirPrefix);
628
+ return this.sortedFilePaths[at]?.startsWith(dirPrefix) ?? false;
629
+ }
630
+ // All indexed file paths under `dirPrefix` (must end with '/') — the
631
+ // contiguous sorted range starting at the prefix's insertion point.
632
+ filesUnder(dirPrefix) {
633
+ this.rebuildFilePathsIfDirty();
634
+ const out = [];
635
+ for (let i = lowerBound(this.sortedFilePaths, dirPrefix); i < this.sortedFilePaths.length; i++) {
636
+ if (!this.sortedFilePaths[i].startsWith(dirPrefix))
637
+ break;
638
+ out.push(this.sortedFilePaths[i]);
639
+ }
640
+ return out;
641
+ }
642
+ getCallees(symbolId) {
643
+ return this.resolveIds(this.callees.get(symbolId));
644
+ }
645
+ // Fan-out: resolved within-file callees (id-keyed adjacency). A lower bound
646
+ // — cross-file/unresolved calls live name-keyed and are NOT counted here,
647
+ // unlike fan-in's getCallerCount which is reference-granular. Tag rendered
648
+ // surfaces accordingly.
649
+ getFanOut(symbolId) {
650
+ return this.callees.get(symbolId)?.size ?? 0;
651
+ }
652
+ getCallers(symbolId) {
653
+ return this.resolveIds(this.callers.get(symbolId));
654
+ }
655
+ // Symbol callers come from the id-keyed adjacency set (already deduped
656
+ // by sourceId); same-file module-level call sites are deduped per file
657
+ // by earliest line. Cross-file unresolved name-match refs live in
658
+ // `getCallerCount`.
659
+ getCallerEdges(symbolId) {
660
+ const sym = this.symbolById.get(symbolId);
661
+ if (!sym)
662
+ return [];
663
+ const out = this.resolveIds(this.callers.get(symbolId)).map((s) => ({ file: s.file, line: s.startLine, symbol: s }));
664
+ const refs = this.referencesByTargetName.get(sym.name);
665
+ if (refs) {
666
+ const moduleByFile = new Map();
667
+ for (const ref of refs) {
668
+ if (ref.targetId !== symbolId || ref.sourceId !== null)
669
+ continue;
670
+ const existing = moduleByFile.get(ref.file);
671
+ if (existing === undefined || ref.line < existing) {
672
+ moduleByFile.set(ref.file, ref.line);
673
+ }
674
+ }
675
+ for (const [file, line] of moduleByFile) {
676
+ out.push({ file, line });
677
+ }
678
+ }
679
+ return out;
680
+ }
681
+ // Depth-N upstream blast radius. Walks the SAME cross-file caller-recovery
682
+ // path as find_references (getReferencesByNameOrAlias + isCallerOf),
683
+ // recursively. BFS so the node/breadth budget is spent on shallow,
684
+ // highest-relevance callers first. Depth-1 children are exactly the
685
+ // find_references caller set (grouped one node per caller symbol). The
686
+ // amplification defenses are described above the CallerTreeNode type.
687
+ getCallerTree(symbolId, opts = {}) {
688
+ const maxDepth = opts.maxDepth ?? DEFAULT_CALLER_TREE_DEPTH;
689
+ const maxBreadth = opts.maxBreadth ?? DEFAULT_CALLER_TREE_BREADTH;
690
+ const maxNodes = opts.maxNodes ?? DEFAULT_CALLER_TREE_NODES;
691
+ const includeWeak = opts.includeWeak ?? false;
692
+ const target = this.symbolById.get(symbolId);
693
+ if (!target) {
694
+ const missing = {
695
+ symbolId, name: symbolId, file: '', line: 0, kind: null, depth: 0,
696
+ strength: 'resolved', sites: [], confidence: 1, children: [],
697
+ isCycle: false, isModuleLevel: false, leafByPolicy: false,
698
+ depthCapped: false, truncatedChildren: 0,
699
+ };
700
+ return {
701
+ root: missing, totalNodes: 0, truncated: false,
702
+ limitations: CALLER_TREE_LIMITATIONS,
703
+ };
704
+ }
705
+ const root = {
706
+ symbolId: target.id, name: target.name, file: target.file,
707
+ line: target.startLine, kind: target.kind, depth: 0,
708
+ strength: 'resolved', sites: [], confidence: 1, children: [],
709
+ isCycle: false, isModuleLevel: false, leafByPolicy: false,
710
+ depthCapped: false, truncatedChildren: 0,
711
+ };
712
+ const cache = new Map();
713
+ let totalNodes = 0;
714
+ let truncated = false;
715
+ const queue = [
716
+ { node: root, symbol: target, ancestors: new Set([target.id]) },
717
+ ];
718
+ while (queue.length > 0) {
719
+ const frame = queue.shift();
720
+ if (!frame)
721
+ break;
722
+ const { node, symbol, ancestors } = frame;
723
+ if (node.depth >= maxDepth)
724
+ continue;
725
+ const refs = this.resolveDirectCallers(symbol, cache);
726
+ const groups = this.groupCallerRefs(refs, symbol);
727
+ // Build candidate child nodes, then sort strongest-first before caps.
728
+ const candidates = groups.map((g) => {
729
+ const childSym = g.sourceId !== null ? this.symbolById.get(g.sourceId) ?? null : null;
730
+ const earliest = g.sites.reduce((a, b) => (b.line < a.line ? b : a));
731
+ const confidence = node.confidence * EDGE_WEIGHT[g.strength] * DEPTH_DECAY;
732
+ const via = node.depth === 0 ? undefined : node.name;
733
+ const child = childSym
734
+ ? {
735
+ symbolId: childSym.id, name: childSym.name, file: childSym.file,
736
+ line: childSym.startLine, kind: childSym.kind,
737
+ depth: node.depth + 1, strength: g.strength, sites: g.sites,
738
+ confidence, via, children: [], isCycle: false,
739
+ isModuleLevel: false, leafByPolicy: false, depthCapped: false,
740
+ truncatedChildren: 0,
741
+ }
742
+ : {
743
+ // Module-level (or dangling sourceId): terminal leaf anchored at
744
+ // the earliest call site. The renderer supplies the display
745
+ // label from isModuleLevel — the index stays free of UI strings.
746
+ symbolId: null, name: '', file: earliest.file, line: earliest.line,
747
+ kind: null, depth: node.depth + 1, strength: g.strength,
748
+ sites: g.sites, confidence, via, children: [], isCycle: false,
749
+ isModuleLevel: true, leafByPolicy: false, depthCapped: false,
750
+ truncatedChildren: 0,
751
+ };
752
+ return { child, symbol: childSym };
753
+ });
754
+ candidates.sort((a, b) => b.child.confidence - a.child.confidence ||
755
+ STRENGTH_RANK[b.child.strength] - STRENGTH_RANK[a.child.strength] ||
756
+ (a.child.file < b.child.file ? -1 : a.child.file > b.child.file ? 1 : 0) ||
757
+ a.child.line - b.child.line);
758
+ let kept = candidates;
759
+ if (candidates.length > maxBreadth) {
760
+ node.truncatedChildren += candidates.length - maxBreadth;
761
+ truncated = true;
762
+ kept = candidates.slice(0, maxBreadth);
763
+ }
764
+ for (const { child, symbol: childSym } of kept) {
765
+ if (totalNodes >= maxNodes) {
766
+ node.truncatedChildren += 1;
767
+ truncated = true;
768
+ continue;
769
+ }
770
+ const cyclic = child.symbolId !== null && ancestors.has(child.symbolId);
771
+ const atDepthWall = child.depth >= maxDepth;
772
+ // include_weak is the explicit "accept the noise" override: it expands
773
+ // every edge class AND bypasses the confidence floor (subject only to
774
+ // depth/cycle/node caps). The default path expands only the stronger
775
+ // classes above the floor, so one wrong weak edge can never fan out
776
+ // into a false subtree.
777
+ const policyOk = includeWeak || EXPANDABLE_BY_DEFAULT.has(child.strength);
778
+ const confOk = includeWeak || child.confidence >= MIN_EXPAND_CONFIDENCE;
779
+ const expandable = child.symbolId !== null && !cyclic && policyOk && confOk;
780
+ // Coherent partition for a non-cyclic real-symbol child:
781
+ // not-expandable -> leafByPolicy (weak edge class OR path
782
+ // confidence below the floor) at ANY depth,
783
+ // so include_weak is the actionable hint;
784
+ // expandable at the wall -> depthCapped (only the depth limit stops
785
+ // it; "raise depth" is the hint);
786
+ // expandable, room left -> recursed (no flag).
787
+ // Module-level children (symbolId null) are terminal leaves handled by
788
+ // isModuleLevel and need no flag.
789
+ if (cyclic) {
790
+ child.isCycle = true;
791
+ }
792
+ else if (!expandable && child.symbolId !== null) {
793
+ child.leafByPolicy = true;
794
+ }
795
+ else if (expandable && atDepthWall) {
796
+ child.depthCapped = true;
797
+ }
798
+ node.children.push(child);
799
+ totalNodes++;
800
+ if (expandable && !atDepthWall && childSym && child.symbolId) {
801
+ queue.push({
802
+ node: child,
803
+ symbol: childSym,
804
+ ancestors: new Set(ancestors).add(child.symbolId),
805
+ });
806
+ }
807
+ }
808
+ }
809
+ return { root, totalNodes, truncated, limitations: CALLER_TREE_LIMITATIONS };
810
+ }
811
+ // Transitive blast radius (impact-set size) as a scalar — the same distinct
812
+ // counting impact.ts renders, shared via countDistinctCallers so the two
813
+ // surfaces agree. NOT tree.totalNodes, which double-counts a caller reached
814
+ // through several upstream branches (a DAG diamond).
815
+ getBlastRadius(symbolId, opts) {
816
+ const tree = this.getCallerTree(symbolId, opts);
817
+ const counts = countDistinctCallers(tree.root);
818
+ // Lower bound if EITHER a breadth/node cap fired OR the depth wall stopped
819
+ // expansion — the scalar can't carry impact's two distinct hints, so one `+`.
820
+ return {
821
+ callers: counts.callers,
822
+ files: counts.files,
823
+ depths: counts.depths,
824
+ truncated: tree.truncated || counts.depthCapped,
825
+ };
826
+ }
827
+ // Risk Hotspots: files ranked by churn × coupling × complexity (the
828
+ // CodeScene/Feathers intersection model — churny-but-decoupled and
829
+ // coupled-but-frozen both fall away — refined by the offender's complexity).
830
+ // Empty off-git (no churn signal). Hybrid for cost: rank a bounded candidate
831
+ // set by the O(1) cached fan-in, run the expensive transitive blast-radius
832
+ // walk only for the rows returned.
833
+ getRiskHotspots(limit = DEFAULT_RISK_HOTSPOTS) {
834
+ if (this.gitMetaState === null)
835
+ return [];
836
+ // Tie-break the candidate cut by path so the slice is deterministic across
837
+ // re-index orderings (getAllFiles is raw Map insertion order).
838
+ const candidates = this.getAllFiles()
839
+ .filter((f) => (f.commitFrequency ?? 0) > 0)
840
+ .sort((a, b) => (b.commitFrequency ?? 0) - (a.commitFrequency ?? 0) ||
841
+ a.path.localeCompare(b.path))
842
+ .slice(0, RISK_CANDIDATE_FILES);
843
+ if (candidates.length === 0)
844
+ return [];
845
+ // Per file the offender is its single highest-fan-in symbol (MAX, not sum:
846
+ // a file's risk is its most-coupled hub, not a pile of trivial helpers).
847
+ // Single pass — only the max is consumed.
848
+ const scored = [];
849
+ for (const f of candidates) {
850
+ let best = null;
851
+ let bestFanIn = 0;
852
+ for (const s of this.getSymbolsInFile(f.path)) {
853
+ const fanIn = this.getCallerCount(s.id);
854
+ if (fanIn > bestFanIn) {
855
+ bestFanIn = fanIn;
856
+ best = s;
857
+ }
858
+ }
859
+ if (best && bestFanIn > 0) {
860
+ scored.push({
861
+ file: f.path,
862
+ symbol: best,
863
+ fanIn: bestFanIn,
864
+ churn: f.commitFrequency ?? 0,
865
+ });
866
+ }
867
+ }
868
+ if (scored.length === 0)
869
+ return [];
870
+ // Product on a log scale (heavy-tailed counts), refined by the offender's
871
+ // complexity. No Math.max(...spread) anywhere — the ranking is a pure
872
+ // per-row product, so the RangeError guard that gitBoostMap needs does not
873
+ // apply here. Complexity enters as a bounded factor (1 + log1p(cx)) that is
874
+ // ALWAYS >= 1: it amplifies a gnarly hub but never zeroes a churny+coupled-
875
+ // but-simple one (the dominant case, since the offender is picked by fan-in
876
+ // with no regard for complexity), and degrades to the exact churn × coupling
877
+ // score when the offender is trivial (cx === 0 → factor 1). `cx` leads with
878
+ // cognitive (the human-effort signal) and falls back to cyclomatic-excess —
879
+ // a deliberate blend, NOT a clean single metric: keep the (1 + …) guard or a
880
+ // cog-0 offender (cyc >= 1 by construction so cx >= 0) re-zeroes the score.
881
+ // Computed before the slice so complexity participates in ranking for ALL
882
+ // scored candidates. Tie-break by file so equal-score rows order (and
883
+ // survive the limit slice) deterministically.
884
+ const rows = scored
885
+ .map((s) => {
886
+ let cx = s.symbol.cognitiveComplexity ?? 0;
887
+ if (cx === 0)
888
+ cx = (s.symbol.complexity ?? 1) - 1;
889
+ return {
890
+ ...s,
891
+ score: Math.log1p(s.churn) * Math.log1p(s.fanIn) * (1 + Math.log1p(cx)),
892
+ };
893
+ })
894
+ .sort((a, b) => b.score - a.score || a.file.localeCompare(b.file))
895
+ .slice(0, Math.max(0, limit));
896
+ // Expensive transitive walk ONLY for the displayed rows. The offender's raw
897
+ // complexity is reported for display (undefined-clean when absent); a less-
898
+ // coupled-but-more-complex symbol in the same file is intentionally NOT the
899
+ // offender — the row's identity is the file's most-coupled hub.
900
+ return rows.map((r) => ({
901
+ file: r.file,
902
+ symbol: r.symbol.name,
903
+ symbolId: r.symbol.id,
904
+ churn: r.churn,
905
+ fanIn: r.fanIn,
906
+ blast: this.getBlastRadius(r.symbol.id, { maxDepth: RISK_BLAST_DEPTH }),
907
+ complexity: r.symbol.complexity,
908
+ cognitiveComplexity: r.symbol.cognitiveComplexity,
909
+ score: r.score,
910
+ }));
911
+ }
912
+ // Filtered + memoized direct callers of `target` — the same candidate set
913
+ // find_references draws from (getReferencesByNameOrAlias + isCallerOf). The
914
+ // underlying caller set is identical, but the RENDERED cardinality can differ:
915
+ // the tree groups per caller symbol and caps breadth at
916
+ // DEFAULT_CALLER_TREE_BREADTH, while find_references lists per ref-site and
917
+ // caps weak member rows at WEAK_MEMBER_ROW_CAP.
918
+ resolveDirectCallers(target, cache) {
919
+ const hit = cache.get(target.id);
920
+ if (hit)
921
+ return hit;
922
+ const refs = this.getReferencesByNameOrAlias(target.name, target.file, isClassMember(target)).filter((r) => isCallerOf(r, target));
923
+ cache.set(target.id, refs);
924
+ return refs;
925
+ }
926
+ // Collapse caller refs into one group per caller symbol (or per file for
927
+ // module-level call sites): all call sites in `sites`, strongest edge wins.
928
+ groupCallerRefs(refs, target) {
929
+ const groups = new Map();
930
+ for (const ref of refs) {
931
+ const strength = this.edgeStrength(ref, target);
932
+ const key = ref.sourceId ?? `\0module:${ref.file}`;
933
+ let g = groups.get(key);
934
+ if (!g) {
935
+ g = { sourceId: ref.sourceId, sites: [], strength };
936
+ groups.set(key, g);
937
+ }
938
+ g.sites.push({ file: ref.file, line: ref.line });
939
+ if (STRENGTH_RANK[strength] > STRENGTH_RANK[g.strength]) {
940
+ g.strength = strength;
941
+ }
942
+ }
943
+ return [...groups.values()];
944
+ }
945
+ // Classify one caller edge by how strongly its source binds to `target`.
946
+ edgeStrength(ref, target) {
947
+ if (ref.targetId !== null)
948
+ return 'resolved';
949
+ const imports = this.importsByFile.get(ref.file) ?? [];
950
+ if (ref.receiver === undefined) {
951
+ if (fileImportsName(imports, target.name) ||
952
+ posix.dirname(ref.file) === posix.dirname(target.file)) {
953
+ return 'import-connected';
954
+ }
955
+ return 'name-match';
956
+ }
957
+ // An opaque (chained/computed) receiver can never name an import, so skip
958
+ // the guaranteed-false scan and weak-classify directly — the same
959
+ // short-circuit rankRefs applies, keeping the two classifiers in lockstep on
960
+ // the hot impact caller-tree path (chained capture makes opaque dominant).
961
+ return ref.receiver !== RECEIVER_OPAQUE && fileImportsReceiver(imports, ref.receiver)
962
+ ? 'import-connected'
963
+ : 'weak-member';
964
+ }
965
+ // Approximate caller count surfaced by find_symbol's `References: ~N`.
966
+ // Reference-granular: one count per call site / `new` / JSX usage.
967
+ // O(1) lookup against a lazily rebuilt cache — `find_symbol` calls
968
+ // this once per result, so a per-call walk would be O(results × files
969
+ // × imports). The full rebuild runs at most once between index updates.
970
+ getCallerCount(symbolId) {
971
+ this.rebuildCallerCountsIfDirty();
972
+ return this.callerCountById.get(symbolId) ?? 0;
973
+ }
974
+ getReferencesByName(name) {
975
+ const list = this.referencesByTargetName.get(name);
976
+ return list ? [...list] : [];
977
+ }
978
+ // Alias-aware: includes refs whose `targetName` is a local alias of `name`
979
+ // in the importing file (`import { name as alias }`; `alias()` site). The
980
+ // extractor records the call-site identifier, so plain by-name lookup misses
981
+ // these.
982
+ //
983
+ // When `targetFile` is provided, both alias refs AND unresolved primary
984
+ // refs are scoped by import resolution — preventing cross-file leakage
985
+ // when two files define a symbol with the same name. Within-file resolved
986
+ // refs (targetId !== null) flow through; the caller's `isCallerOf` filter
987
+ // rejects refs that precisely resolve to a different homonym.
988
+ getReferencesByNameOrAlias(name, targetFile, targetIsMember = false) {
989
+ const primary = this.referencesByTargetName.get(name) ?? [];
990
+ const filteredPrimary = targetFile === undefined
991
+ ? primary
992
+ : primary.filter((ref) => this.primaryRefMatchesTarget(ref, name, targetFile, targetIsMember));
993
+ const out = filteredPrimary.slice();
994
+ const seen = new Set(filteredPrimary);
995
+ this.rebuildAliasIndexIfDirty();
996
+ for (const entry of this.renamingAliasesByName.get(name) ?? []) {
997
+ const { filePath, sourceModule, alias } = entry;
998
+ if (targetFile !== undefined) {
999
+ const importingFile = this.fileByPath.get(filePath);
1000
+ if (!importingFile)
1001
+ continue;
1002
+ const resolved = this.resolveImportTarget(importingFile, sourceModule);
1003
+ // Skip ONLY when the specifier resolves to a known different
1004
+ // file. null (unresolvable specifier — TS path alias, workspace
1005
+ // pkg, Python absolute import) falls through to best-effort
1006
+ // include; same policy as primaryRefMatchesTarget.
1007
+ if (resolved !== null && resolved !== targetFile)
1008
+ continue;
1009
+ }
1010
+ const aliasRefs = this.referencesByTargetName.get(alias);
1011
+ if (!aliasRefs)
1012
+ continue;
1013
+ for (const ref of aliasRefs) {
1014
+ if (ref.file !== filePath)
1015
+ continue;
1016
+ // `obj.h()` where h happens to equal a local import alias —
1017
+ // a member call's property never binds through a top-level
1018
+ // import; only bare `h()` sites do.
1019
+ if (ref.receiver !== undefined)
1020
+ continue;
1021
+ if (seen.has(ref))
1022
+ continue;
1023
+ seen.add(ref);
1024
+ out.push(ref);
1025
+ }
1026
+ }
1027
+ return out;
1028
+ }
1029
+ getSymbolById(id) {
1030
+ return this.symbolById.get(id);
1031
+ }
1032
+ getImports(filePath) {
1033
+ const list = this.importsByFile.get(filePath);
1034
+ return list ? [...list] : [];
1035
+ }
1036
+ getStats() {
1037
+ const filesByLanguage = {};
1038
+ for (const fi of this.fileByPath.values()) {
1039
+ filesByLanguage[fi.language] = (filesByLanguage[fi.language] ?? 0) + 1;
1040
+ }
1041
+ const symbolsByKind = zeroSymbolsByKind();
1042
+ const entries = [];
1043
+ for (const sym of this.symbolById.values()) {
1044
+ symbolsByKind[sym.kind]++;
1045
+ if (sym.exported && ENTRY_POINT_FILENAME_RE.test(basename(sym.file))) {
1046
+ entries.push({ file: sym.file, symbol: sym.name, line: sym.startLine });
1047
+ }
1048
+ }
1049
+ entries.sort((a, b) => a.file.localeCompare(b.file) || a.symbol.localeCompare(b.symbol));
1050
+ return {
1051
+ totalFiles: this.fileByPath.size,
1052
+ totalSymbols: this.symbolById.size,
1053
+ filesByLanguage,
1054
+ symbolsByKind,
1055
+ entryPoints: entries.slice(0, 20),
1056
+ };
1057
+ }
1058
+ save(cachePath) {
1059
+ return this.runLocked(async () => {
1060
+ const allRefs = [];
1061
+ for (const refs of this.referencesBySourceFile.values()) {
1062
+ for (const ref of refs)
1063
+ allRefs.push(ref);
1064
+ }
1065
+ const data = {
1066
+ version: SCHEMA_VERSION,
1067
+ createdAt: Date.now(),
1068
+ projectRoot: this.projectRoot,
1069
+ symbols: [...this.symbolById.entries()],
1070
+ files: [...this.fileByPath.entries()],
1071
+ imports: [...this.importsByFile.entries()],
1072
+ callees: adjacencyToEntries(this.callees),
1073
+ callers: adjacencyToEntries(this.callers),
1074
+ references: allRefs,
1075
+ cochanges: [...this.cochangesByFile.entries()],
1076
+ hotspots: [...this.hotspotList],
1077
+ gitMeta: this.gitMetaState,
1078
+ };
1079
+ const json = JSON.stringify(data);
1080
+ const tmp = `${cachePath}.tmp.${process.pid}.${Date.now()}`;
1081
+ await fs.mkdir(dirname(cachePath), { recursive: true });
1082
+ try {
1083
+ const fh = await fs.open(tmp, 'w');
1084
+ try {
1085
+ await fh.writeFile(json);
1086
+ await fh.sync();
1087
+ }
1088
+ finally {
1089
+ await fh.close();
1090
+ }
1091
+ try {
1092
+ await fs.rename(tmp, cachePath);
1093
+ }
1094
+ catch {
1095
+ // Windows can't rename over an existing file; retry after unlink.
1096
+ await fs.unlink(cachePath).catch(() => undefined);
1097
+ await fs.rename(tmp, cachePath);
1098
+ }
1099
+ }
1100
+ catch (err) {
1101
+ await fs.unlink(tmp).catch(() => undefined);
1102
+ throw err;
1103
+ }
1104
+ });
1105
+ }
1106
+ async load(cachePath) {
1107
+ await this.cleanupStaleTmp(cachePath);
1108
+ let raw;
1109
+ try {
1110
+ raw = await fs.readFile(cachePath, 'utf8');
1111
+ }
1112
+ catch (err) {
1113
+ const code = err?.code;
1114
+ if (code !== 'ENOENT') {
1115
+ log.warn(`CodeIndex.load: failed to read cache at ${cachePath}: ${errMsg(err)}`);
1116
+ }
1117
+ return false;
1118
+ }
1119
+ let parsed;
1120
+ try {
1121
+ parsed = JSON.parse(raw);
1122
+ }
1123
+ catch {
1124
+ log.warn(`CodeIndex.load: cache is malformed at ${cachePath}; deleting`);
1125
+ await fs.unlink(cachePath).catch(() => undefined);
1126
+ return false;
1127
+ }
1128
+ if (!isValidPersisted(parsed, SCHEMA_VERSION)) {
1129
+ log.warn(`CodeIndex.load: cache failed validation at ${cachePath}; deleting`);
1130
+ await fs.unlink(cachePath).catch(() => undefined);
1131
+ return false;
1132
+ }
1133
+ if (this.projectRoot && parsed.projectRoot !== this.projectRoot) {
1134
+ log.warn(`CodeIndex.load: cache projectRoot mismatch (cache=${parsed.projectRoot}, ` +
1135
+ `expected=${this.projectRoot}); deleting`);
1136
+ await fs.unlink(cachePath).catch(() => undefined);
1137
+ return false;
1138
+ }
1139
+ const symbolById = new Map();
1140
+ const fileByPath = new Map();
1141
+ const importsByFile = new Map();
1142
+ const symbolsByName = new Map();
1143
+ const symbolsByFile = new Map();
1144
+ const callees = new Map();
1145
+ const callers = new Map();
1146
+ const referencesByTargetName = new Map();
1147
+ const referencesBySourceFile = new Map();
1148
+ const cochangesByFile = new Map();
1149
+ const hotspotList = [];
1150
+ try {
1151
+ for (const [id, sym] of parsed.symbols)
1152
+ symbolById.set(id, sym);
1153
+ for (const [path, fi] of parsed.files)
1154
+ fileByPath.set(path, fi);
1155
+ for (const [path, imps] of parsed.imports)
1156
+ importsByFile.set(path, imps);
1157
+ for (const [src, targets] of parsed.callees)
1158
+ callees.set(src, new Set(targets));
1159
+ for (const [tgt, sources] of parsed.callers)
1160
+ callers.set(tgt, new Set(sources));
1161
+ for (const ref of parsed.references) {
1162
+ if (!isPersistedReference(ref)) {
1163
+ throw new Error('persisted reference has invalid shape');
1164
+ }
1165
+ pushOrInit(referencesByTargetName, ref.targetName, ref);
1166
+ pushOrInit(referencesBySourceFile, ref.file, ref);
1167
+ }
1168
+ for (const entry of parsed.cochanges) {
1169
+ if (!Array.isArray(entry) ||
1170
+ typeof entry[0] !== 'string' ||
1171
+ !Array.isArray(entry[1]) ||
1172
+ !entry[1].every(isPersistedCoChange)) {
1173
+ throw new Error('persisted cochange entry has invalid shape');
1174
+ }
1175
+ cochangesByFile.set(entry[0], entry[1]);
1176
+ }
1177
+ for (const path of parsed.hotspots) {
1178
+ if (typeof path !== 'string') {
1179
+ throw new Error('persisted hotspot entry has invalid shape');
1180
+ }
1181
+ hotspotList.push(path);
1182
+ }
1183
+ // Seed entries for zero-symbol files so removeFile's symsInFile guard fires (mirrors addFile).
1184
+ for (const path of fileByPath.keys())
1185
+ symbolsByFile.set(path, []);
1186
+ for (const sym of symbolById.values()) {
1187
+ pushOrInit(symbolsByName, sym.name, sym);
1188
+ pushOrInit(symbolsByFile, sym.file, sym);
1189
+ }
1190
+ }
1191
+ catch (err) {
1192
+ log.warn(`CodeIndex.load: cache failed validation at ${cachePath} (${errMsg(err)}); deleting`);
1193
+ await fs.unlink(cachePath).catch(() => undefined);
1194
+ return false;
1195
+ }
1196
+ this.symbolById = symbolById;
1197
+ this.fileByPath = fileByPath;
1198
+ this.importsByFile = importsByFile;
1199
+ this.symbolsByName = symbolsByName;
1200
+ this.symbolsByFile = symbolsByFile;
1201
+ this.callees = callees;
1202
+ this.callers = callers;
1203
+ this.referencesByTargetName = referencesByTargetName;
1204
+ this.referencesBySourceFile = referencesBySourceFile;
1205
+ this.cochangesByFile = cochangesByFile;
1206
+ this.hotspotList = hotspotList;
1207
+ this.gitMetaState = parsed.gitMeta;
1208
+ // Derived caches: reset; rebuild*IfDirty repopulates lazily.
1209
+ this.sortedNames = [];
1210
+ this.sortedNamesLower = [];
1211
+ this.searchIndex = null;
1212
+ this.namesDirty = true;
1213
+ this.callerCountById.clear();
1214
+ this.callerCountsDirty = true;
1215
+ this.aliasIndexDirty = true;
1216
+ this.filePathsDirty = true;
1217
+ return true;
1218
+ }
1219
+ async cleanupStaleTmp(cachePath) {
1220
+ try {
1221
+ const dir = dirname(cachePath);
1222
+ const base = basename(cachePath);
1223
+ const entries = await fs.readdir(dir);
1224
+ const tmpPrefix = `${base}.tmp.`;
1225
+ await Promise.all(entries
1226
+ .filter((e) => e.startsWith(tmpPrefix))
1227
+ .map((e) => fs.unlink(join(dir, e)).catch(() => undefined)));
1228
+ }
1229
+ catch {
1230
+ // ignore: parent dir may not exist yet
1231
+ }
1232
+ }
1233
+ rebuildIndexesIfDirty() {
1234
+ if (!this.namesDirty)
1235
+ return;
1236
+ const names = [...this.symbolsByName.keys()];
1237
+ const pairs = names.map((n) => [n, n.toLowerCase()]);
1238
+ // Codepoint compare (not localeCompare) so sort order matches `lowerBound`'s `<`.
1239
+ pairs.sort((a, b) => (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0));
1240
+ this.sortedNames = pairs.map((p) => p[0]);
1241
+ this.sortedNamesLower = pairs.map((p) => p[1]);
1242
+ // All four fields are indexed so `searchSymbols` can match signatures
1243
+ // and docstrings, but the DEFAULT search options pin `fields` to
1244
+ // name+fqn — `suggest` (did-you-mean) must not surface doc-only hits.
1245
+ // BM25 stats are per-field, so suggest's scores are unchanged by the
1246
+ // extra indexed fields.
1247
+ this.searchIndex = new MiniSearch({
1248
+ fields: ['name', 'fqn', 'signature', 'doc'],
1249
+ idField: 'id',
1250
+ searchOptions: {
1251
+ fields: ['name', 'fqn'],
1252
+ fuzzy: 0.2,
1253
+ prefix: true,
1254
+ boost: { name: 2 },
1255
+ },
1256
+ });
1257
+ this.searchIndex.addAll([...this.symbolById.values()]);
1258
+ this.namesDirty = false;
1259
+ }
1260
+ rebuildCallerCountsIfDirty() {
1261
+ if (!this.callerCountsDirty)
1262
+ return;
1263
+ this.callerCountById.clear();
1264
+ // isCallerOf rejects every UNRESOLVED ref for short names, so the
1265
+ // hot homonym buckets (`get`/`set`/`run` — mostly unresolved member
1266
+ // calls) are pre-filtered to resolved refs ONCE per name instead of
1267
+ // rescanned in full per same-named symbol. The predicate stays
1268
+ // isCallerOf so counts can never desync from find_references' rows.
1269
+ const resolvedShortRefs = new Map();
1270
+ for (const sym of this.symbolById.values()) {
1271
+ let count = 0;
1272
+ if (sym.name.length < SHORT_NAME_THRESHOLD) {
1273
+ let resolved = resolvedShortRefs.get(sym.name);
1274
+ if (!resolved) {
1275
+ resolved = (this.referencesByTargetName.get(sym.name) ?? []).filter((r) => r.targetId !== null);
1276
+ resolvedShortRefs.set(sym.name, resolved);
1277
+ }
1278
+ for (const ref of resolved) {
1279
+ if (isCallerOf(ref, sym))
1280
+ count++;
1281
+ }
1282
+ }
1283
+ else {
1284
+ const refs = this.getReferencesByNameOrAlias(sym.name, sym.file, isClassMember(sym));
1285
+ for (const ref of refs) {
1286
+ if (isCallerOf(ref, sym))
1287
+ count++;
1288
+ }
1289
+ }
1290
+ // Skip zero-count entries — getCallerCount returns `?? 0`, so the
1291
+ // observable behavior is identical and we save one Map slot per
1292
+ // never-called helper (common for leaf utilities).
1293
+ if (count > 0)
1294
+ this.callerCountById.set(sym.id, count);
1295
+ }
1296
+ this.callerCountsDirty = false;
1297
+ }
1298
+ rebuildFilePathsIfDirty() {
1299
+ if (!this.filePathsDirty)
1300
+ return;
1301
+ this.sortedFilePaths = [...this.fileByPath.keys()].sort();
1302
+ this.filePathsDirty = false;
1303
+ }
1304
+ rebuildAliasIndexIfDirty() {
1305
+ if (!this.aliasIndexDirty)
1306
+ return;
1307
+ this.renamingAliasesByName.clear();
1308
+ for (const [filePath, imports] of this.importsByFile) {
1309
+ for (const imp of imports) {
1310
+ for (const named of imp.importedNames) {
1311
+ if (!named.alias || named.alias === named.name)
1312
+ continue;
1313
+ // Renaming type-only aliases (`import type { X as Y }`) are
1314
+ // erased at runtime; their alias-named call sites can't bind
1315
+ // through the import.
1316
+ if (!isValueBinding(named))
1317
+ continue;
1318
+ pushOrInit(this.renamingAliasesByName, named.name, {
1319
+ filePath,
1320
+ sourceModule: imp.sourceModule,
1321
+ alias: named.alias,
1322
+ });
1323
+ }
1324
+ }
1325
+ }
1326
+ this.aliasIndexDirty = false;
1327
+ }
1328
+ resolveIds(ids) {
1329
+ if (!ids)
1330
+ return [];
1331
+ const out = [];
1332
+ for (const id of ids) {
1333
+ const sym = this.symbolById.get(id);
1334
+ if (sym)
1335
+ out.push(sym);
1336
+ }
1337
+ return out;
1338
+ }
1339
+ runLocked(work) {
1340
+ const next = this.writeLock.then(work);
1341
+ // Swallow rejections on the lock chain so one failed save doesn't block
1342
+ // future ones; the original promise still rejects to the caller.
1343
+ this.writeLock = next.then(() => undefined, () => undefined);
1344
+ return next;
1345
+ }
1346
+ // Decides whether a primary ref should be attributed to `targetFile`.
1347
+ // Resolved refs (targetId !== null) always flow through — `isCallerOf`
1348
+ // filters precise mismatches downstream. For unresolved refs the policy
1349
+ // distinguishes four states of the source file's matching imports:
1350
+ //
1351
+ // - resolves to targetFile → include (precise match);
1352
+ // - resolves to a different indexed file → drop (binds elsewhere);
1353
+ // - matches but specifier is unresolvable (TS path alias, workspace
1354
+ // pkg, Python absolute) → fall through to best-effort include;
1355
+ // - no matching import at all → drop. The bare `name()` call binds
1356
+ // to a parameter, local, nested-function, or global — attributing
1357
+ // it to every same-named export is wrong.
1358
+ primaryRefMatchesTarget(ref, name, targetFile, targetIsMember) {
1359
+ if (ref.targetId !== null)
1360
+ return true;
1361
+ if (ref.receiver !== undefined) {
1362
+ return this.memberRefMatchesTarget(ref, ref.receiver, targetFile, targetIsMember);
1363
+ }
1364
+ const importingFile = this.fileByPath.get(ref.file);
1365
+ if (!importingFile)
1366
+ return true;
1367
+ // Go: files of one package call each other's top-level functions with
1368
+ // NO import statement (the no-matching-import drop below would hide
1369
+ // every sibling-file caller — the dominant Go call pattern). One
1370
+ // directory = one package, so a same-directory bare ref is attributed
1371
+ // directly — but only to a top-level Go target: a bare Go identifier
1372
+ // can never bind to a same-named TS/Python symbol that happens to share
1373
+ // the directory, nor to a struct FIELD or other member (members are
1374
+ // reachable only through a receiver). `_test` files share the directory
1375
+ // and slip through; acceptable — they really do call the target.
1376
+ if (!targetIsMember &&
1377
+ importingFile.language === 'go' &&
1378
+ this.fileByPath.get(targetFile)?.language === 'go' &&
1379
+ posix.dirname(ref.file) === posix.dirname(targetFile)) {
1380
+ return true;
1381
+ }
1382
+ const imports = this.importsByFile.get(ref.file) ?? [];
1383
+ let hasUnresolvableMatch = false;
1384
+ for (const imp of imports) {
1385
+ for (const named of imp.importedNames) {
1386
+ if (isWildcardImport(named)) {
1387
+ const resolved = this.resolveImportTarget(importingFile, imp.sourceModule);
1388
+ if (resolved === null) {
1389
+ hasUnresolvableMatch = true;
1390
+ }
1391
+ else if (resolved === targetFile) {
1392
+ return true;
1393
+ }
1394
+ continue;
1395
+ }
1396
+ const localName = named.alias ?? named.name;
1397
+ if (localName !== name)
1398
+ continue;
1399
+ if (!isValueBinding(named))
1400
+ continue;
1401
+ // The local binding `name` is an alias for a different export.
1402
+ // The alias loop attributes via resolveImportTarget; skip here
1403
+ // so the ref isn't double-attributed to a same-named homonym.
1404
+ if (isRenamingNamedAlias(named))
1405
+ return false;
1406
+ const resolved = this.resolveImportTarget(importingFile, imp.sourceModule);
1407
+ if (resolved === null) {
1408
+ hasUnresolvableMatch = true;
1409
+ continue;
1410
+ }
1411
+ if (resolved === targetFile)
1412
+ return true;
1413
+ // Specifier resolves to a different indexed file: bare `name`
1414
+ // binds elsewhere. JS/TS forbids duplicate top-level bindings,
1415
+ // so no other matching import can change that.
1416
+ return false;
1417
+ }
1418
+ }
1419
+ return hasUnresolvableMatch;
1420
+ }
1421
+ // Scoping for unresolved member refs (`receiver.name()`). Deliberately
1422
+ // asymmetric with the bare-name policy above: a bare `save()` with no
1423
+ // matching import provably binds locally (drop), but `obj.save()` says
1424
+ // nothing about where obj's class lives — and methods are unreachable
1425
+ // by any other Phase-1 mechanism — so unknown receivers weakly include
1426
+ // (recall over precision; isCallerOf and output labeling counterbalance).
1427
+ // Module/namespace receivers ARE statically resolvable: `utils.foo()`
1428
+ // binds to an export of whatever module `utils` names, so those admit
1429
+ // or drop precisely.
1430
+ memberRefMatchesTarget(ref, receiver, targetFile, targetIsMember) {
1431
+ // An opaque (chained/computed) receiver can never name an import binding,
1432
+ // so the scan below would always fall through to the weak include — skip
1433
+ // it, matching rankRefs and edgeStrength (the sibling classifiers named in
1434
+ // types.ts), which both short-circuit the same guaranteed-false scan for
1435
+ // RECEIVER_OPAQUE. Keeps the three in lockstep on the hot per-call path
1436
+ // (chained capture makes opaque the dominant member-ref shape).
1437
+ if (receiver === RECEIVER_OPAQUE)
1438
+ return true;
1439
+ const importingFile = this.fileByPath.get(ref.file);
1440
+ if (!importingFile)
1441
+ return true;
1442
+ for (const imp of this.importsByFile.get(ref.file) ?? []) {
1443
+ for (const named of imp.importedNames) {
1444
+ if ((named.alias ?? named.name) !== receiver)
1445
+ continue;
1446
+ if (named.kind === 'namespace' || named.kind === 'module') {
1447
+ // Module-object access reaches only TOP-LEVEL exports —
1448
+ // `utils.save()` can never invoke `Cache.prototype.save`, so a
1449
+ // class-member target is out of reach through this binding.
1450
+ if (targetIsMember)
1451
+ return false;
1452
+ const resolved = this.resolveImportTarget(importingFile, moduleSpecifierFor(imp, named));
1453
+ if (resolved === null)
1454
+ return true; // path alias / absolute py — best effort
1455
+ return resolved === targetFile;
1456
+ }
1457
+ // Type-only bindings are erased at runtime; member access through
1458
+ // them can't be a call into the target.
1459
+ if (named.kind === 'type')
1460
+ return false;
1461
+ // Value binding: the receiver is an object, and the defining file
1462
+ // of its class is statically unknowable — weak include.
1463
+ return true;
1464
+ }
1465
+ }
1466
+ // Unknown receiver (local, parameter, field) — weak name-grade match.
1467
+ return true;
1468
+ }
1469
+ // Returns the indexed file the specifier resolves to, or null if the
1470
+ // specifier is unrecognized (TS path alias, workspace pkg, Python
1471
+ // absolute import) or if no candidate is indexed. Picks the FIRST
1472
+ // indexed candidate (direct match, then suffix candidates in
1473
+ // language-specific order) — when the same stem is indexed under
1474
+ // multiple extensions (`foo.ts` AND `foo.js`), this disambiguates to
1475
+ // one. The candidate list and order come from `normalizeImportSpecifier`
1476
+ // and depend on the importer's language and the specifier's explicit
1477
+ // extension — see the *_CANDIDATES constants.
1478
+ resolveImportTarget(importingFile, sourceModule) {
1479
+ const norm = normalizeImportSpecifier(importingFile, sourceModule);
1480
+ if (norm === null)
1481
+ return null;
1482
+ const baseDir = posix.dirname(importingFile.path);
1483
+ const resolved = posix.join(baseDir, norm.specifier);
1484
+ if (this.fileByPath.has(resolved))
1485
+ return resolved;
1486
+ for (const suffix of norm.candidates) {
1487
+ const candidate = resolved + suffix;
1488
+ if (this.fileByPath.has(candidate))
1489
+ return candidate;
1490
+ }
1491
+ return null;
1492
+ }
1493
+ }
1494
+ // Translates a raw import specifier into a POSIX relative path plus the
1495
+ // candidate-suffix order that should follow. Returns null for specifiers
1496
+ // that can't be resolved without project config (bare specifiers, TS path
1497
+ // aliases, Python absolute imports, bare-package Python imports like
1498
+ // `from . import x` whose target is reached via the imported NAME).
1499
+ function normalizeImportSpecifier(importingFile, sourceModule) {
1500
+ // Python relative imports use dot-prefix syntax with no slashes:
1501
+ // `.utils`, `..pkg.sub`. n leading dots = (n-1) levels up; remaining
1502
+ // dots in the module path are package separators → '/'.
1503
+ if (importingFile.language === 'python') {
1504
+ const m = sourceModule.match(/^(\.+)(.*)$/);
1505
+ if (!m)
1506
+ return null;
1507
+ const [, dots, rest] = m;
1508
+ if (rest.length === 0)
1509
+ return null;
1510
+ const upParts = Array(dots.length - 1).fill('..');
1511
+ return {
1512
+ specifier: posix.join(...upParts, rest.replace(/\./g, '/')),
1513
+ candidates: PY_CANDIDATES,
1514
+ };
1515
+ }
1516
+ if (!sourceModule.startsWith('.'))
1517
+ return null;
1518
+ // JS importers can't resolve to TS source at runtime — keep explicit
1519
+ // extensions exact so the resolution returns the actual JS file. The
1520
+ // JS candidate list still includes `.ts/.tsx` as a fallback for the
1521
+ // extensionless case (`./foo`) under allowJs/checkJs projects.
1522
+ if (importingFile.language === 'javascript') {
1523
+ return { specifier: sourceModule, candidates: JS_CANDIDATES };
1524
+ }
1525
+ // TS/TSX with `.jsx` specifier: Node16/NodeNext emits `.jsx` for `.tsx`
1526
+ // source. Strip and use TS_FROM_JSX_CANDIDATES so `.tsx` wins over
1527
+ // `.ts` at the same stem.
1528
+ if (sourceModule.endsWith('.jsx')) {
1529
+ return {
1530
+ specifier: sourceModule.slice(0, -'.jsx'.length),
1531
+ candidates: TS_FROM_JSX_CANDIDATES,
1532
+ };
1533
+ }
1534
+ // TS/TSX with `.js` specifier: strip and use TS_FROM_JS_CANDIDATES so
1535
+ // explicit `.js` siblings beat `.ts` at the same stem (hand-written JS
1536
+ // in mixed repos), while still falling back to `.ts` when only the
1537
+ // source is indexed (Node16/NodeNext emit case). `.mjs`/`.cjs` are
1538
+ // literal JS targets — `endsWith('.js')` doesn't match them, so they
1539
+ // fall through to direct match. Direct `.ts`/`.tsx` specifiers also
1540
+ // fall through. (Add `.mts`/`.cts` to scanner + candidate lists if
1541
+ // those source kinds are ever supported.)
1542
+ if (sourceModule.endsWith('.js')) {
1543
+ return {
1544
+ specifier: sourceModule.slice(0, -'.js'.length),
1545
+ candidates: TS_FROM_JS_CANDIDATES,
1546
+ };
1547
+ }
1548
+ return { specifier: sourceModule, candidates: TS_CANDIDATES };
1549
+ }
1550
+ // Resolvable specifier for a module-object binding. Python's bare-dot form
1551
+ // (`from . import x`, `from .. import x`) binds the SUBMODULE x — the real
1552
+ // specifier is the dots plus the bound name; everything else (TS namespace
1553
+ // imports, Python `import a.b`) resolves by sourceModule directly. The
1554
+ // kind gate matters: a TS `import * as pkg from '.'` has named.name '*'
1555
+ // (the namespace sentinel), which must NOT be appended to the dots.
1556
+ function moduleSpecifierFor(imp, named) {
1557
+ return named.kind === 'module' &&
1558
+ /^\.+$/.test(imp.sourceModule) &&
1559
+ named.name !== imp.sourceModule
1560
+ ? `${imp.sourceModule}${named.name}`
1561
+ : imp.sourceModule;
1562
+ }
1563
+ // `import { X as Y }` where X is a regular identifier (not default or
1564
+ // namespace) and X !== Y. The local binding Y points to export X — bare
1565
+ // `Y()` calls bind to X, so same-name scoping by Y would misattribute.
1566
+ function isRenamingNamedAlias(named) {
1567
+ return (named.alias !== undefined &&
1568
+ named.alias !== named.name &&
1569
+ named.name !== IMPORT_DEFAULT &&
1570
+ named.name !== IMPORT_NAMESPACE);
1571
+ }
1572
+ // Python `from .x import *` — `alias === undefined` distinguishes it from
1573
+ // TS `import * as ns from './x'`, which carries alias='ns' and exposes
1574
+ // member access only (bare callees don't bind through it).
1575
+ export function isWildcardImport(named) {
1576
+ return named.name === IMPORT_NAMESPACE && named.alias === undefined;
1577
+ }
1578
+ // Bindings where bare `localName()` is a legitimate value-callable site.
1579
+ // kind='type' (TS `import type`), 'namespace' (TS `import * as ns`), and
1580
+ // 'module' (Python `import x` / `from . import x`) all bind something
1581
+ // that throws TypeError when invoked directly, so they shouldn't be
1582
+ // admitted as evidence that a bare call resolves through the import.
1583
+ // Absent kind defaults to 'value' for legacy persisted indexes.
1584
+ function isValueBinding(named) {
1585
+ return named.kind === undefined || named.kind === 'value';
1586
+ }
1587
+ // Filters refs to those that should be surfaced as callers of `target`.
1588
+ // Used by both find_references's renderCallers and getCallerCount so the
1589
+ // rendered list and `References: ~N` always agree.
1590
+ export function isCallerOf(ref, target) {
1591
+ // Recursion: target calling itself.
1592
+ if (ref.sourceId === target.id)
1593
+ return false;
1594
+ // Homonym already resolved precisely to a different same-named symbol.
1595
+ if (ref.targetId !== null && ref.targetId !== target.id)
1596
+ return false;
1597
+ if (ref.targetId === null) {
1598
+ const isMember = ref.receiver !== undefined;
1599
+ // Go package scope: a top-level name is unique within a package (one
1600
+ // directory), so same-directory Go refs warrant two exceptions below —
1601
+ // unexported MEMBERS are reachable package-wide, and a bare `Pairs{}`
1602
+ // composite literal / `Pairs(x)` conversion can only mean THE type.
1603
+ // Computed lazily: only the two carve-out branches consult it, and the
1604
+ // dominant refs (exported members, bare function calls, short names)
1605
+ // never reach them, so the dirname work is usually skipped.
1606
+ const goSamePackage = () => target.language === 'go' &&
1607
+ ref.file.endsWith('.go') &&
1608
+ posix.dirname(ref.file) === posix.dirname(target.file);
1609
+ // Self-receiver refs (extractor-determined: TS `this` node, Python
1610
+ // self/cls) that extract-time resolution did NOT bind to a sibling
1611
+ // method can only target an inherited method — LSP territory. An
1612
+ // ordinary receiver merely NAMED `self` is not affected.
1613
+ if (isMember && ref.selfReceiver)
1614
+ return false;
1615
+ // Bare-name matches never bind to method/interface/type — bare
1616
+ // `save()` calls a top-level function, not `C.prototype.save`.
1617
+ // Member matches (`obj.save()`) ARE evidence for methods — the point
1618
+ // of member extraction — but still never for interface/type, which
1619
+ // are never invoked at runtime. Go 'type'-kind symbols are the
1620
+ // exception: a same-package BARE composite literal / conversion
1621
+ // (`Pairs{}`, `Pairs(x)`) does target the type, and extract-time
1622
+ // resolution covers same-file only.
1623
+ if (NON_CALLABLE_KINDS.has(target.kind) &&
1624
+ !(isMember && target.kind === 'method') &&
1625
+ !(!isMember && target.kind === 'type' && goSamePackage())) {
1626
+ return false;
1627
+ }
1628
+ // Short names like `do`/`is` (and `x.get()`/`x.set()`) flood with
1629
+ // cross-file false matches; only count precisely-resolved refs.
1630
+ if (target.name.length < SHORT_NAME_THRESHOLD)
1631
+ return false;
1632
+ // Cross-file member access can only reach exported targets. (Python
1633
+ // exported-ness is the __all__/underscore heuristic, so legal
1634
+ // `utils._helper()` access is filtered — accepted Phase-1 precision.)
1635
+ // Go same-package siblings reach unexported MEMBERS (methods and
1636
+ // func-typed fields) legally — the member analog of the same-package
1637
+ // bare carve-out. Gated on the target actually being a member: a member
1638
+ // ref (`x.foo()`) can never reach a top-level function or variable.
1639
+ if (isMember &&
1640
+ ref.file !== target.file &&
1641
+ !target.exported &&
1642
+ !(isClassMember(target) && goSamePackage())) {
1643
+ return false;
1644
+ }
1645
+ }
1646
+ return true;
1647
+ }
1648
+ export function isClassMember(s) {
1649
+ return classNameFromFqn(s.fqn) !== null;
1650
+ }
1651
+ // Distinct transitive callers under a caller-tree root. Walks EVERY emitted
1652
+ // node (cycles included — shown once) and dedupes by symbolId, falling back to
1653
+ // `m:file:line` for module-level call sites (null symbolId). This is the exact
1654
+ // counting impact.ts renders ("N callers across D depths (F files)"), shared so
1655
+ // impact and the risk surface never diverge — and deduped, unlike
1656
+ // CallerTreeResult.totalNodes which double-counts DAG diamonds.
1657
+ export function countDistinctCallers(root) {
1658
+ const callers = new Set();
1659
+ const files = new Set();
1660
+ const depths = new Set();
1661
+ let depthCapped = false;
1662
+ const walk = (node) => {
1663
+ for (const child of node.children) {
1664
+ callers.add(child.symbolId ?? `m:${child.file}:${child.line}`);
1665
+ files.add(child.file);
1666
+ depths.add(child.depth);
1667
+ if (child.depthCapped)
1668
+ depthCapped = true;
1669
+ walk(child);
1670
+ }
1671
+ };
1672
+ walk(root);
1673
+ return { callers: callers.size, files: files.size, depths: depths.size, depthCapped };
1674
+ }
1675
+ // True when `imports` brings `name` into scope as a value binding the bare
1676
+ // call site could resolve to (named import, alias, or wildcard). Shared by
1677
+ // getCallerTree's edge classification and find_references' rankRefs so the
1678
+ // two stay in lockstep — a one-sided edit would desync impact's edge labels
1679
+ // from find_references' caller tiers.
1680
+ export function fileImportsName(imports, name) {
1681
+ for (const imp of imports) {
1682
+ for (const named of imp.importedNames) {
1683
+ if (named.name === name || named.alias === name)
1684
+ return true;
1685
+ if (isWildcardImport(named))
1686
+ return true;
1687
+ }
1688
+ }
1689
+ return false;
1690
+ }
1691
+ // True when `receiver` names a namespace/module import in `imports` — the only
1692
+ // receiver bindings whose target module is statically resolvable. Shared with
1693
+ // find_references' rankRefs (see fileImportsName).
1694
+ export function fileImportsReceiver(imports, receiver) {
1695
+ for (const imp of imports) {
1696
+ for (const named of imp.importedNames) {
1697
+ if (named.kind !== 'namespace' && named.kind !== 'module')
1698
+ continue;
1699
+ if ((named.alias ?? named.name) === receiver)
1700
+ return true;
1701
+ }
1702
+ }
1703
+ return false;
1704
+ }
1705
+ export function matchesKindScope(s, kind, scope) {
1706
+ if (kind && s.kind !== kind)
1707
+ return false;
1708
+ if (!scope)
1709
+ return true;
1710
+ return scope.endsWith('/') ? s.file.startsWith(scope) : s.file === scope;
1711
+ }
1712
+ function pushOrInit(map, key, value) {
1713
+ const list = map.get(key);
1714
+ if (list)
1715
+ list.push(value);
1716
+ else
1717
+ map.set(key, [value]);
1718
+ }
1719
+ function addAdjacency(map, key, value) {
1720
+ let set = map.get(key);
1721
+ if (!set) {
1722
+ set = new Set();
1723
+ map.set(key, set);
1724
+ }
1725
+ set.add(value);
1726
+ }
1727
+ function pruneAdjacency(map, deleted) {
1728
+ for (const [key, set] of map) {
1729
+ for (const id of deleted)
1730
+ set.delete(id);
1731
+ if (set.size === 0)
1732
+ map.delete(key);
1733
+ }
1734
+ }
1735
+ function adjacencyToEntries(map) {
1736
+ const out = [];
1737
+ for (const [k, set] of map)
1738
+ out.push([k, [...set]]);
1739
+ return out;
1740
+ }
1741
+ function lowerBound(arr, target) {
1742
+ let lo = 0;
1743
+ let hi = arr.length;
1744
+ while (lo < hi) {
1745
+ const mid = (lo + hi) >>> 1;
1746
+ if (arr[mid] < target)
1747
+ lo = mid + 1;
1748
+ else
1749
+ hi = mid;
1750
+ }
1751
+ return lo;
1752
+ }
1753
+ function isPersistedReference(ref) {
1754
+ if (typeof ref !== 'object' || ref === null)
1755
+ return false;
1756
+ const r = ref;
1757
+ return ((r.sourceId === null || typeof r.sourceId === 'string') &&
1758
+ (r.targetId === null || typeof r.targetId === 'string') &&
1759
+ typeof r.targetName === 'string' &&
1760
+ typeof r.kind === 'string' &&
1761
+ typeof r.file === 'string' &&
1762
+ typeof r.line === 'number' &&
1763
+ (r.receiver === undefined || typeof r.receiver === 'string') &&
1764
+ (r.selfReceiver === undefined || typeof r.selfReceiver === 'boolean'));
1765
+ }
1766
+ function isPersistedCoChange(value) {
1767
+ if (typeof value !== 'object' || value === null)
1768
+ return false;
1769
+ const c = value;
1770
+ return (typeof c.fileA === 'string' &&
1771
+ typeof c.fileB === 'string' &&
1772
+ typeof c.sharedCommits === 'number' &&
1773
+ typeof c.confidenceAB === 'number' &&
1774
+ typeof c.confidenceBA === 'number' &&
1775
+ typeof c.lastSeen === 'number');
1776
+ }
1777
+ function isPersistedGitMeta(value) {
1778
+ if (typeof value !== 'object' || value === null)
1779
+ return false;
1780
+ const m = value;
1781
+ return (typeof m.head === 'string' &&
1782
+ typeof m.windowDays === 'number' &&
1783
+ typeof m.analyzedAt === 'number');
1784
+ }
1785
+ function isValidPersisted(data, expectedVersion) {
1786
+ if (typeof data !== 'object' || data === null)
1787
+ return false;
1788
+ const d = data;
1789
+ return (d.version === expectedVersion &&
1790
+ typeof d.createdAt === 'number' &&
1791
+ typeof d.projectRoot === 'string' &&
1792
+ Array.isArray(d.symbols) &&
1793
+ Array.isArray(d.files) &&
1794
+ Array.isArray(d.imports) &&
1795
+ Array.isArray(d.callees) &&
1796
+ Array.isArray(d.callers) &&
1797
+ Array.isArray(d.references) &&
1798
+ Array.isArray(d.cochanges) &&
1799
+ Array.isArray(d.hotspots) &&
1800
+ (d.gitMeta === null || isPersistedGitMeta(d.gitMeta)));
1801
+ }