@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.
- package/dist/adapter.d.ts +31 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +298 -0
- package/dist/adapter.js.map +1 -0
- package/dist/client.d.ts +171 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +471 -0
- package/dist/client.js.map +1 -0
- package/dist/extract.d.ts +49 -0
- package/dist/extract.d.ts.map +1 -0
- package/dist/extract.js +892 -0
- package/dist/extract.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/jpa.d.ts +107 -0
- package/dist/jpa.d.ts.map +1 -0
- package/dist/jpa.js +312 -0
- package/dist/jpa.js.map +1 -0
- package/dist/parse.d.ts +297 -0
- package/dist/parse.d.ts.map +1 -0
- package/dist/parse.js +765 -0
- package/dist/parse.js.map +1 -0
- package/dist/roots.d.ts +9 -0
- package/dist/roots.d.ts.map +1 -0
- package/dist/roots.js +9 -0
- package/dist/roots.js.map +1 -0
- package/dist/spring.d.ts +78 -0
- package/dist/spring.d.ts.map +1 -0
- package/dist/spring.js +397 -0
- package/dist/spring.js.map +1 -0
- package/dist/tree-sitter-java.wasm +0 -0
- package/package.json +36 -0
package/dist/extract.js
ADDED
|
@@ -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
|