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.
@@ -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