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.
- package/LICENSE +21 -0
- package/README.md +177 -0
- package/dist/config.js +223 -0
- package/dist/git/analyzer.js +177 -0
- package/dist/git/git-service.js +568 -0
- package/dist/git/head-watcher.js +113 -0
- package/dist/git/runner.js +204 -0
- package/dist/index.js +138 -0
- package/dist/indexer/code-index.js +1801 -0
- package/dist/indexer/complexity.js +633 -0
- package/dist/indexer/extractor.js +354 -0
- package/dist/indexer/languages/cpp.js +934 -0
- package/dist/indexer/languages/csharp.js +854 -0
- package/dist/indexer/languages/dart.js +777 -0
- package/dist/indexer/languages/go.js +665 -0
- package/dist/indexer/languages/java.js +507 -0
- package/dist/indexer/languages/kotlin.js +709 -0
- package/dist/indexer/languages/objc.js +397 -0
- package/dist/indexer/languages/php.js +771 -0
- package/dist/indexer/languages/python.js +455 -0
- package/dist/indexer/languages/ruby.js +697 -0
- package/dist/indexer/languages/rust.js +754 -0
- package/dist/indexer/languages/swift.js +691 -0
- package/dist/indexer/languages/typescript.js +485 -0
- package/dist/indexer/parser.js +175 -0
- package/dist/indexer/pipeline.js +342 -0
- package/dist/indexer/scanner.js +279 -0
- package/dist/indexer/watcher.js +353 -0
- package/dist/logger.js +16 -0
- package/dist/server.js +170 -0
- package/dist/tools/common.js +207 -0
- package/dist/tools/find-references.js +224 -0
- package/dist/tools/find-symbol.js +94 -0
- package/dist/tools/get-context.js +370 -0
- package/dist/tools/impact.js +218 -0
- package/dist/tools/overview.js +482 -0
- package/dist/tools/search-structure.js +303 -0
- package/dist/types.js +61 -0
- package/grammars/tree-sitter-c.wasm +0 -0
- package/grammars/tree-sitter-c_sharp.wasm +0 -0
- package/grammars/tree-sitter-cpp.wasm +0 -0
- package/grammars/tree-sitter-dart.wasm +0 -0
- package/grammars/tree-sitter-go.wasm +0 -0
- package/grammars/tree-sitter-java.wasm +0 -0
- package/grammars/tree-sitter-javascript.wasm +0 -0
- package/grammars/tree-sitter-kotlin.wasm +0 -0
- package/grammars/tree-sitter-objc.wasm +0 -0
- package/grammars/tree-sitter-php.wasm +0 -0
- package/grammars/tree-sitter-python.wasm +0 -0
- package/grammars/tree-sitter-ruby.wasm +0 -0
- package/grammars/tree-sitter-rust.wasm +0 -0
- package/grammars/tree-sitter-swift.wasm +0 -0
- package/grammars/tree-sitter-tsx.wasm +0 -0
- package/grammars/tree-sitter-typescript.wasm +0 -0
- 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
|
+
}
|