circle-ir 4.9.29 → 4.10.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/dist/analysis/config-loader.d.ts.map +1 -1
- package/dist/analysis/config-loader.js +23 -0
- package/dist/analysis/config-loader.js.map +1 -1
- package/dist/analyzer.d.ts +12 -0
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +1 -1
- package/dist/analyzer.js.map +1 -1
- package/dist/browser/circle-ir.js +95 -4
- package/dist/core/circle-ir-core.cjs +94 -3
- package/dist/core/circle-ir-core.js +94 -3
- package/dist/core/extractors/types.d.ts +28 -1
- package/dist/core/extractors/types.d.ts.map +1 -1
- package/dist/core/extractors/types.js +86 -3
- package/dist/core/extractors/types.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/navigation/index.d.ts +8 -0
- package/dist/navigation/index.d.ts.map +1 -0
- package/dist/navigation/index.js +6 -0
- package/dist/navigation/index.js.map +1 -0
- package/dist/navigation/navigation-index.d.ts +208 -0
- package/dist/navigation/navigation-index.d.ts.map +1 -0
- package/dist/navigation/navigation-index.js +968 -0
- package/dist/navigation/navigation-index.js.map +1 -0
- package/dist/navigation/types.d.ts +160 -0
- package/dist/navigation/types.d.ts.map +1 -0
- package/dist/navigation/types.js +17 -0
- package/dist/navigation/types.js.map +1 -0
- package/dist/types/index.d.ts +19 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,968 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Navigation queries — callers and callees, each answer carrying how it was
|
|
3
|
+
* reached and each non-answer carrying why.
|
|
4
|
+
*
|
|
5
|
+
* This is an **additive** surface. `CrossFileResolver.resolveCall` and
|
|
6
|
+
* `.findCallers` are untouched: the SAST passes and the taint corpora depend on
|
|
7
|
+
* their exact behaviour, and a change there ships behind the corpora gates, not
|
|
8
|
+
* with a navigation feature. So this module builds its own index from the same
|
|
9
|
+
* IR and answers its own questions.
|
|
10
|
+
*
|
|
11
|
+
* What it does that `resolveCall` does not, and why each one is here:
|
|
12
|
+
*
|
|
13
|
+
* - **A `record` receiver.** `resolveCall` cannot see a record because the
|
|
14
|
+
* default extraction emits no type for one. Asking `analyze` for
|
|
15
|
+
* `navigationTypes` fixes that upstream; this index then treats a record
|
|
16
|
+
* like any other class.
|
|
17
|
+
* - **A chained receiver.** `failed(this).feedback(x).output(y)` arrives with
|
|
18
|
+
* the receiver as an expression string. The chain is walked left to right,
|
|
19
|
+
* typing each step from the previous step's declared return type.
|
|
20
|
+
* - **A constructor.** `new Widget()` arrives as a call named `Widget` with
|
|
21
|
+
* no receiver. It is bound to `app.Widget.<init>`.
|
|
22
|
+
* - **A nested type's static import.** `import static app.Outer.Inner.make`
|
|
23
|
+
* names `app.Outer.Inner.make`, which only matches a symbol table that
|
|
24
|
+
* kept the nesting — hence `TypeInfo.enclosing_type`.
|
|
25
|
+
*
|
|
26
|
+
* Those four are not a wish list: they are every shape found by auditing the
|
|
27
|
+
* 8,015 call sites `resolveCall` leaves unanswered in a 405-file Java
|
|
28
|
+
* repository, of which 7,246 are correctly unanswered (the target is outside
|
|
29
|
+
* the tree) and 455 are these.
|
|
30
|
+
*
|
|
31
|
+
* What it deliberately does **not** do: invent a receiver type. Where no type
|
|
32
|
+
* is known the answer is `inferred` or there is no answer, and `inferred` is a
|
|
33
|
+
* permanent floor — see `Tier`.
|
|
34
|
+
*/
|
|
35
|
+
const REFLECTIVE_TYPES = new Set([
|
|
36
|
+
'Class', 'Method', 'Field', 'Constructor', 'AccessibleObject',
|
|
37
|
+
'MethodHandle', 'MethodHandles', 'Proxy', 'InvocationHandler', 'ClassLoader',
|
|
38
|
+
]);
|
|
39
|
+
const REFLECTIVE_METHODS = new Set([
|
|
40
|
+
'forName', 'setAccessible', 'getDeclaredMethod', 'getDeclaredField',
|
|
41
|
+
'getDeclaredConstructor', 'newProxyInstance', 'getAnnotation',
|
|
42
|
+
'getAnnotationsByType', 'isAnnotationPresent', 'getGenericReturnType',
|
|
43
|
+
]);
|
|
44
|
+
const LOMBOK_ACCESSOR = new Set(['Getter', 'Setter', 'Data', 'Value']);
|
|
45
|
+
const LOMBOK_BUILDER = new Set(['Builder', 'SuperBuilder']);
|
|
46
|
+
/** Bumped when the shape of a cached per-file record changes. */
|
|
47
|
+
const CACHE_VERSION = 'nav-1';
|
|
48
|
+
const fileCache = new Map();
|
|
49
|
+
export function buildNavigationIndex(files, options = {}) {
|
|
50
|
+
return new NavigationIndex(files, options);
|
|
51
|
+
}
|
|
52
|
+
export class NavigationIndex {
|
|
53
|
+
files = new Map();
|
|
54
|
+
typesByFqn = new Map();
|
|
55
|
+
typesBySimpleName = new Map();
|
|
56
|
+
/** Every method name declared anywhere in the tree — "is the target in scope at all?" */
|
|
57
|
+
declaredNames = new Set();
|
|
58
|
+
/** Package prefixes of the indexed files, longest first. */
|
|
59
|
+
projectPackages = [];
|
|
60
|
+
scope;
|
|
61
|
+
indexMs;
|
|
62
|
+
parseMs;
|
|
63
|
+
/** Direct subtypes, by supertype FQN. */
|
|
64
|
+
subtypes = new Map();
|
|
65
|
+
constructor(input, options) {
|
|
66
|
+
const t0 = now();
|
|
67
|
+
// A file in a language this index cannot resolve is **not searched**, and
|
|
68
|
+
// the scope must say so. Reporting it as searched was the first graded
|
|
69
|
+
// record's `:denominator-stated` deviation: four languages named, one
|
|
70
|
+
// resolved, and a file count a caller could divide an answer by and get
|
|
71
|
+
// a ratio that means nothing. The decline is recorded by language name,
|
|
72
|
+
// with the vocabulary reason, on every answer.
|
|
73
|
+
const declined = new Set();
|
|
74
|
+
for (const f of input) {
|
|
75
|
+
if (!this.parses(f.language)) {
|
|
76
|
+
declined.add(f.language);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const key = f.contentHash ?? (f.source !== undefined ? cheapHash(f.source) : undefined);
|
|
80
|
+
const cacheKey = key ? `${CACHE_VERSION}:${f.path}:${key}` : undefined;
|
|
81
|
+
let rec = options.cache && cacheKey ? fileCache.get(cacheKey) : undefined;
|
|
82
|
+
if (!rec) {
|
|
83
|
+
rec = buildFileRecord(f);
|
|
84
|
+
if (options.cache && cacheKey)
|
|
85
|
+
fileCache.set(cacheKey, rec);
|
|
86
|
+
}
|
|
87
|
+
this.files.set(f.path, rec);
|
|
88
|
+
}
|
|
89
|
+
const pkgs = new Set();
|
|
90
|
+
for (const rec of this.files.values()) {
|
|
91
|
+
if (rec.pkg)
|
|
92
|
+
pkgs.add(rec.pkg);
|
|
93
|
+
for (const t of rec.types) {
|
|
94
|
+
this.typesByFqn.set(t.fqn, t);
|
|
95
|
+
push(this.typesBySimpleName, t.simpleName, t);
|
|
96
|
+
for (const m of t.methods.keys())
|
|
97
|
+
this.declaredNames.add(m);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// The shortest distinct package prefixes, so `app.a` and `app.b` both
|
|
101
|
+
// count as inside the tree without listing every leaf.
|
|
102
|
+
this.projectPackages = shortestPrefixes([...pkgs]);
|
|
103
|
+
// Subtype edges, from simple names resolved in the declaring file.
|
|
104
|
+
for (const rec of this.files.values()) {
|
|
105
|
+
for (const t of rec.types) {
|
|
106
|
+
for (const sup of t.superNames) {
|
|
107
|
+
const supFqn = this.resolveTypeName(sup, rec) ?? sup;
|
|
108
|
+
push2(this.subtypes, supFqn, t.fqn);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const languages = [...new Set([...this.files.values()].map(f => f.language))].sort();
|
|
113
|
+
this.scope = {
|
|
114
|
+
searched: { files: this.files.size, languages },
|
|
115
|
+
excluded: [
|
|
116
|
+
...(options.excluded ?? []),
|
|
117
|
+
...[...declined].sort().map(lang => ({
|
|
118
|
+
pattern: lang,
|
|
119
|
+
reason: `unsupported-language: this index resolves ${languages.join(', ') || 'no language'}`,
|
|
120
|
+
})),
|
|
121
|
+
],
|
|
122
|
+
};
|
|
123
|
+
this.indexMs = now() - t0;
|
|
124
|
+
this.parseMs = options.parseMs ?? 0;
|
|
125
|
+
}
|
|
126
|
+
// ---------------------------------------------------------------- queries
|
|
127
|
+
/**
|
|
128
|
+
* Every call site that calls `symbol`, with how each was bound, plus every
|
|
129
|
+
* site that calls something of that *name* and could not be bound, with why.
|
|
130
|
+
*
|
|
131
|
+
* The second list is the point. A name-only search on a 405-file repository
|
|
132
|
+
* reported 147 callers of one method where the right answer was 0 `exact`,
|
|
133
|
+
* 69 unverified and 78 contradicted by the resolver's own receiver type. Here
|
|
134
|
+
* the 78 do not appear as answers at all, and the 69 arrive labelled.
|
|
135
|
+
*/
|
|
136
|
+
resolveCallers(query, opts = {}) {
|
|
137
|
+
const t0 = now();
|
|
138
|
+
const symbol = this.querySymbol(query, 'callers');
|
|
139
|
+
const answers = [];
|
|
140
|
+
const unresolved = [];
|
|
141
|
+
if (symbol === undefined) {
|
|
142
|
+
return this.finish('callers', query, answers, unresolved, t0, opts);
|
|
143
|
+
}
|
|
144
|
+
const wantedName = lastSegment(symbol);
|
|
145
|
+
for (const rec of this.files.values()) {
|
|
146
|
+
for (const call of rec.calls) {
|
|
147
|
+
if (!this.nameCouldMatch(call, wantedName, symbol, rec))
|
|
148
|
+
continue;
|
|
149
|
+
const bound = this.bind(call, rec);
|
|
150
|
+
if (bound && bound.target === symbol) {
|
|
151
|
+
answers.push({ ...bound, methodName: call.method_name, site: this.siteOf(call, rec) });
|
|
152
|
+
}
|
|
153
|
+
else if (!bound) {
|
|
154
|
+
unresolved.push({
|
|
155
|
+
site: this.siteOf(call, rec),
|
|
156
|
+
reason: this.reasonFor(call, rec),
|
|
157
|
+
methodName: call.method_name,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
// A site bound to a different target is neither: it is answered, elsewhere.
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return this.finish('callers', query, answers, unresolved, t0, opts);
|
|
164
|
+
}
|
|
165
|
+
/** Every call made from inside `symbol`'s body, bound the same way. */
|
|
166
|
+
resolveCallees(query, opts = {}) {
|
|
167
|
+
const t0 = now();
|
|
168
|
+
const symbol = this.querySymbol(query, 'callees');
|
|
169
|
+
const answers = [];
|
|
170
|
+
const unresolved = [];
|
|
171
|
+
if (symbol === undefined) {
|
|
172
|
+
return this.finish('callees', query, answers, unresolved, t0, opts);
|
|
173
|
+
}
|
|
174
|
+
const owner = this.typesByFqn.get(stripLast(symbol));
|
|
175
|
+
const method = owner?.methods.get(lastSegment(symbol));
|
|
176
|
+
if (!owner || !method) {
|
|
177
|
+
return this.finish('callees', query, answers, unresolved, t0, opts);
|
|
178
|
+
}
|
|
179
|
+
const rec = this.files.get(owner.file);
|
|
180
|
+
if (!rec)
|
|
181
|
+
return this.finish('callees', query, answers, unresolved, t0, opts);
|
|
182
|
+
for (const call of rec.calls) {
|
|
183
|
+
const line = call.location.line;
|
|
184
|
+
if (line < method.startLine || line > method.endLine)
|
|
185
|
+
continue;
|
|
186
|
+
const bound = this.bind(call, rec);
|
|
187
|
+
if (bound)
|
|
188
|
+
answers.push({ ...bound, methodName: call.method_name, site: this.siteOf(call, rec) });
|
|
189
|
+
else {
|
|
190
|
+
unresolved.push({
|
|
191
|
+
site: this.siteOf(call, rec),
|
|
192
|
+
reason: this.reasonFor(call, rec),
|
|
193
|
+
methodName: call.method_name,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return this.finish('callees', query, answers, unresolved, t0, opts);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* The symbol a call site names, so a caller holding a line of code can ask
|
|
201
|
+
* the same question as a caller holding a name.
|
|
202
|
+
*
|
|
203
|
+
* Keyed on `(file, line, methodName)` and **not** on a column: a chained
|
|
204
|
+
* expression reports several calls at one line and column, so a column
|
|
205
|
+
* cannot address one of them. The method name is what completes the key.
|
|
206
|
+
*
|
|
207
|
+
* For `callers` the symbol is the call's own target — "who else calls what
|
|
208
|
+
* this line calls". For `callees` it is the method whose body holds the
|
|
209
|
+
* line — "what does the method I am looking at call". Returns nothing when
|
|
210
|
+
* the site names no call, when the call cannot be bound to a target, or
|
|
211
|
+
* when the name is written twice on one line, which the key cannot separate.
|
|
212
|
+
*/
|
|
213
|
+
symbolAt(file, line, methodName, kind) {
|
|
214
|
+
const rec = this.files.get(file);
|
|
215
|
+
if (!rec)
|
|
216
|
+
return undefined;
|
|
217
|
+
if (kind === 'callees') {
|
|
218
|
+
const t = this.enclosingTypeAt(rec, line);
|
|
219
|
+
if (!t)
|
|
220
|
+
return undefined;
|
|
221
|
+
for (const [name, m] of t.methods) {
|
|
222
|
+
if (line >= m.startLine && line <= m.endLine)
|
|
223
|
+
return `${t.fqn}.${name}`;
|
|
224
|
+
}
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
const at = rec.calls.filter((c) => c.location.line === line && c.method_name === methodName);
|
|
228
|
+
// The same name twice on one line is beyond this key, and picking one of
|
|
229
|
+
// them would be a guess presented as an answer.
|
|
230
|
+
if (at.length !== 1)
|
|
231
|
+
return undefined;
|
|
232
|
+
return this.bind(at[0], rec)?.target;
|
|
233
|
+
}
|
|
234
|
+
// ------------------------------------------------------------ the binding
|
|
235
|
+
/**
|
|
236
|
+
* Bind one call site to a target, or to nothing.
|
|
237
|
+
*
|
|
238
|
+
* The order matters and is the whole contract: a receiver type is sought
|
|
239
|
+
* first, and only when none can be had does a name-only bind happen, which
|
|
240
|
+
* is labelled `inferred` and never anything better.
|
|
241
|
+
*/
|
|
242
|
+
bind(call, rec) {
|
|
243
|
+
// A constructor arrives as a call named after the type, with no receiver.
|
|
244
|
+
const ctor = this.asConstructor(call, rec);
|
|
245
|
+
if (ctor)
|
|
246
|
+
return ctor;
|
|
247
|
+
const recv = this.receiverTypeOf(call, rec);
|
|
248
|
+
// The receiver's type is known and is not ours. Binding by name here is
|
|
249
|
+
// precisely the contradiction the contract forbids: on a 405-file
|
|
250
|
+
// repository, 78 of 147 name-only "callers" of one method had a receiver
|
|
251
|
+
// whose own declared type could not have that method. A known foreign type
|
|
252
|
+
// is an answer about scope, not a weak answer about a target.
|
|
253
|
+
if (recv?.kind === 'foreign')
|
|
254
|
+
return undefined;
|
|
255
|
+
if (recv?.kind === 'project') {
|
|
256
|
+
const hit = this.lookupThroughHierarchy(recv.fqn, call.method_name);
|
|
257
|
+
if (hit)
|
|
258
|
+
return this.tierFor(hit.owner, call.method_name, recv.evidence);
|
|
259
|
+
// The receiver's type is known and does not have this method. Binding by
|
|
260
|
+
// name here would be the contradiction the audit found 78 of, so no.
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
// A statically imported member names its owner outright.
|
|
264
|
+
const owner = this.staticOwnerFor(call, rec);
|
|
265
|
+
if (owner) {
|
|
266
|
+
const hit = this.lookupThroughHierarchy(owner.fqn, call.method_name);
|
|
267
|
+
if (hit)
|
|
268
|
+
return this.tierFor(hit.owner, call.method_name, owner.evidence);
|
|
269
|
+
}
|
|
270
|
+
// No receiver at all: an unqualified call is `this` or the enclosing type's
|
|
271
|
+
// own static method. The enclosing type is known exactly, so this is not a
|
|
272
|
+
// guess — but it is still only a *declaration*, and the first graded record
|
|
273
|
+
// caught this path handing out `exact` on an abstract member whose body
|
|
274
|
+
// lives in a subclass. Every path to a target now goes through `tierFor`.
|
|
275
|
+
if (call.receiver === null && !call.receiver_type) {
|
|
276
|
+
const encl = this.enclosingTypeOf(call, rec);
|
|
277
|
+
if (encl) {
|
|
278
|
+
const hit = this.lookupThroughHierarchy(encl.fqn, call.method_name);
|
|
279
|
+
if (hit) {
|
|
280
|
+
return this.tierFor(hit.owner, call.method_name, `unqualified call inside ${encl.fqn}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
// Nothing typed the receiver. A name-only bind is the floor, and only when
|
|
285
|
+
// the name is unambiguous in the tree — a name owned by several types is
|
|
286
|
+
// not evidence of anything.
|
|
287
|
+
const byName = this.uniqueByName(call.method_name);
|
|
288
|
+
if (byName) {
|
|
289
|
+
return {
|
|
290
|
+
target: `${byName.fqn}.${call.method_name}`,
|
|
291
|
+
tier: 'inferred',
|
|
292
|
+
evidence: `bound by method name alone — no receiver type was available; ${call.method_name} is declared exactly once in the searched tree, on ${byName.fqn}`,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
return undefined;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* The tier for a target, once the declaring type is known.
|
|
299
|
+
*
|
|
300
|
+
* Every path to an answer goes through here, which is the point: the
|
|
301
|
+
* body-required rule was added in one place and the first graded record
|
|
302
|
+
* found it missing from another. `exact` is a promise that the target named
|
|
303
|
+
* is the code that runs, so it needs two things — the declaring type known,
|
|
304
|
+
* and the declaration **having a body**. An abstract member satisfies only
|
|
305
|
+
* the first; what runs there is a subclass's body or a lambda, never the
|
|
306
|
+
* member named. The target stays the declaring member, which is what a
|
|
307
|
+
* static resolver answers; only the tier changes.
|
|
308
|
+
*/
|
|
309
|
+
tierFor(owner, method, evidence) {
|
|
310
|
+
const impls = this.implementorsWith(owner.fqn, method);
|
|
311
|
+
const bodyless = this.isBodyless(owner, method);
|
|
312
|
+
if (impls.length > 1 || bodyless) {
|
|
313
|
+
return {
|
|
314
|
+
target: `${owner.fqn}.${method}`,
|
|
315
|
+
tier: 'polymorphic',
|
|
316
|
+
candidates: impls.map(t => `${t}.${method}`),
|
|
317
|
+
evidence: bodyless && impls.length === 0
|
|
318
|
+
? `${evidence}; ${method} is declared without a body on ${owner.kind} ${owner.fqn} and nothing in the searched tree implements it — what runs is an implementation or a lambda outside this index`
|
|
319
|
+
: `${evidence}; ${method} is declared on ${owner.kind} ${owner.fqn} with ${impls.length} implementor(s) in the searched tree`,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
target: `${owner.fqn}.${method}`,
|
|
324
|
+
tier: 'exact',
|
|
325
|
+
evidence: `${evidence}; ${method} is the unique method of that name on ${owner.fqn}, and it has a body`,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
/** `new Widget()` → `app.Widget.<init>`. */
|
|
329
|
+
asConstructor(call, rec) {
|
|
330
|
+
const looksCtor = call.is_constructor === true ||
|
|
331
|
+
(call.receiver === null &&
|
|
332
|
+
!!call.receiver_type &&
|
|
333
|
+
call.receiver_type === call.method_name &&
|
|
334
|
+
/^[A-Z]/.test(call.method_name));
|
|
335
|
+
if (!looksCtor)
|
|
336
|
+
return undefined;
|
|
337
|
+
const t = this.resolveTypeRecord(call.receiver_type ?? call.method_name, rec);
|
|
338
|
+
if (!t)
|
|
339
|
+
return undefined;
|
|
340
|
+
return {
|
|
341
|
+
target: `${t.fqn}.<init>`,
|
|
342
|
+
tier: 'exact',
|
|
343
|
+
evidence: `object creation of ${t.fqn}, declared at ${t.file}:${t.startLine}`,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* The receiver's static type, where it can be had without inventing one.
|
|
348
|
+
*
|
|
349
|
+
* Four sources, in order of how much they are trusted: the extractor's own
|
|
350
|
+
* fully-qualified answer, its simple-name answer resolved through this file's
|
|
351
|
+
* imports, a chain of calls whose declared return types can be walked, and a
|
|
352
|
+
* bare type name used as a static receiver.
|
|
353
|
+
*/
|
|
354
|
+
receiverTypeOf(call, rec) {
|
|
355
|
+
if (call.is_constructor)
|
|
356
|
+
return undefined;
|
|
357
|
+
if (call.receiver_type_fqn) {
|
|
358
|
+
const t = this.typesByFqn.get(call.receiver_type_fqn);
|
|
359
|
+
if (t) {
|
|
360
|
+
return { kind: 'project', fqn: t.fqn, evidence: `receiver type ${t.fqn} from the extractor` };
|
|
361
|
+
}
|
|
362
|
+
return {
|
|
363
|
+
kind: 'foreign',
|
|
364
|
+
name: call.receiver_type_fqn,
|
|
365
|
+
evidence: `receiver type ${call.receiver_type_fqn}, which is not declared in the searched tree`,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
if (call.receiver_type) {
|
|
369
|
+
const t = this.resolveTypeRecord(call.receiver_type, rec);
|
|
370
|
+
if (t) {
|
|
371
|
+
return {
|
|
372
|
+
kind: 'project',
|
|
373
|
+
fqn: t.fqn,
|
|
374
|
+
evidence: `receiver declared as ${call.receiver_type}, resolved to ${t.fqn}`,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
// `var` is the extractor saying it read an inferred declaration, not that
|
|
378
|
+
// it knows the type — so it is no type at all, not a foreign one.
|
|
379
|
+
if (call.receiver_type === 'var')
|
|
380
|
+
return undefined;
|
|
381
|
+
return {
|
|
382
|
+
kind: 'foreign',
|
|
383
|
+
name: call.receiver_type,
|
|
384
|
+
evidence: `receiver declared as ${call.receiver_type}, a type outside the searched tree`,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
return this.typeOfExpression(call.receiver, rec, 0);
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* The type a receiver *expression* evaluates to.
|
|
391
|
+
*
|
|
392
|
+
* `Builder.of().step("a")` is typed by resolving `Builder`, then taking
|
|
393
|
+
* `of`'s declared return type, then `step`'s. A step whose return type is
|
|
394
|
+
* unknown ends the walk and the whole expression is untyped — a half-walked
|
|
395
|
+
* chain is not a type.
|
|
396
|
+
*/
|
|
397
|
+
typeOfExpression(expr, rec, depth) {
|
|
398
|
+
if (!expr || depth > 8)
|
|
399
|
+
return undefined;
|
|
400
|
+
const text = expr.replace(/\s+/g, ' ').trim();
|
|
401
|
+
const chain = splitCallChain(text);
|
|
402
|
+
if (!chain)
|
|
403
|
+
return undefined;
|
|
404
|
+
let current;
|
|
405
|
+
let evidence = '';
|
|
406
|
+
// The root is a type name used statically, `this`, or a call on the
|
|
407
|
+
// enclosing type (a statically imported factory, or its own method).
|
|
408
|
+
const root = chain.root;
|
|
409
|
+
if (root.kind === 'type') {
|
|
410
|
+
current = this.resolveTypeRecord(root.name, rec);
|
|
411
|
+
if (!current)
|
|
412
|
+
return undefined;
|
|
413
|
+
evidence = `chain rooted at type ${current.fqn}`;
|
|
414
|
+
}
|
|
415
|
+
else if (root.kind === 'this') {
|
|
416
|
+
current = this.enclosingTypeAt(rec, chain.steps[0]?.line ?? 0) ?? rec.types[0];
|
|
417
|
+
if (!current)
|
|
418
|
+
return undefined;
|
|
419
|
+
evidence = `chain rooted at this (${current.fqn})`;
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
// A bare call: `failed(this).feedback(x)`. Its owner is a static import
|
|
423
|
+
// or the enclosing type.
|
|
424
|
+
const ownerFqn = rec.staticImports.get(root.name) ??
|
|
425
|
+
rec.staticWildcards.find(w => this.typesByFqn.get(w)?.methods.has(root.name)) ??
|
|
426
|
+
rec.types.find(t => t.methods.has(root.name))?.fqn;
|
|
427
|
+
const owner = ownerFqn ? this.typesByFqn.get(ownerFqn) : undefined;
|
|
428
|
+
const m = owner?.methods.get(root.name);
|
|
429
|
+
if (!owner || !m || !m.returnType)
|
|
430
|
+
return undefined;
|
|
431
|
+
current = this.resolveTypeRecord(m.returnType, rec);
|
|
432
|
+
if (!current)
|
|
433
|
+
return undefined;
|
|
434
|
+
evidence = `chain rooted at ${owner.fqn}.${root.name}, which returns ${m.returnType}`;
|
|
435
|
+
}
|
|
436
|
+
for (const step of chain.steps) {
|
|
437
|
+
const hit = this.lookupThroughHierarchy(current.fqn, step.name);
|
|
438
|
+
const m = hit?.owner.methods.get(step.name);
|
|
439
|
+
if (!m || !m.returnType)
|
|
440
|
+
return undefined;
|
|
441
|
+
const next = this.resolveTypeRecord(m.returnType, rec);
|
|
442
|
+
if (!next)
|
|
443
|
+
return undefined;
|
|
444
|
+
evidence += ` → ${step.name}() returns ${m.returnType}`;
|
|
445
|
+
current = next;
|
|
446
|
+
}
|
|
447
|
+
return { kind: 'project', fqn: current.fqn, evidence };
|
|
448
|
+
}
|
|
449
|
+
/** The owner named by a static import of this call's method. */
|
|
450
|
+
staticOwnerFor(call, rec) {
|
|
451
|
+
if (call.receiver !== null)
|
|
452
|
+
return undefined;
|
|
453
|
+
const direct = rec.staticImports.get(call.method_name);
|
|
454
|
+
if (direct && this.typesByFqn.has(direct)) {
|
|
455
|
+
return { fqn: direct, evidence: `statically imported from ${direct}` };
|
|
456
|
+
}
|
|
457
|
+
for (const w of rec.staticWildcards) {
|
|
458
|
+
if (this.typesByFqn.get(w)?.methods.has(call.method_name)) {
|
|
459
|
+
return { fqn: w, evidence: `statically imported from ${w}.*` };
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return undefined;
|
|
463
|
+
}
|
|
464
|
+
enclosingTypeOf(call, rec) {
|
|
465
|
+
return this.enclosingTypeAt(rec, call.location.line);
|
|
466
|
+
}
|
|
467
|
+
/** The innermost declared type whose line range holds `line`. */
|
|
468
|
+
enclosingTypeAt(rec, line) {
|
|
469
|
+
let best;
|
|
470
|
+
for (const t of rec.types) {
|
|
471
|
+
if (line < t.startLine || line > t.endLine)
|
|
472
|
+
continue;
|
|
473
|
+
if (!best || t.startLine > best.startLine)
|
|
474
|
+
best = t;
|
|
475
|
+
}
|
|
476
|
+
return best ?? rec.types[0];
|
|
477
|
+
}
|
|
478
|
+
/** Find `method` on `fqn` or on a supertype of it, inside the tree. */
|
|
479
|
+
lookupThroughHierarchy(fqn, method, seen = new Set()) {
|
|
480
|
+
if (seen.has(fqn))
|
|
481
|
+
return undefined;
|
|
482
|
+
seen.add(fqn);
|
|
483
|
+
const t = this.typesByFqn.get(fqn);
|
|
484
|
+
if (!t)
|
|
485
|
+
return undefined;
|
|
486
|
+
if (t.methods.has(method))
|
|
487
|
+
return { owner: t };
|
|
488
|
+
const rec = this.files.get(t.file);
|
|
489
|
+
for (const sup of t.superNames) {
|
|
490
|
+
const supFqn = rec ? this.resolveTypeName(sup, rec) : undefined;
|
|
491
|
+
if (!supFqn)
|
|
492
|
+
continue;
|
|
493
|
+
const hit = this.lookupThroughHierarchy(supFqn, method, seen);
|
|
494
|
+
if (hit)
|
|
495
|
+
return hit;
|
|
496
|
+
}
|
|
497
|
+
return undefined;
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Whether a declaration carries no body, so nothing it names can run.
|
|
501
|
+
*
|
|
502
|
+
* A class method says so itself: `abstract` is in its modifiers. An
|
|
503
|
+
* interface method does not — the Java extraction reports no modifiers for
|
|
504
|
+
* either an abstract member or a `default` one — so the declaration line is
|
|
505
|
+
* read from the source, where `default` and `static` are the only two ways
|
|
506
|
+
* an interface member can have a body. `default` and `static` precede the
|
|
507
|
+
* return type, so they are on the declaration's first line.
|
|
508
|
+
*
|
|
509
|
+
* With no source supplied, an interface member is treated as body-less.
|
|
510
|
+
* That is the conservative direction: it costs an answer its `exact` label
|
|
511
|
+
* and never grants one.
|
|
512
|
+
*/
|
|
513
|
+
isBodyless(owner, method) {
|
|
514
|
+
const m = owner.methods.get(method);
|
|
515
|
+
if (!m)
|
|
516
|
+
return false;
|
|
517
|
+
if (m.modifiers.includes('abstract'))
|
|
518
|
+
return true;
|
|
519
|
+
if (owner.kind !== 'interface')
|
|
520
|
+
return false;
|
|
521
|
+
if (m.modifiers.includes('static') || m.modifiers.includes('default'))
|
|
522
|
+
return false;
|
|
523
|
+
const line = this.files.get(owner.file)?.lines?.[m.startLine - 1];
|
|
524
|
+
if (line === undefined)
|
|
525
|
+
return true;
|
|
526
|
+
return !/\b(default|static)\b/.test(line);
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Types in the tree that declare a **runnable** `method` and are `fqn` or
|
|
530
|
+
* below it.
|
|
531
|
+
*
|
|
532
|
+
* "Runnable" is the whole of it: `candidates` answers "which body could
|
|
533
|
+
* run", so an abstract declaration does not belong there even when it is
|
|
534
|
+
* the type the walk started from. An `abstract class Base { abstract step(); }`
|
|
535
|
+
* with nothing overriding `step` has no candidates, and saying so is the
|
|
536
|
+
* honest answer — listing `Base.step` would name a body that does not exist.
|
|
537
|
+
*/
|
|
538
|
+
implementorsWith(fqn, method) {
|
|
539
|
+
const out = [];
|
|
540
|
+
const walk = (f, seen) => {
|
|
541
|
+
if (seen.has(f))
|
|
542
|
+
return;
|
|
543
|
+
seen.add(f);
|
|
544
|
+
const t = this.typesByFqn.get(f);
|
|
545
|
+
if (t && t.methods.has(method) && t.kind !== 'interface' && !this.isBodyless(t, method)) {
|
|
546
|
+
out.push(f);
|
|
547
|
+
}
|
|
548
|
+
for (const sub of this.subtypes.get(f) ?? [])
|
|
549
|
+
walk(sub, seen);
|
|
550
|
+
};
|
|
551
|
+
walk(fqn, new Set());
|
|
552
|
+
return out;
|
|
553
|
+
}
|
|
554
|
+
/** The one type in the tree declaring `name`, or nothing if it is not unique. */
|
|
555
|
+
uniqueByName(name) {
|
|
556
|
+
const owners = [];
|
|
557
|
+
for (const t of this.typesByFqn.values()) {
|
|
558
|
+
if (t.methods.has(name))
|
|
559
|
+
owners.push(t);
|
|
560
|
+
if (owners.length > 1)
|
|
561
|
+
return undefined;
|
|
562
|
+
}
|
|
563
|
+
return owners[0];
|
|
564
|
+
}
|
|
565
|
+
/** A simple or qualified type name, resolved from one file's point of view. */
|
|
566
|
+
resolveTypeRecord(name, rec) {
|
|
567
|
+
const fqn = this.resolveTypeName(name, rec);
|
|
568
|
+
return fqn ? this.typesByFqn.get(fqn) : undefined;
|
|
569
|
+
}
|
|
570
|
+
resolveTypeName(name, rec) {
|
|
571
|
+
const bare = name.replace(/<.*>$/, '').replace(/\[\]$/, '').trim();
|
|
572
|
+
if (!bare)
|
|
573
|
+
return undefined;
|
|
574
|
+
if (this.typesByFqn.has(bare))
|
|
575
|
+
return bare;
|
|
576
|
+
const imported = rec.imports.get(bare);
|
|
577
|
+
if (imported && this.typesByFqn.has(imported))
|
|
578
|
+
return imported;
|
|
579
|
+
// Same package, including a nested type of a type in this file.
|
|
580
|
+
const samePkg = rec.pkg ? `${rec.pkg}.${bare}` : bare;
|
|
581
|
+
if (this.typesByFqn.has(samePkg))
|
|
582
|
+
return samePkg;
|
|
583
|
+
for (const t of rec.types) {
|
|
584
|
+
const nested = `${t.fqn}.${bare}`;
|
|
585
|
+
if (this.typesByFqn.has(nested))
|
|
586
|
+
return nested;
|
|
587
|
+
}
|
|
588
|
+
// A unique simple name anywhere in the tree. Ambiguity resolves to nothing
|
|
589
|
+
// rather than to a guess.
|
|
590
|
+
const byName = this.typesBySimpleName.get(bare);
|
|
591
|
+
if (byName && byName.length === 1)
|
|
592
|
+
return byName[0].fqn;
|
|
593
|
+
return undefined;
|
|
594
|
+
}
|
|
595
|
+
// ------------------------------------------------------------- the reason
|
|
596
|
+
/**
|
|
597
|
+
* Why a site in range produced no answer.
|
|
598
|
+
*
|
|
599
|
+
* Every branch rests on something read from the source, not on a default.
|
|
600
|
+
* `unknown` is the honest end of the list: the target is in the tree and this
|
|
601
|
+
* index failed to bind it, which is a different claim from the other five and
|
|
602
|
+
* is reported as its own share.
|
|
603
|
+
*/
|
|
604
|
+
reasonFor(call, rec) {
|
|
605
|
+
if (!rec.parseOk)
|
|
606
|
+
return 'parse-error';
|
|
607
|
+
if (!this.parses(rec.language))
|
|
608
|
+
return 'unsupported-language';
|
|
609
|
+
if (REFLECTIVE_TYPES.has(call.receiver_type ?? '') ||
|
|
610
|
+
REFLECTIVE_METHODS.has(call.method_name) ||
|
|
611
|
+
/\bgetClass\s*\(\s*\)\s*$|\.class$|Class\.forName/.test(call.receiver ?? '')) {
|
|
612
|
+
return 'dynamic';
|
|
613
|
+
}
|
|
614
|
+
// A member that only exists after annotation processing. The receiver's
|
|
615
|
+
// type is ours, the member is not written anywhere in it, and an
|
|
616
|
+
// annotation on the type or the field would have generated it.
|
|
617
|
+
const recvType = (call.receiver_type_fqn ? this.typesByFqn.get(call.receiver_type_fqn) : undefined) ??
|
|
618
|
+
(call.receiver_type ? this.resolveTypeRecord(call.receiver_type, rec) : undefined) ??
|
|
619
|
+
(call.receiver === null ? this.enclosingTypeOf(call, rec) : undefined);
|
|
620
|
+
if (recvType && this.wouldBeGenerated(recvType, call.method_name))
|
|
621
|
+
return 'generated';
|
|
622
|
+
if (call.receiver && this.builderChainWouldGenerate(call, rec))
|
|
623
|
+
return 'generated';
|
|
624
|
+
// Outside the tree: the extractor gave a package that is not ours, or an
|
|
625
|
+
// import does, or the name is declared in no file we indexed.
|
|
626
|
+
const fqn = call.receiver_type_fqn;
|
|
627
|
+
if (fqn && !this.isProjectFqn(fqn))
|
|
628
|
+
return 'external';
|
|
629
|
+
if (call.receiver_type) {
|
|
630
|
+
const imported = rec.imports.get(call.receiver_type);
|
|
631
|
+
if (imported && !this.isProjectFqn(imported))
|
|
632
|
+
return 'external';
|
|
633
|
+
}
|
|
634
|
+
if (call.receiver === null) {
|
|
635
|
+
const stat = rec.staticImports.get(call.method_name);
|
|
636
|
+
if (stat && !this.isProjectFqn(stat))
|
|
637
|
+
return 'external';
|
|
638
|
+
}
|
|
639
|
+
if (!this.declaredNames.has(call.method_name))
|
|
640
|
+
return 'external';
|
|
641
|
+
return 'unknown';
|
|
642
|
+
}
|
|
643
|
+
/** `@Getter`/`@Setter`/`@Data`/`@Builder` would add this member. */
|
|
644
|
+
wouldBeGenerated(t, method) {
|
|
645
|
+
const seen = new Set();
|
|
646
|
+
const walk = (fqn) => {
|
|
647
|
+
if (seen.has(fqn))
|
|
648
|
+
return false;
|
|
649
|
+
seen.add(fqn);
|
|
650
|
+
const rt = this.typesByFqn.get(fqn);
|
|
651
|
+
if (!rt)
|
|
652
|
+
return false;
|
|
653
|
+
if (rt.methods.has(method))
|
|
654
|
+
return false;
|
|
655
|
+
const anns = new Set(rt.annotations);
|
|
656
|
+
const lower = (s) => s.charAt(0).toLowerCase() + s.slice(1);
|
|
657
|
+
const any = (set) => [...anns].some(a => set.has(a));
|
|
658
|
+
if (any(LOMBOK_BUILDER) && (method === 'builder' || method === 'toBuilder'))
|
|
659
|
+
return true;
|
|
660
|
+
if (any(LOMBOK_ACCESSOR)) {
|
|
661
|
+
if (/^get[A-Z]/.test(method) && rt.fieldNames.includes(lower(method.slice(3))))
|
|
662
|
+
return true;
|
|
663
|
+
if (/^is[A-Z]/.test(method) && rt.fieldNames.includes(lower(method.slice(2))))
|
|
664
|
+
return true;
|
|
665
|
+
if (/^set[A-Z]/.test(method) && rt.fieldNames.includes(lower(method.slice(3))))
|
|
666
|
+
return true;
|
|
667
|
+
}
|
|
668
|
+
if (/^get[A-Z]/.test(method) && rt.getterFields.includes(lower(method.slice(3))))
|
|
669
|
+
return true;
|
|
670
|
+
if (/^is[A-Z]/.test(method) && rt.getterFields.includes(lower(method.slice(2))))
|
|
671
|
+
return true;
|
|
672
|
+
if (/^set[A-Z]/.test(method) && rt.setterFields.includes(lower(method.slice(3))))
|
|
673
|
+
return true;
|
|
674
|
+
if (anns.has('Slf4j') && ['info', 'warn', 'error', 'debug', 'trace'].includes(method))
|
|
675
|
+
return true;
|
|
676
|
+
const frec = this.files.get(rt.file);
|
|
677
|
+
for (const sup of rt.superNames) {
|
|
678
|
+
const supFqn = frec ? this.resolveTypeName(sup, frec) : undefined;
|
|
679
|
+
if (supFqn && walk(supFqn))
|
|
680
|
+
return true;
|
|
681
|
+
}
|
|
682
|
+
return false;
|
|
683
|
+
};
|
|
684
|
+
return walk(t.fqn);
|
|
685
|
+
}
|
|
686
|
+
/** `Email.builder().title(...)` — a builder setter named after a field. */
|
|
687
|
+
builderChainWouldGenerate(call, rec) {
|
|
688
|
+
const m = /^([A-Z]\w*)\s*\.\s*builder\s*\(/.exec((call.receiver ?? '').replace(/\s+/g, ' '));
|
|
689
|
+
if (!m)
|
|
690
|
+
return false;
|
|
691
|
+
const t = this.resolveTypeRecord(m[1], rec);
|
|
692
|
+
if (!t)
|
|
693
|
+
return false;
|
|
694
|
+
const builds = t.annotations.some(a => LOMBOK_BUILDER.has(a));
|
|
695
|
+
return builds && t.fieldNames.includes(call.method_name);
|
|
696
|
+
}
|
|
697
|
+
isProjectFqn(fqn) {
|
|
698
|
+
return this.projectPackages.some(p => fqn === p || fqn.startsWith(`${p}.`));
|
|
699
|
+
}
|
|
700
|
+
parses(language) {
|
|
701
|
+
return language === 'java';
|
|
702
|
+
}
|
|
703
|
+
// -------------------------------------------------------------- plumbing
|
|
704
|
+
/** A site whose method name could be the one asked for. */
|
|
705
|
+
nameCouldMatch(call, wantedName, symbol, rec) {
|
|
706
|
+
if (wantedName === '<init>') {
|
|
707
|
+
const typeFqn = stripLast(symbol);
|
|
708
|
+
const t = this.typesByFqn.get(typeFqn);
|
|
709
|
+
return !!t && (call.is_constructor === true || call.method_name === t.simpleName);
|
|
710
|
+
}
|
|
711
|
+
void rec;
|
|
712
|
+
return call.method_name === wantedName;
|
|
713
|
+
}
|
|
714
|
+
querySymbol(query, kind) {
|
|
715
|
+
if (query.symbol)
|
|
716
|
+
return query.symbol;
|
|
717
|
+
if (!query.site)
|
|
718
|
+
return undefined;
|
|
719
|
+
// A site query on `callers` means "who calls the method this site calls";
|
|
720
|
+
// on `callees` it means "what does the method holding this site call".
|
|
721
|
+
const rec = this.files.get(query.site.file);
|
|
722
|
+
if (!rec)
|
|
723
|
+
return undefined;
|
|
724
|
+
// A (line, col) triple can name several calls in a chained expression, so
|
|
725
|
+
// a site query that does not say which method it means is ambiguous and
|
|
726
|
+
// gets no answer rather than an arbitrary one.
|
|
727
|
+
const at = rec.calls.filter(c => c.location.line === query.site.line && c.location.column === query.site.col);
|
|
728
|
+
const call = at.length === 1 ? at[0] : undefined;
|
|
729
|
+
if (kind === 'callers') {
|
|
730
|
+
if (!call)
|
|
731
|
+
return undefined;
|
|
732
|
+
return this.bind(call, rec)?.target;
|
|
733
|
+
}
|
|
734
|
+
const t = this.enclosingTypeAt(rec, query.site.line);
|
|
735
|
+
if (!t)
|
|
736
|
+
return undefined;
|
|
737
|
+
for (const [name, m] of t.methods) {
|
|
738
|
+
if (query.site.line >= m.startLine && query.site.line <= m.endLine) {
|
|
739
|
+
return `${t.fqn}.${name}`;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
return undefined;
|
|
743
|
+
}
|
|
744
|
+
siteOf(call, rec) {
|
|
745
|
+
const site = {
|
|
746
|
+
file: rec.path,
|
|
747
|
+
line: call.location.line,
|
|
748
|
+
col: call.location.column,
|
|
749
|
+
inMethod: call.in_method ?? null,
|
|
750
|
+
};
|
|
751
|
+
const text = rec.lines?.[call.location.line - 1];
|
|
752
|
+
if (text !== undefined)
|
|
753
|
+
site.text = text.trim();
|
|
754
|
+
return site;
|
|
755
|
+
}
|
|
756
|
+
finish(kind, query, answers, unresolved, t0, opts) {
|
|
757
|
+
let kept = answers;
|
|
758
|
+
if (opts.tiers)
|
|
759
|
+
kept = kept.filter(a => opts.tiers.includes(a.tier));
|
|
760
|
+
kept = [...kept].sort((a, b) => a.site.file.localeCompare(b.site.file) || a.site.line - b.site.line);
|
|
761
|
+
const total = kept.length;
|
|
762
|
+
let truncated;
|
|
763
|
+
if (opts.limit !== undefined && total > opts.limit) {
|
|
764
|
+
kept = kept.slice(0, opts.limit);
|
|
765
|
+
truncated = { returned: kept.length, total };
|
|
766
|
+
}
|
|
767
|
+
const out = {
|
|
768
|
+
query: { kind, ...(query.symbol ? { symbol: query.symbol } : { site: query.site }) },
|
|
769
|
+
answers: kept,
|
|
770
|
+
unresolved: [...unresolved].sort((a, b) => a.site.file.localeCompare(b.site.file) || a.site.line - b.site.line),
|
|
771
|
+
scope: this.scope,
|
|
772
|
+
timing: {
|
|
773
|
+
parseMs: round(this.parseMs),
|
|
774
|
+
indexMs: round(this.indexMs),
|
|
775
|
+
queryMs: round(now() - t0),
|
|
776
|
+
},
|
|
777
|
+
};
|
|
778
|
+
if (truncated)
|
|
779
|
+
out.truncated = truncated;
|
|
780
|
+
return out;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
// ------------------------------------------------------------------ helpers
|
|
784
|
+
function buildFileRecord(f) {
|
|
785
|
+
const pkg = f.ir.meta?.package ?? '';
|
|
786
|
+
const imports = new Map();
|
|
787
|
+
const staticImports = new Map();
|
|
788
|
+
const staticWildcards = [];
|
|
789
|
+
for (const imp of f.ir.imports ?? []) {
|
|
790
|
+
const from = imp.from_package ?? '';
|
|
791
|
+
const name = imp.imported_name ?? '';
|
|
792
|
+
if (!from && !name)
|
|
793
|
+
continue;
|
|
794
|
+
if (imp.is_wildcard) {
|
|
795
|
+
// `import static app.Outer.Inner.*` and `import app.pkg.*` are not
|
|
796
|
+
// distinguished by the IR; treating the package as a static owner is
|
|
797
|
+
// harmless, because an owner that is not a type in the tree never matches.
|
|
798
|
+
staticWildcards.push(from);
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
// A static import's `from_package` is the owning *type*, not a package —
|
|
802
|
+
// `import static app.Outer.Inner.make` gives `app.Outer.Inner` / `make`.
|
|
803
|
+
staticImports.set(name, from);
|
|
804
|
+
imports.set(name, from ? `${from}.${name}` : name);
|
|
805
|
+
}
|
|
806
|
+
const types = [];
|
|
807
|
+
for (const t of f.ir.types ?? []) {
|
|
808
|
+
const tpkg = t.package ?? pkg;
|
|
809
|
+
const path = [tpkg, t.enclosing_type, t.name].filter(Boolean).join('.');
|
|
810
|
+
const methods = new Map();
|
|
811
|
+
for (const m of t.methods ?? []) {
|
|
812
|
+
// A later overload does not replace an earlier one's range; the widest
|
|
813
|
+
// range wins, so a callee query over the method's body sees all of it.
|
|
814
|
+
const prev = methods.get(m.name);
|
|
815
|
+
methods.set(m.name, {
|
|
816
|
+
returnType: m.return_type ?? prev?.returnType ?? null,
|
|
817
|
+
startLine: prev ? Math.min(prev.startLine, m.start_line) : m.start_line,
|
|
818
|
+
endLine: prev ? Math.max(prev.endLine, m.end_line) : m.end_line,
|
|
819
|
+
modifiers: [...new Set([...(prev?.modifiers ?? []), ...(m.modifiers ?? [])])],
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
types.push({
|
|
823
|
+
fqn: path,
|
|
824
|
+
simpleName: t.name,
|
|
825
|
+
kind: t.kind,
|
|
826
|
+
isRecord: t.is_record === true,
|
|
827
|
+
file: f.path,
|
|
828
|
+
superNames: [t.extends, ...(t.implements ?? [])].filter((x) => !!x),
|
|
829
|
+
annotations: (t.annotations ?? []).map(stripAnnotation),
|
|
830
|
+
fieldNames: (t.fields ?? []).map(x => x.name),
|
|
831
|
+
getterFields: lombokFields(f.source, 'Getter'),
|
|
832
|
+
setterFields: lombokFields(f.source, 'Setter'),
|
|
833
|
+
methods,
|
|
834
|
+
startLine: t.start_line,
|
|
835
|
+
endLine: t.end_line,
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
return {
|
|
839
|
+
path: f.path,
|
|
840
|
+
language: f.language,
|
|
841
|
+
pkg,
|
|
842
|
+
parseOk: f.ir.parse_status ? f.ir.parse_status.success !== false : true,
|
|
843
|
+
imports,
|
|
844
|
+
staticImports,
|
|
845
|
+
staticWildcards,
|
|
846
|
+
types,
|
|
847
|
+
calls: f.ir.calls ?? [],
|
|
848
|
+
lines: f.source?.split('\n'),
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
/** `@Getter` written on a field, which no extractor reports as an annotation. */
|
|
852
|
+
function lombokFields(source, kind) {
|
|
853
|
+
if (!source)
|
|
854
|
+
return [];
|
|
855
|
+
const re = new RegExp(`@${kind}(?:\\([^)]*\\))?[^;\\n]*?\\b(\\w+)\\s*(?:=|;)`, 'g');
|
|
856
|
+
return [...new Set([...source.matchAll(re)].map(m => m[1]))];
|
|
857
|
+
}
|
|
858
|
+
function stripAnnotation(a) {
|
|
859
|
+
return a.replace(/^@/, '').replace(/\(.*$/, '').split('.').pop() ?? a;
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Split a receiver expression into a root and a list of `.name(...)` steps.
|
|
863
|
+
*
|
|
864
|
+
* Returns nothing for anything that is not a chain of calls on a nameable
|
|
865
|
+
* root — an array index, a ternary, an arithmetic expression. Not typing an
|
|
866
|
+
* expression is the correct outcome for all of those.
|
|
867
|
+
*/
|
|
868
|
+
function splitCallChain(text) {
|
|
869
|
+
// Walk the string, splitting on top-level dots only, so a dot inside an
|
|
870
|
+
// argument list or a string literal does not end a step.
|
|
871
|
+
const parts = [];
|
|
872
|
+
let depth = 0;
|
|
873
|
+
let quote = null;
|
|
874
|
+
let buf = '';
|
|
875
|
+
for (let i = 0; i < text.length; i++) {
|
|
876
|
+
const ch = text[i];
|
|
877
|
+
if (quote) {
|
|
878
|
+
buf += ch;
|
|
879
|
+
if (ch === quote && text[i - 1] !== '\\')
|
|
880
|
+
quote = null;
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
if (ch === '"' || ch === "'") {
|
|
884
|
+
quote = ch;
|
|
885
|
+
buf += ch;
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
if (ch === '(' || ch === '[' || ch === '<')
|
|
889
|
+
depth++;
|
|
890
|
+
if (ch === ')' || ch === ']' || ch === '>')
|
|
891
|
+
depth--;
|
|
892
|
+
if (ch === '.' && depth === 0) {
|
|
893
|
+
parts.push(buf);
|
|
894
|
+
buf = '';
|
|
895
|
+
continue;
|
|
896
|
+
}
|
|
897
|
+
buf += ch;
|
|
898
|
+
}
|
|
899
|
+
parts.push(buf);
|
|
900
|
+
if (parts.length === 0)
|
|
901
|
+
return null;
|
|
902
|
+
const head = parts[0].trim();
|
|
903
|
+
let root;
|
|
904
|
+
if (head === 'this')
|
|
905
|
+
root = { kind: 'this', name: 'this' };
|
|
906
|
+
else if (/^[A-Z]\w*$/.test(head))
|
|
907
|
+
root = { kind: 'type', name: head };
|
|
908
|
+
else if (/^[a-zA-Z_]\w*\s*\(/.test(head))
|
|
909
|
+
root = { kind: 'call', name: head.slice(0, head.indexOf('(')).trim() };
|
|
910
|
+
else
|
|
911
|
+
return null; // a plain identifier, an index, an expression — not typed here
|
|
912
|
+
const steps = [];
|
|
913
|
+
for (const raw of parts.slice(1)) {
|
|
914
|
+
const p = raw.trim();
|
|
915
|
+
const m = /^([a-zA-Z_]\w*)\s*\(/.exec(p);
|
|
916
|
+
if (!m)
|
|
917
|
+
return null; // a field access mid-chain is not walked
|
|
918
|
+
steps.push({ name: m[1], line: 0 });
|
|
919
|
+
}
|
|
920
|
+
return { root, steps };
|
|
921
|
+
}
|
|
922
|
+
function shortestPrefixes(pkgs) {
|
|
923
|
+
const sorted = [...pkgs].sort();
|
|
924
|
+
const out = [];
|
|
925
|
+
for (const p of sorted) {
|
|
926
|
+
if (!out.some(o => p === o || p.startsWith(`${o}.`)))
|
|
927
|
+
out.push(p);
|
|
928
|
+
}
|
|
929
|
+
return out;
|
|
930
|
+
}
|
|
931
|
+
function lastSegment(fqn) {
|
|
932
|
+
const i = fqn.lastIndexOf('.');
|
|
933
|
+
return i === -1 ? fqn : fqn.slice(i + 1);
|
|
934
|
+
}
|
|
935
|
+
function stripLast(fqn) {
|
|
936
|
+
const i = fqn.lastIndexOf('.');
|
|
937
|
+
return i === -1 ? '' : fqn.slice(0, i);
|
|
938
|
+
}
|
|
939
|
+
function push(m, k, v) {
|
|
940
|
+
const cur = m.get(k);
|
|
941
|
+
if (cur)
|
|
942
|
+
cur.push(v);
|
|
943
|
+
else
|
|
944
|
+
m.set(k, [v]);
|
|
945
|
+
}
|
|
946
|
+
function push2(m, k, v) {
|
|
947
|
+
const cur = m.get(k);
|
|
948
|
+
if (cur)
|
|
949
|
+
cur.add(v);
|
|
950
|
+
else
|
|
951
|
+
m.set(k, new Set([v]));
|
|
952
|
+
}
|
|
953
|
+
/** FNV-1a over the file text. Enough to key a cache; not a security hash. */
|
|
954
|
+
function cheapHash(s) {
|
|
955
|
+
let h = 0x811c9dc5;
|
|
956
|
+
for (let i = 0; i < s.length; i++) {
|
|
957
|
+
h ^= s.charCodeAt(i);
|
|
958
|
+
h = Math.imul(h, 0x01000193);
|
|
959
|
+
}
|
|
960
|
+
return (h >>> 0).toString(16);
|
|
961
|
+
}
|
|
962
|
+
function now() {
|
|
963
|
+
return typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
964
|
+
}
|
|
965
|
+
function round(n) {
|
|
966
|
+
return Math.round(n * 100) / 100;
|
|
967
|
+
}
|
|
968
|
+
//# sourceMappingURL=navigation-index.js.map
|