@descryy/adapter-java 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.
@@ -0,0 +1,892 @@
1
+ /**
2
+ * Java units to Canonical IR.
3
+ *
4
+ * ## Why a breadth adapter reaches further in Java than in most languages
5
+ *
6
+ * Every import is explicit and fully qualified, every field and parameter
7
+ * carries a written type, and a public type's simple name is its file's name.
8
+ * None of that needs a type checker to read, so `repo.findById(id)` resolves
9
+ * from the declaration `private final OwnerRepository repo` alone — a call
10
+ * through a receiver, which in TypeScript needed R3.
11
+ *
12
+ * That is read as **R2 evidence, and never R3**. R2 is a reference resolved to a
13
+ * specific definition, which is exactly what happens here — the level names the
14
+ * evidence, not the machinery, and Java hands over the same evidence an LSP
15
+ * would without one being run. R3 is a *checked shape*, and a written annotation
16
+ * is not one: it can be shadowed, it can be generic, and nothing here verifies
17
+ * which overload the compiler would select. Claiming R3 for a name-level fact is
18
+ * the confusion DEC-058 was written about, where 83% of an adapter's edges
19
+ * claimed a level they had not earned.
20
+ *
21
+ * `IMPORTS` stays at R1. An import is module resolution and nothing more.
22
+ *
23
+ * ## What is deliberately not resolved
24
+ *
25
+ * `var` declarations, chained calls whose receiver is another call, generic type
26
+ * variables, and any name reachable only through a wildcard import that matches
27
+ * more than one analysed type. Each goes to the ledger with its reason. A
28
+ * missing edge is a disclosed gap; a guessed one corrupts every layer above.
29
+ */
30
+ import { edgeId, endpointQsp, nodeId, normaliseEndpointPath, symbolQsp, testCaseQsp, } from "@descryy/ir";
31
+ import { jpaModels } from "./jpa.js";
32
+ import { jaxrsRoutes, springRoutes } from "./spring.js";
33
+ import { clientCalls, endpointFor } from "./client.js";
34
+ export const LANGUAGE = "java";
35
+ /** Confidence by the level an edge's evidence earned, never the level reached. */
36
+ const CONFIDENCE = { 0: 0.5, 1: 0.7, 2: 0.9, 3: 0.95 };
37
+ /** A compilation unit with no `package` declaration. Not a legal package name,
38
+ * so it cannot collide with one. */
39
+ export const DEFAULT_PACKAGE = "(default)";
40
+ const REASONS = {
41
+ // The final sentence used to read "A scope boundary, not an analysis gap."
42
+ // Measured on `okhttp`: of the 458 `outside` entries naming a qualified type,
43
+ // **238 are declared in a `.kt` file in the same repository** — `OkHttpClient`,
44
+ // `Response`, `RequestBody`. They are not a scope boundary; they are a
45
+ // language this run cannot read, which is `CLAUDE.md` rule 6's *fourth*
46
+ // category. The adapter cannot tell the two apart — deciding needs a
47
+ // repo-wide, cross-adapter declaration index it is not given — so it must
48
+ // stop claiming it can. `bench/cross-language-census.mjs`.
49
+ outside: "names a type this run did not analyse. Either a scope boundary — the JDK, a dependency " +
50
+ "on the classpath, a source root outside the analysed set — or a declaration this run " +
51
+ "could not read, in another language in this same repository. This adapter sees only " +
52
+ "Java sources and cannot tell the two apart.",
53
+ unmodelled: "the declaring type is in the analysed set but declares no such member — most often a " +
54
+ "member inherited from a supertype this run did not read. An analysis gap.",
55
+ untypedReceiver: "the receiver's type is not written down at this reference: a `var` declaration, a chained " +
56
+ "call, a generic type variable, or a name declared twice with different types.",
57
+ ambiguousWildcard: "reachable only through a wildcard import, and more than one analysed type matches the " +
58
+ "simple name. Resolving it would be a guess between equals.",
59
+ ambiguousDeclaration: "this fully-qualified name is declared in more than one module or source set, and none of " +
60
+ "them is the referring unit's own. Which one is on this code's classpath is a build fact " +
61
+ "this adapter does not read.",
62
+ unknownName: "no declaration for this simple name in the compilation unit, its imports, or its package.",
63
+ belowLevel: "resolved to a definition, but this run was capped below the level that evidence earns. " +
64
+ "Not an analysis gap and not a scope boundary — a limit the caller asked for.",
65
+ routeIdCollision: "this route's method and path are identical to one already emitted, so it collapsed onto " +
66
+ "the same API_ROUTE node and this declaration's own SERVES_API edge and location were " +
67
+ "dropped. The endpoint is real; this specific declaration is not the one the graph kept.",
68
+ };
69
+ /**
70
+ * The qualified symbol path for a Java symbol.
71
+ *
72
+ * The Java package *is* the namespace, so no anchor has to be inferred the way a
73
+ * TypeScript package root does — DEC-047's entire problem does not exist here,
74
+ * and neither does DEC-077's duplicate-anchor collision. The nesting path is
75
+ * everything after the package: `Outer.Inner.method`.
76
+ */
77
+ function qspFor(fqn, unit) {
78
+ const nested = unit.packageName !== "" && fqn.startsWith(`${unit.packageName}.`)
79
+ ? fqn.slice(unit.packageName.length + 1)
80
+ : fqn;
81
+ return symbolQsp(unit.identityPackage, nested.split("."));
82
+ }
83
+ /**
84
+ * Whether a unit's identity anchor names a test source set.
85
+ *
86
+ * `identityPackage` is `module/sourceSet/package` (`roots.ts`), so the source
87
+ * set is the second segment when there are three. A unit with no `src/` split
88
+ * has two segments and no source set, and returns false rather than guessing.
89
+ *
90
+ * `test`, `testFixtures` and `integrationTest` are the names Maven and Gradle
91
+ * declare, not a convention read off a path — the distinction `roots.ts`'s
92
+ * header exists for.
93
+ */
94
+ function isTestSourceSet(identityPackage) {
95
+ const segments = identityPackage.split("/");
96
+ if (segments.length < 3)
97
+ return false;
98
+ return /^(test|.*[Tt]est.*)$/.test(segments[1] ?? "");
99
+ }
100
+ /**
101
+ * Choose among the candidates a scope produced, preferring the analysed unit's
102
+ * own identity package. Two survivors is genuinely ambiguous and is refused.
103
+ */
104
+ function pick(from, candidates) {
105
+ if (candidates === undefined || candidates.length === 0)
106
+ return { reason: REASONS.outside };
107
+ const local = candidates.filter((each) => each.unit.identityPackage === from.identityPackage);
108
+ if (local.length === 1)
109
+ return { declared: local[0] };
110
+ if (candidates.length === 1)
111
+ return { declared: candidates[0] };
112
+ return { reason: REASONS.ambiguousDeclaration };
113
+ }
114
+ /**
115
+ * The scopes a written type name is searched in, in the order Java searches them.
116
+ *
117
+ * **Shared rather than written twice, and that is the point of pulling it out.**
118
+ * Two consumers now resolve a supertype name: the `INHERITS`/`IMPLEMENTS` edges,
119
+ * and JPA's `@MappedSuperclass` walk. A second copy of these rules would drift,
120
+ * and the failure it drifts into is not symmetric — an edge resolved by one rule
121
+ * and a column by another puts a **column on the wrong table**, which is a wrong
122
+ * answer rather than a missing one.
123
+ *
124
+ * `decides` marks a scope that settles the question even when it fails to
125
+ * resolve. An explicit single-type import is unambiguous by language rule, so a
126
+ * name it fails to resolve is not one a later scope may claim. Only the
127
+ * same-unit scope falls through, because a file may declare several types and
128
+ * only one of them can be the match.
129
+ */
130
+ function scopesFor(index, unit, written) {
131
+ // A dotted name is one of two things and they are not distinguishable by
132
+ // spelling: `com.example.Order` is already fully qualified and needs no scope,
133
+ // `DiskLruCache.Snapshot` is a nested type named through its enclosing type
134
+ // and needs the *enclosing* name resolved first. Try the whole path as
135
+ // written, then resolve the head and re-attach the tail.
136
+ //
137
+ // Only the whole path used to be tried, so every reference to a repo-declared
138
+ // nested type was lost — 28 across the four corpora, `mall`'s `Criteria` ×19.
139
+ // `decides: true` on that single scope is what made it terminal.
140
+ if (written.includes(".")) {
141
+ const asWritten = index.get(written);
142
+ if (asWritten !== undefined)
143
+ return [{ candidates: asWritten, decides: true }];
144
+ const cut = written.lastIndexOf(".");
145
+ const head = written.slice(0, cut);
146
+ const tail = written.slice(cut + 1);
147
+ // The head is itself resolved by the normal rules — it may be an import, a
148
+ // same-package type, or another nested name — so this recurses rather than
149
+ // reimplementing the scope order a second time and letting the two drift.
150
+ const ordered = [];
151
+ for (const scope of scopesFor(index, unit, head)) {
152
+ for (const candidate of scope.candidates) {
153
+ const enclosing = candidate;
154
+ const fqn = enclosing.type?.fqn;
155
+ if (fqn === undefined)
156
+ continue;
157
+ const nested = index.get(`${fqn}.${tail}`);
158
+ if (nested !== undefined)
159
+ ordered.push({ candidates: nested, decides: true });
160
+ }
161
+ }
162
+ // Nothing found is a refusal, not a fallback to the bare tail. Resolving
163
+ // `A.B` as `B` is exactly the dropped qualifier this branch exists to stop.
164
+ return ordered.length > 0 ? ordered : [{ candidates: [], decides: true }];
165
+ }
166
+ const ordered = [];
167
+ // 1. Declared in this compilation unit, innermost first.
168
+ for (const type of unit.types) {
169
+ if (type.simpleName === written)
170
+ ordered.push({ candidates: index.get(type.fqn) ?? [], decides: false });
171
+ }
172
+ // 2. An explicit single-type import is unambiguous by language rule.
173
+ const imported = unit.singleTypeImports.get(written);
174
+ if (imported !== undefined) {
175
+ ordered.push({ candidates: index.get(imported) ?? [], decides: true });
176
+ return ordered;
177
+ }
178
+ // 3. Same package.
179
+ const samePackage = unit.packageName === "" ? written : `${unit.packageName}.${written}`;
180
+ const inPackage = index.get(samePackage);
181
+ if (inPackage !== undefined) {
182
+ ordered.push({ candidates: inPackage, decides: true });
183
+ return ordered;
184
+ }
185
+ // 4. Wildcard imports. Accepted only when exactly one analysed type matches:
186
+ // two candidates is genuinely ambiguous in Java too, and picking one would
187
+ // be a coin toss recorded as a fact.
188
+ const candidates = unit.wildcardImports.flatMap((wildcard) => [...(index.get(`${wildcard}.${written}`) ?? [])]);
189
+ if (candidates.length > 0)
190
+ ordered.push({ candidates, decides: true });
191
+ return ordered;
192
+ }
193
+ export function extract(input) {
194
+ const nodes = [];
195
+ const edges = [];
196
+ const unresolved = [];
197
+ const seenEdge = new Set();
198
+ const scope = input.scope;
199
+ /** Outbound HTTP calls, collected in the method loop and emitted with the routes. */
200
+ const clientCallSites = [];
201
+ /**
202
+ * Returns `false` when the edge's evidence outranks the level this run
203
+ * reached, so the caller can disclose the reference instead of dropping it.
204
+ *
205
+ * An edge may not claim a level the run did not reach — the Normaliser rejects
206
+ * it, and rightly: a run capped at R0 that emits R2 edges is claiming
207
+ * resolution it did not perform. Capping the number instead would be worse,
208
+ * because the edge would survive wearing a level its evidence never earned,
209
+ * which is DEC-058's defect exactly.
210
+ */
211
+ const push = (edge, level) => {
212
+ if (level > input.reached)
213
+ return false;
214
+ if (edge.from === edge.to)
215
+ return true;
216
+ const key = edgeId(edge.from, edge.to, edge.type);
217
+ if (seenEdge.has(key))
218
+ return true;
219
+ seenEdge.add(key);
220
+ edges.push({
221
+ ...edge,
222
+ producedBy: input.producedBy,
223
+ resolution: level,
224
+ confidence: CONFIDENCE[level] ?? 0.5,
225
+ });
226
+ return true;
227
+ };
228
+ const file = (unit, ref) => ({
229
+ file: unit.file,
230
+ line: ref.line,
231
+ });
232
+ const ledger = (from, edgeType, rawTarget, at, reason, attrs) => {
233
+ unresolved.push({
234
+ fromNodeId: from,
235
+ edgeType,
236
+ rawTarget: rawTarget.slice(0, 120),
237
+ file: at.file,
238
+ line: at.line,
239
+ producedBy: input.producedBy,
240
+ reason,
241
+ ...(attrs === undefined ? {} : { attrs }),
242
+ });
243
+ };
244
+ // -------------------------------------------------------------------------
245
+ // Nodes
246
+ // -------------------------------------------------------------------------
247
+ /**
248
+ * Fully-qualified Java name -> every analysed type that declares it.
249
+ *
250
+ * A list, not a single entry, and that is the whole lesson of the airbyte run:
251
+ * `io.airbyte.integrations.destination.redis.RedisDataFactory` is declared
252
+ * twice, in two source sets that never share a classpath. A `Map<string,
253
+ * Declared>` here silently keeps the last one and resolves every reference in
254
+ * both source sets to it — a wrong edge that no recall measurement can see,
255
+ * because the name was found.
256
+ */
257
+ const byFqn = new Map();
258
+ const byMember = new Map();
259
+ const moduleIdOf = new Map();
260
+ /** Simple name -> every analysed fully-qualified name that ends with it. */
261
+ const bySimpleName = new Map();
262
+ /**
263
+ * Every declared type, indexed before any node id exists — and it has to be.
264
+ *
265
+ * A JPA entity is a `MODEL` rather than a `CLASS`, and the id hashes the kind
266
+ * (DEC-004), so the kind must be settled before the id is minted. But settling
267
+ * it now needs `@MappedSuperclass` resolution *across* compilation units, and
268
+ * that needs an index. `byFqn` cannot be it, because `byFqn` holds ids.
269
+ *
270
+ * So the types are indexed twice, once without ids for the entity decision and
271
+ * once with them for the edges. The alternative was minting a `CLASS` id and
272
+ * rewriting it, which is the exact thing DEC-004 forbids.
273
+ */
274
+ const typesByFqn = new Map();
275
+ for (const unit of input.units) {
276
+ for (const type of unit.types) {
277
+ const bucket = typesByFqn.get(type.fqn);
278
+ if (bucket === undefined)
279
+ typesByFqn.set(type.fqn, [{ type, unit }]);
280
+ else
281
+ bucket.push({ type, unit });
282
+ }
283
+ }
284
+ /** A supertype name as written, resolved to its declaration. Same rules as edges. */
285
+ const resolveSuper = (unit, written) => {
286
+ for (const scope of scopesFor(typesByFqn, unit, written)) {
287
+ const found = pick(unit, scope.candidates);
288
+ if ("declared" in found)
289
+ return found.declared;
290
+ // An ambiguous supertype is refused rather than guessed: two candidates
291
+ // means two different tables, and picking one puts real columns on the
292
+ // wrong one.
293
+ if (scope.decides)
294
+ return undefined;
295
+ }
296
+ return undefined;
297
+ };
298
+ /**
299
+ * The entity map for the whole batch, resolved once before any id is minted.
300
+ *
301
+ * Keyed by `identityPackage::fqn` rather than by `fqn` alone, because a
302
+ * fully-qualified Java name is **not** unique across source sets — the airbyte
303
+ * duplicate that this file already records for `byFqn`. Keying on the fqn here
304
+ * would let a `@Entity` in one source set turn its namesake in another into a
305
+ * MODEL it never was.
306
+ */
307
+ const entitiesByType = new Map();
308
+ for (const unit of input.units) {
309
+ for (const model of jpaModels(unit, resolveSuper)) {
310
+ entitiesByType.set(`${unit.identityPackage}::${model.fqn}`, model);
311
+ }
312
+ }
313
+ for (const unit of input.units) {
314
+ const pkg = unit.identityPackage;
315
+ // The compilation unit is named by its *primary* type, which Java requires
316
+ // to match the file name. That is a symbol path, not a file path — DEC-004's
317
+ // rule is satisfied, and a moved file keeps its identity.
318
+ //
319
+ // `types[0]` was not that type. Nested declarations are appended before the
320
+ // type enclosing them, so a file whose outer class holds a private interface
321
+ // took its identity from the interface: `TeradataJdbcSourceAcceptanceTest.java`
322
+ // became the module `SqlConsumer`, a nested private interface on line 123.
323
+ // Two such files in one package would then share a module id.
324
+ //
325
+ // The stem is preferred because the language guarantees it names the public
326
+ // type; a top-level declaration is the fallback for a file with none, and the
327
+ // stem stands alone only when the file declares no top-level type at all.
328
+ const stem = unit.file.slice(unit.file.lastIndexOf("/") + 1).replace(/\.java$/, "");
329
+ const topLevel = unit.types.filter((each) => unit.packageName === ""
330
+ ? !each.fqn.includes(".")
331
+ : !each.fqn.slice(unit.packageName.length + 1).includes("."));
332
+ const unitName = topLevel.some((each) => each.simpleName === stem)
333
+ ? stem
334
+ : (topLevel[0]?.simpleName ?? stem);
335
+ const moduleId = nodeId(scope, "MODULE", symbolQsp(pkg, [unitName]), LANGUAGE);
336
+ moduleIdOf.set(unit.file, moduleId);
337
+ nodes.push({
338
+ id: moduleId,
339
+ type: "MODULE",
340
+ // The compilation unit is *named* with its extension and *identified*
341
+ // without one. Java requires a public type's simple name to equal its file
342
+ // name, so a bare `Status` is the name of both the module and the class it
343
+ // holds — and a corpus role bound on `(file, name)` then matches two nodes
344
+ // and correctly refuses to guess between them. `Status.java` is what a Java
345
+ // developer calls the unit and can never collide with a type.
346
+ name: `${unitName}.java`,
347
+ file: unit.file,
348
+ range: { startLine: 1, endLine: 1 },
349
+ language: LANGUAGE,
350
+ producedBy: input.producedBy,
351
+ resolution: 0,
352
+ attrs: unit.hasError ? { parsedWithErrors: true } : {},
353
+ });
354
+ // A JPA entity is a MODEL rather than a CLASS, and the node id hashes the
355
+ // kind (DEC-004) — so the kind has to be decided BEFORE the id is minted,
356
+ // not patched onto the node afterwards. Everything downstream reads the id
357
+ // out of `byFqn`, so one derivation keeps them in step. Resolved for the
358
+ // whole batch above, because inherited mappings cross compilation units.
359
+ for (const type of unit.types) {
360
+ const model = entitiesByType.get(`${unit.identityPackage}::${type.fqn}`);
361
+ const kind = model === undefined ? "CLASS" : "MODEL";
362
+ const id = nodeId(scope, kind, qspFor(type.fqn, unit), LANGUAGE);
363
+ const declaredAs = byFqn.get(type.fqn);
364
+ if (declaredAs === undefined)
365
+ byFqn.set(type.fqn, [{ id, type, unit }]);
366
+ else
367
+ declaredAs.push({ id, type, unit });
368
+ const bucket = bySimpleName.get(type.simpleName);
369
+ if (bucket === undefined)
370
+ bySimpleName.set(type.simpleName, [type.fqn]);
371
+ else
372
+ bucket.push(type.fqn);
373
+ const attrs = { declarationForm: type.kind };
374
+ if (model !== undefined) {
375
+ attrs["orm"] = "jpa";
376
+ if (model.table !== null)
377
+ attrs["table"] = model.table;
378
+ // **Not gated on `input.reached`, and the exception is the point.** A
379
+ // mapped column is a declaration the framework itself enforces, not an
380
+ // inference, so its resolution is the evidence's rather than the run's
381
+ // (DEC-058) — the same reasoning adapter-python records for SQLAlchemy.
382
+ if (model.fields.length > 0)
383
+ attrs["fields"] = model.fields;
384
+ if (model.undecided.length > 0)
385
+ attrs["undecidedNullability"] = model.undecided;
386
+ }
387
+ nodes.push({
388
+ id,
389
+ type: kind,
390
+ name: type.simpleName,
391
+ file: unit.file,
392
+ range: { startLine: type.startLine, endLine: type.endLine },
393
+ language: LANGUAGE,
394
+ producedBy: input.producedBy,
395
+ resolution: 0,
396
+ attrs,
397
+ });
398
+ /**
399
+ * Overloads are one node, and the count is disclosed rather than hidden.
400
+ *
401
+ * A qualified symbol path carries no parameter types (DEC-011), so
402
+ * `getPet(String)` and `getPet(Integer)` name one symbol — and on
403
+ * `spring-petclinic` that produced three nodes sharing one id before this
404
+ * existed. Encoding parameters into the path would fix the collision by
405
+ * making Java's identity rule different from every other language's, which
406
+ * is the leak the IR boundary exists to prevent.
407
+ *
408
+ * What is lost is real and is stated: an edge into an overload set does not
409
+ * say which overload. That is a name-level fact, and DEC-058's rule caps it
410
+ * at the level its evidence earned.
411
+ */
412
+ const overloads = new Map();
413
+ for (const method of type.methods) {
414
+ const bucket = overloads.get(method.name);
415
+ if (bucket === undefined)
416
+ overloads.set(method.name, [method]);
417
+ else
418
+ bucket.push(method);
419
+ }
420
+ for (const [name, group] of overloads) {
421
+ const first = group[0];
422
+ // A `@Test` method is a test case, not a function that happens to be
423
+ // tested. Emitting both would put two nodes on one declaration and let a
424
+ // coverage query count it twice.
425
+ const isCase = group.some((each) => each.isTest);
426
+ const memberId = isCase
427
+ ? nodeId(scope, "TEST_CASE", testCaseQsp(unit.identityPackage, [type.simpleName], name), LANGUAGE)
428
+ : nodeId(scope, "FUNCTION", qspFor(first.fqn, unit), LANGUAGE);
429
+ byMember.set(`${unit.identityPackage}::${first.fqn}`, {
430
+ id: memberId,
431
+ method: first,
432
+ ownerFqn: type.fqn,
433
+ });
434
+ // DEC-248: a CALLS edge out of this node can originate from *any*
435
+ // overload's body — `emitCall` below resolves a ref against its own
436
+ // `method` (the specific overload), not `member.method`, precisely so a
437
+ // ref found inside a later overload's body is attributed correctly to
438
+ // the shared symbol. A range pinned to `first`'s lines alone silently
439
+ // excludes every overload but the first, so an edge attributed to this
440
+ // node can point outside its own declared range — `spring-petclinic`'s
441
+ // `Owner.getPet(String)` was credited with a call that only exists in
442
+ // the neighbouring `getPet(Integer)` overload. The range now spans every
443
+ // overload in the group, so it always contains whichever one is the
444
+ // true source of an edge attributed to this node.
445
+ const startLine = Math.min(...group.map((each) => each.startLine));
446
+ const endLine = Math.max(...group.map((each) => each.endLine));
447
+ // Widening is only sound when the group's own declarations are what
448
+ // fill the span. Java does not require overloads — constructors above
449
+ // all — to sit next to each other, so the widened range can swallow an
450
+ // unrelated field or method declared in the gap between two of them.
451
+ // `language-problems.md` §3's row 5 named this a latent risk on top of
452
+ // the widening fix above; disclosed here rather than silently letting
453
+ // a change to that unrelated member attribute to this node in diff
454
+ // scoping.
455
+ const groupMembers = new Set(group);
456
+ const includesOtherMember = group.length > 1 &&
457
+ (type.methods.some((each) => !groupMembers.has(each) && each.startLine > startLine && each.startLine < endLine) ||
458
+ type.fieldDeclarations.some((field) => field.line > startLine && field.line < endLine));
459
+ nodes.push({
460
+ id: memberId,
461
+ type: isCase ? "TEST_CASE" : "FUNCTION",
462
+ name: isCase ? `${type.simpleName}.${name}` : name,
463
+ file: unit.file,
464
+ range: { startLine, endLine },
465
+ language: LANGUAGE,
466
+ producedBy: input.producedBy,
467
+ resolution: 0,
468
+ attrs: {
469
+ ...(group.length > 1 ? { overloads: group.length } : {}),
470
+ ...(includesOtherMember ? { rangeIncludesOtherMembers: true } : {}),
471
+ },
472
+ });
473
+ }
474
+ }
475
+ }
476
+ // -------------------------------------------------------------------------
477
+ // Name resolution
478
+ // -------------------------------------------------------------------------
479
+ /**
480
+ * Pick the declaration of a fully-qualified name that the referring unit can
481
+ * actually see.
482
+ *
483
+ * Java's own rule is the classpath, which this adapter does not read. The
484
+ * approximation is the strictest one that cannot be wrong in the common case:
485
+ * a declaration in the same module and source set wins outright, one candidate
486
+ * overall is taken, and a genuine tie between two scopes is refused rather
487
+ * than broken arbitrarily.
488
+ */
489
+ /** Resolve a simple or qualified type name as written inside one unit. */
490
+ const resolveType = (unit, written) => {
491
+ for (const scope of scopesFor(byFqn, unit, written)) {
492
+ const found = pick(unit, scope.candidates);
493
+ if ("declared" in found)
494
+ return found;
495
+ if (scope.decides)
496
+ return found;
497
+ }
498
+ return { reason: bySimpleName.has(written) ? REASONS.unknownName : REASONS.outside };
499
+ };
500
+ /**
501
+ * The declared type of a field, following the scopes Java says are in scope.
502
+ *
503
+ * Two of them were missing, and both were found by the first recall
504
+ * measurement this project has taken rather than by any precision sample —
505
+ * a missing edge leaves nothing behind for a precision draw to land on.
506
+ *
507
+ * `viaThis` distinguishes `this.pet` from a bare `pet`. `this` names the
508
+ * instance, so it reaches the type's own fields and inherited ones and stops;
509
+ * a bare name additionally sees the *lexically enclosing* class's fields,
510
+ * which is how a `@Nested` test class reads the fields of the class holding it.
511
+ * Collapsing the two would resolve `this.x` against an outer class it cannot
512
+ * legally reach.
513
+ */
514
+ const fieldTypeOf = (owner, name, viaThis) => {
515
+ const search = [owner];
516
+ const seen = new Set();
517
+ if (!viaThis) {
518
+ // Lexically enclosing types, innermost first: `pkg.Outer.Inner` -> `pkg.Outer`.
519
+ let fqn = owner.type.fqn;
520
+ for (;;) {
521
+ const cut = fqn.lastIndexOf(".");
522
+ if (cut === -1)
523
+ break;
524
+ fqn = fqn.slice(0, cut);
525
+ const enclosing = (byFqn.get(fqn) ?? []).find((each) => each.unit.identityPackage === owner.unit.identityPackage);
526
+ if (enclosing === undefined)
527
+ continue;
528
+ search.push(enclosing);
529
+ }
530
+ }
531
+ while (search.length > 0) {
532
+ const current = search.shift();
533
+ const key = `${current.unit.identityPackage}::${current.type.fqn}`;
534
+ if (seen.has(key))
535
+ continue;
536
+ seen.add(key);
537
+ if (current.type.fields.has(name)) {
538
+ const written = current.type.fields.get(name);
539
+ return written === null || written === undefined ? { poisoned: true } : { written };
540
+ }
541
+ for (const superName of [...current.type.extendsNames, ...current.type.implementsNames]) {
542
+ const resolved = resolveType(current.unit, superName);
543
+ if ("declared" in resolved)
544
+ search.push(resolved.declared);
545
+ }
546
+ }
547
+ return undefined;
548
+ };
549
+ /** Find a method by name on a type or anywhere up its analysed supertype chain. */
550
+ const findMember = (start, name) => {
551
+ const seen = new Set();
552
+ const queue = [start];
553
+ while (queue.length > 0) {
554
+ const current = queue.shift();
555
+ const key = `${current.unit.identityPackage}::${current.type.fqn}`;
556
+ if (seen.has(key))
557
+ continue;
558
+ seen.add(key);
559
+ const direct = byMember.get(`${key}.${name}`);
560
+ if (direct !== undefined)
561
+ return direct;
562
+ for (const superName of [...current.type.extendsNames, ...current.type.implementsNames]) {
563
+ const resolved = resolveType(current.unit, superName);
564
+ if ("declared" in resolved)
565
+ queue.push(resolved.declared);
566
+ }
567
+ }
568
+ return undefined;
569
+ };
570
+ // -------------------------------------------------------------------------
571
+ // Edges
572
+ // -------------------------------------------------------------------------
573
+ for (const unit of input.units) {
574
+ const moduleId = moduleIdOf.get(unit.file);
575
+ if (moduleId === undefined)
576
+ continue;
577
+ // IMPORTS — module to module. A wildcard import names a package and not a
578
+ // compilation unit, so it has nothing to point at and is disclosed instead.
579
+ for (const each of unit.importLines) {
580
+ if (each.wildcard) {
581
+ ledger(moduleId, "IMPORTS", `${each.fqn}.*`, { file: unit.file, line: each.line }, REASONS.ambiguousWildcard);
582
+ continue;
583
+ }
584
+ // A static import names a member; the module it comes from is its owner.
585
+ const ownerFqn = byFqn.has(each.fqn) ? each.fqn : each.fqn.slice(0, each.fqn.lastIndexOf("."));
586
+ const target = pick(unit, byFqn.get(ownerFqn));
587
+ if ("reason" in target) {
588
+ ledger(moduleId, "IMPORTS", each.fqn, { file: unit.file, line: each.line }, target.reason);
589
+ continue;
590
+ }
591
+ const targetModule = moduleIdOf.get(target.declared.unit.file);
592
+ if (targetModule !== undefined)
593
+ push({ from: moduleId, to: targetModule, type: "IMPORTS" }, 1);
594
+ }
595
+ for (const type of unit.types) {
596
+ const declared = (byFqn.get(type.fqn) ?? []).find((each) => each.unit.file === unit.file && each.type.fqn === type.fqn);
597
+ if (declared === undefined)
598
+ continue;
599
+ const relate = (names, edgeType) => {
600
+ for (const written of names) {
601
+ const resolved = resolveType(unit, written);
602
+ if ("declared" in resolved) {
603
+ if (push({ from: declared.id, to: resolved.declared.id, type: edgeType }, 2))
604
+ continue;
605
+ ledger(declared.id, edgeType, written, { file: unit.file, line: type.startLine }, REASONS.belowLevel);
606
+ continue;
607
+ }
608
+ ledger(declared.id, edgeType, written, { file: unit.file, line: type.startLine }, resolved.reason);
609
+ }
610
+ };
611
+ relate(type.extendsNames, "INHERITS");
612
+ relate(type.implementsNames, "IMPLEMENTS");
613
+ for (const ref of type.typeRefs)
614
+ emitTypeRef(declared.id, unit, ref);
615
+ for (const method of type.methods) {
616
+ const member = byMember.get(`${unit.identityPackage}::${method.fqn}`);
617
+ if (member === undefined)
618
+ continue;
619
+ // The caller's half of the HTTP boundary. Collected here, where the
620
+ // enclosing FUNCTION node and the receiver scopes are both in hand, and
621
+ // emitted with the routes below so both halves mint one `API_ENDPOINT`.
622
+ {
623
+ const read = clientCalls(method, member.id, (name) => writtenReceiverType(method, declared, name),
624
+ // `identityPackage` is `module/sourceSet/package`, so the second
625
+ // segment is the source set the build declared — read from the
626
+ // anchor rather than pattern-matched off the file path.
627
+ isTestSourceSet(unit.identityPackage));
628
+ clientCallSites.push(...read.calls);
629
+ for (const refusal of read.refusals) {
630
+ ledger(refusal.fromId, "USES_API", refusal.raw, { file: unit.file, line: refusal.line }, refusal.reason,
631
+ // Unset `refusalClass` stays legal and means unclassified — DEC-242 — so
632
+ // `attrs` is omitted entirely rather than sent with an `undefined` field.
633
+ refusal.refusalClass === undefined
634
+ ? undefined
635
+ : {
636
+ blockedBy: refusal.blockedBy,
637
+ refusalClass: refusal.refusalClass,
638
+ ...(refusal.argumentKind === undefined ? {} : { argumentKind: refusal.argumentKind }),
639
+ });
640
+ }
641
+ }
642
+ for (const ref of method.refs) {
643
+ if (ref.kind === "type") {
644
+ emitTypeRef(member.id, unit, ref);
645
+ continue;
646
+ }
647
+ if (ref.kind === "member") {
648
+ // A dotted access whose receiver is a declared variable is a field
649
+ // read, not a type reference. Nothing is emitted and nothing is
650
+ // filed: the ledger records references that *could* have been edges,
651
+ // and this one was never a candidate.
652
+ if (method.locals.has(ref.name) || type.fields.has(ref.name))
653
+ continue;
654
+ emitTypeRef(member.id, unit, ref);
655
+ continue;
656
+ }
657
+ // `method`, not `member.method`. An overload set shares one node, and
658
+ // the node carries the *first* overload — so resolving a receiver
659
+ // against `member.method.locals` would type overload A's variables
660
+ // with overload B's declarations. Same name, different parameter,
661
+ // different type: a wrong edge, and one no recall measurement could
662
+ // ever see.
663
+ emitCall(member, method, declared, unit, ref);
664
+ }
665
+ }
666
+ }
667
+ }
668
+ function emitTypeRef(from, unit, ref) {
669
+ const resolved = resolveType(unit, ref.name);
670
+ if ("declared" in resolved) {
671
+ if (!push({ from, to: resolved.declared.id, type: "USES_TYPE" }, 2)) {
672
+ ledger(from, "USES_TYPE", ref.name, file(unit, ref), REASONS.belowLevel);
673
+ }
674
+ return;
675
+ }
676
+ ledger(from, "USES_TYPE", ref.name, file(unit, ref), resolved.reason);
677
+ }
678
+ function emitCall(member, method, owner, unit, ref) {
679
+ // A coverage relation is a claim about a target. An unresolved reference has
680
+ // no target to make it about, so it is filed as the call it plainly is —
681
+ // the TypeScript adapter learned this by putting 21,223 assertion calls into
682
+ // its own test-coverage denominator.
683
+ const resolvedType = method.isTest ? "TESTS" : "CALLS";
684
+ const target = receiverTarget(method, owner, unit, ref);
685
+ if ("reason" in target) {
686
+ ledger(member.id, "CALLS", ref.raw, file(unit, ref), target.reason);
687
+ return;
688
+ }
689
+ let found = findMember(target.declared, ref.name);
690
+ // An unqualified name may be bound by a static import rather than declared
691
+ // on the enclosing type. The language guarantees that binding, so following
692
+ // it is resolution and not inference — and without it every JUnit assertion
693
+ // and every statically-imported helper is unresolvable.
694
+ if (found === undefined && ref.receiver === undefined) {
695
+ const owningType = unit.staticMemberImports.get(ref.name);
696
+ if (owningType !== undefined) {
697
+ const declaring = pick(unit, byFqn.get(owningType));
698
+ if ("declared" in declaring)
699
+ found = findMember(declaring.declared, ref.name);
700
+ }
701
+ }
702
+ if (found === undefined) {
703
+ ledger(member.id, "CALLS", ref.raw, file(unit, ref), REASONS.unmodelled);
704
+ return;
705
+ }
706
+ // The evidence is a reference resolved to a specific definition, which is
707
+ // what R2 names — reached here through the language's own declarations
708
+ // rather than through an LSP. DEC-058's rule is that the level describes the
709
+ // evidence, not the machinery that produced it.
710
+ if (!push({ from: member.id, to: found.id, type: resolvedType }, 2)) {
711
+ ledger(member.id, "CALLS", ref.raw, file(unit, ref), REASONS.belowLevel);
712
+ }
713
+ }
714
+ /** Which type a call's receiver names, from what the source wrote down. */
715
+ function receiverTarget(method, owner, unit, ref) {
716
+ const receiver = ref.receiver;
717
+ if (receiver === undefined || receiver === "this")
718
+ return { declared: owner };
719
+ // `this.types.findPetTypes()` — the receiver is a field, named explicitly.
720
+ // Left unhandled, `this.types` was looked up as though it were a variable
721
+ // called "this.types" and then as a type of that name, and neither exists:
722
+ // three of the four recall misses on `spring-petclinic` were this one idiom,
723
+ // which is how every constructor-injected Spring field is written.
724
+ const viaThis = receiver.startsWith("this.");
725
+ const bare = viaThis ? receiver.slice("this.".length) : receiver;
726
+ if (bare.includes("."))
727
+ return { reason: REASONS.untypedReceiver };
728
+ // A local or parameter shadows a field, exactly as the language says — but
729
+ // `this.x` names the field regardless of what locals are in scope.
730
+ if (!viaThis) {
731
+ const local = method.locals.get(bare);
732
+ if (local === null)
733
+ return { reason: REASONS.untypedReceiver };
734
+ if (local !== undefined)
735
+ return resolveType(unit, local);
736
+ }
737
+ const field = fieldTypeOf(owner, bare, viaThis);
738
+ if (field !== undefined) {
739
+ return "poisoned" in field ? { reason: REASONS.untypedReceiver } : resolveType(unit, field.written);
740
+ }
741
+ if (viaThis)
742
+ return { reason: REASONS.untypedReceiver };
743
+ // `Type.staticMethod()` — a receiver that is itself a type name.
744
+ const asType = resolveType(unit, receiver);
745
+ if ("declared" in asType)
746
+ return asType;
747
+ // Anything else is a chained call, a field of an unread supertype, or an
748
+ // expression. None of them wrote a type down at this reference.
749
+ return { reason: /^[A-Z]/.test(receiver) ? asType.reason : REASONS.untypedReceiver };
750
+ }
751
+ /**
752
+ * The receiver's type **as written**, without resolving it.
753
+ *
754
+ * Deliberately not `receiverTarget`. That function answers "which analysed
755
+ * declaration does this receiver name", and every HTTP client type —
756
+ * `RestTemplate`, `WebClient` — is a Spring class **outside the analysed
757
+ * set**, so it correctly returns `outside` for all of them. The client reader
758
+ * does not need a declaration; it needs the name the source wrote, to compare
759
+ * against a closed set.
760
+ *
761
+ * The scope order is the same and is deliberately kept to the two cases that
762
+ * carry a written type — a local or parameter, then a field. Anything else
763
+ * (`Type.staticMethod()`, a chained builder, an expression) has no written
764
+ * type at this reference and returns `undefined`, which the caller turns into
765
+ * a disclosed refusal rather than a guess.
766
+ *
767
+ * A `function` declaration rather than a `const` arrow so it hoists: the
768
+ * method loop that calls it runs earlier in `extract`'s body than this point.
769
+ */
770
+ function writtenReceiverType(method, owner, receiver) {
771
+ const viaThis = receiver.startsWith("this.");
772
+ const bare = viaThis ? receiver.slice("this.".length) : receiver;
773
+ if (bare.includes("."))
774
+ return undefined;
775
+ if (!viaThis) {
776
+ const local = method.locals.get(bare);
777
+ // `null` is a poisoned entry — declared twice, or `var`. Not a name.
778
+ if (local === null)
779
+ return undefined;
780
+ if (local !== undefined)
781
+ return local;
782
+ }
783
+ const field = fieldTypeOf(owner, bare, viaThis);
784
+ if (field === undefined || "poisoned" in field)
785
+ return undefined;
786
+ return field.written;
787
+ }
788
+ ;
789
+ // --- Spring routes -------------------------------------------------------
790
+ //
791
+ // R2 and no lower, for the reason the Go adapter records: telling a controller
792
+ // from an ordinary class is provenance, and a route emitted from the R0 or R1
793
+ // rung is a claim stronger than the run that produced it — which the
794
+ // Normaliser rejects as RESOLUTION_EXCEEDS_BATCH.
795
+ if (input.reached >= 2) {
796
+ const routeNodes = new Map();
797
+ const endpointNodes = new Map();
798
+ for (const unit of input.units) {
799
+ // Spring and JAX-RS are read independently — each gated on its own
800
+ // provenance in `spring.ts` — and merged here because both mint the
801
+ // same `API_ROUTE`/`API_ENDPOINT` shape (`SpringRoute`). See
802
+ // `language-problems.md` §3's JAX-RS row.
803
+ for (const route of [...springRoutes(unit), ...jaxrsRoutes(unit)]) {
804
+ const template = normaliseEndpointPath(route.template);
805
+ const name = `${route.method} ${route.template}`;
806
+ // Repo-scoped and template-as-written: which service serves a path is
807
+ // exactly what the join must not erase.
808
+ const routeId = nodeId(scope, "API_ROUTE", name, LANGUAGE);
809
+ if (!routeNodes.has(routeId)) {
810
+ routeNodes.set(routeId, {
811
+ id: routeId,
812
+ type: "API_ROUTE",
813
+ name,
814
+ file: unit.file,
815
+ range: { startLine: route.line, endLine: route.line },
816
+ language: LANGUAGE,
817
+ producedBy: input.producedBy,
818
+ resolution: 2,
819
+ attrs: {
820
+ method: route.method,
821
+ pathTemplate: template,
822
+ rawTemplate: route.template,
823
+ written: route.written,
824
+ framework: route.framework,
825
+ },
826
+ });
827
+ }
828
+ else {
829
+ // A second declaration claiming the same method+path — real (two
830
+ // controllers mapped to overlapping prefixes, Spring and JAX-RS both
831
+ // serving the same route) rather than a bug in this reader, but
832
+ // silent until now: nothing distinguished it from "checked, only one
833
+ // declaration exists". Same write pattern named across five language
834
+ // adapters in `language-problems.md` §0.2 #10, fixed here the way
835
+ // `adapter-python` already fixed it — a ledger row on the collision
836
+ // branch, not a change to the underlying node-identity question
837
+ // (`DEC-A-routes-ts-reconciliation`).
838
+ ledger(
839
+ // Every unit gets a MODULE node in the Nodes section above, before
840
+ // this Edges section ever runs — always present.
841
+ moduleIdOf.get(unit.file), "SERVES_API", name, { file: unit.file, line: route.line }, REASONS.routeIdCollision);
842
+ }
843
+ // Workspace-scoped, fileless, language-less, and minted with the SAME
844
+ // endpointQsp every other producer uses — DEC-115's one hard constraint.
845
+ // Two producers that mint different ids do not conflict, they silently
846
+ // fail to join.
847
+ const endpointId = nodeId(scope, "API_ENDPOINT", endpointQsp(route.method, route.template), null);
848
+ if (!endpointNodes.has(endpointId)) {
849
+ endpointNodes.set(endpointId, {
850
+ id: endpointId,
851
+ type: "API_ENDPOINT",
852
+ name: `${route.method} ${template}`,
853
+ file: null,
854
+ range: null,
855
+ language: null,
856
+ producedBy: input.producedBy,
857
+ resolution: 2,
858
+ attrs: { method: route.method, pathTemplate: template },
859
+ });
860
+ }
861
+ push({ from: routeId, to: endpointId, type: "SERVES_API" }, 2);
862
+ }
863
+ }
864
+ // --- The caller's half ---------------------------------------------------
865
+ //
866
+ // Mints into the SAME `endpointNodes` map, so a call to a route this run
867
+ // also read produces one node with two edges rather than two nodes with one
868
+ // each. A call to a route this run did *not* read still mints its endpoint —
869
+ // that is the point of `API_ENDPOINT` being the join node: either side can
870
+ // exist without the other (golden 08's note).
871
+ for (const call of clientCallSites) {
872
+ const endpoint = endpointFor(scope, call, input.producedBy, normaliseEndpointPath);
873
+ if (!endpointNodes.has(endpoint.id))
874
+ endpointNodes.set(endpoint.id, endpoint);
875
+ push({
876
+ from: call.fromId,
877
+ to: endpoint.id,
878
+ type: "USES_API",
879
+ // **The distinction the census forced.** 12 of Java's 16 relative-path
880
+ // call sites are in test files and none are production, so a consumer
881
+ // reading these as service-to-service coupling would be wrong about
882
+ // nearly all of them. `test` means this edge feeds *Verification
883
+ // status* — which routes have integration coverage — and not the
884
+ // confidence label. DEC-189.
885
+ attrs: { callerKind: call.callerKind, via: call.method },
886
+ }, 2);
887
+ }
888
+ nodes.push(...routeNodes.values(), ...endpointNodes.values());
889
+ }
890
+ return { nodes, edges, unresolved };
891
+ }
892
+ //# sourceMappingURL=extract.js.map