@descryy/adapter-kotlin 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 +27 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +272 -0
- package/dist/adapter.js.map +1 -0
- package/dist/client.d.ts +93 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +344 -0
- package/dist/client.js.map +1 -0
- package/dist/extract.d.ts +56 -0
- package/dist/extract.d.ts.map +1 -0
- package/dist/extract.js +722 -0
- package/dist/extract.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/parse.d.ts +157 -0
- package/dist/parse.d.ts.map +1 -0
- package/dist/parse.js +571 -0
- package/dist/parse.js.map +1 -0
- package/dist/retrofit.d.ts +78 -0
- package/dist/retrofit.d.ts.map +1 -0
- package/dist/retrofit.js +195 -0
- package/dist/retrofit.js.map +1 -0
- package/dist/routes.d.ts +54 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +231 -0
- package/dist/routes.js.map +1 -0
- package/dist/spring.d.ts +39 -0
- package/dist/spring.d.ts.map +1 -0
- package/dist/spring.js +231 -0
- package/dist/spring.js.map +1 -0
- package/dist/tree-sitter-kotlin.wasm +0 -0
- package/package.json +35 -0
package/dist/extract.js
ADDED
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kotlin structures into IR nodes and edges.
|
|
3
|
+
*
|
|
4
|
+
* ## The identity rule, and why it refuses
|
|
5
|
+
*
|
|
6
|
+
* Kotlin shares `@descryy/adapter-jvm`'s anchor with Java: a symbol's identity is
|
|
7
|
+
* `module/sourceSet/package`, derived by subtracting the package path from the
|
|
8
|
+
* file's directory. Measured before this was written — okhttp 570 of 576 files
|
|
9
|
+
* derive, ktor 2,146 of 2,324.
|
|
10
|
+
*
|
|
11
|
+
* **When the subtraction fails, the node is refused.** Java falls back to the
|
|
12
|
+
* bare package name; Kotlin must not, because Kotlin Multiplatform declares one
|
|
13
|
+
* qualified name in several source sets **by design** — `expect`/`actual`. A
|
|
14
|
+
* bare-package fallback collapses those onto one id. Measured on ktor: 4 FQNs,
|
|
15
|
+
* `io.ktor.utils.io.charsets.Charsets` among them, declared across seven
|
|
16
|
+
* platform files.
|
|
17
|
+
*
|
|
18
|
+
* Disclosing the collapse on `attrs` instead was considered and rejected:
|
|
19
|
+
* nothing above the IR reads `attrs`, so scoring, governance and blast radius
|
|
20
|
+
* would act on a merged node while the note sat somewhere they cannot see.
|
|
21
|
+
* A refusal is visible to the layers that act on it; that is the whole
|
|
22
|
+
* difference (DEC-173).
|
|
23
|
+
*
|
|
24
|
+
* ## No cross-language edges, and that is a measurement
|
|
25
|
+
*
|
|
26
|
+
* The build plan has this adapter mint Java-shaped target ids for references
|
|
27
|
+
* into `.java` files. **It is not implemented, because the corpus chosen to
|
|
28
|
+
* measure it cannot witness it.** okhttp's 68 `.java` files are 56 samples with
|
|
29
|
+
* `kt/` twins, 11 `module-info.java` declaring no types, and 3 `*JavaTest.java`
|
|
30
|
+
* exercising the Kotlin API. The traffic runs Java→Kotlin. ktor is 2,324 `.kt`
|
|
31
|
+
* against 1 `.java`.
|
|
32
|
+
*
|
|
33
|
+
* A rule with zero witnesses is an untaken branch, not a weak rule — so it is
|
|
34
|
+
* declared absent rather than written blind.
|
|
35
|
+
*/
|
|
36
|
+
import { endpointQsp, nodeId, normaliseEndpointPath, symbolQsp, } from "@descryy/ir";
|
|
37
|
+
const LANGUAGE = "kotlin";
|
|
38
|
+
const typeKey = (identityPackage, path) => `${identityPackage}::${path.join(".")}`;
|
|
39
|
+
// identityPackage is `module[/sourceSet]/package`; the package is the segment
|
|
40
|
+
// after the last slash and is what an import path states.
|
|
41
|
+
const packageOf = (identityPackage) => identityPackage.slice(identityPackage.lastIndexOf("/") + 1);
|
|
42
|
+
export function extract(input) {
|
|
43
|
+
const nodes = [];
|
|
44
|
+
const edges = [];
|
|
45
|
+
const unresolved = [];
|
|
46
|
+
const scope = input.scope;
|
|
47
|
+
const typesByKey = new Map();
|
|
48
|
+
const funcsByKey = new Map();
|
|
49
|
+
const moduleIdOf = new Map();
|
|
50
|
+
const seenEdges = new Set();
|
|
51
|
+
// `IREdge` carries no `language` — an edge's language is the language of the
|
|
52
|
+
// nodes it joins, and duplicating it here would be a second place for it to
|
|
53
|
+
// be wrong.
|
|
54
|
+
const push = (edge, confidence, resolution) => {
|
|
55
|
+
if (resolution > input.reached)
|
|
56
|
+
return false;
|
|
57
|
+
const key = `${edge.from}|${edge.to}|${edge.type}`;
|
|
58
|
+
if (seenEdges.has(key))
|
|
59
|
+
return true;
|
|
60
|
+
seenEdges.add(key);
|
|
61
|
+
edges.push({ ...edge, confidence, producedBy: input.producedBy, resolution });
|
|
62
|
+
return true;
|
|
63
|
+
};
|
|
64
|
+
const refuse = (fromNodeId, edgeType, rawTarget, file, line, reason, attrs) => {
|
|
65
|
+
unresolved.push({
|
|
66
|
+
fromNodeId,
|
|
67
|
+
edgeType,
|
|
68
|
+
rawTarget,
|
|
69
|
+
file,
|
|
70
|
+
line,
|
|
71
|
+
producedBy: input.producedBy,
|
|
72
|
+
reason,
|
|
73
|
+
...(attrs === undefined ? {} : { attrs }),
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
// -------------------------------------------------------------------------
|
|
77
|
+
// Pass 0 — count declarations sharing an identity key: same identityPackage,
|
|
78
|
+
// same owner chain, same name. Two shapes produce this, and both share one
|
|
79
|
+
// cause: this adapter's identity has no parameter-type or file component to
|
|
80
|
+
// break the tie, by design (DEC-004 — a file component churns on rename).
|
|
81
|
+
//
|
|
82
|
+
// - Two files in one package each declaring a top-level `fun main()`. The
|
|
83
|
+
// JVM synthesises a distinct `FileNameKt` class per file; nothing here
|
|
84
|
+
// does. okhttp's `samples/` alone has 20+ such files.
|
|
85
|
+
// - Overloads: two methods on the same class sharing a name over different
|
|
86
|
+
// parameters, which this adapter cannot distinguish without a type
|
|
87
|
+
// checker. Measured on ktor: `AuthenticationConfig.provider(name)` and
|
|
88
|
+
// `.provider(name, configure, body)` collide; the second's own body is
|
|
89
|
+
// the one that calls `requireProviderNotRegistered`, and it was silently
|
|
90
|
+
// unreachable through the merged node.
|
|
91
|
+
//
|
|
92
|
+
// Pass 1 used to keep the first declaration under a key and silently drop
|
|
93
|
+
// the rest with no disclosure — a rule-7 violation — which cross-attributes
|
|
94
|
+
// every edge that belonged to a dropped declaration onto whichever one
|
|
95
|
+
// happened to parse first, rather than losing them cleanly.
|
|
96
|
+
// -------------------------------------------------------------------------
|
|
97
|
+
const declNameCount = new Map();
|
|
98
|
+
for (const unit of input.files) {
|
|
99
|
+
if (unit.identityPackage === undefined)
|
|
100
|
+
continue;
|
|
101
|
+
for (const decl of unit.decls) {
|
|
102
|
+
const k = `type::${typeKey(unit.identityPackage, [...decl.owners, decl.name])}`;
|
|
103
|
+
declNameCount.set(k, (declNameCount.get(k) ?? 0) + 1);
|
|
104
|
+
}
|
|
105
|
+
for (const fn of unit.funcs) {
|
|
106
|
+
const k = `func::${typeKey(unit.identityPackage, [...fn.owners, fn.name])}`;
|
|
107
|
+
declNameCount.set(k, (declNameCount.get(k) ?? 0) + 1);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// -------------------------------------------------------------------------
|
|
111
|
+
// Pass 1 — declarations. A file with no derivable identity package mints
|
|
112
|
+
// nothing at all, and says so once for the file rather than once per symbol.
|
|
113
|
+
// -------------------------------------------------------------------------
|
|
114
|
+
for (const unit of input.files) {
|
|
115
|
+
if (unit.identityPackage === undefined) {
|
|
116
|
+
refuse("", "IMPORTS", unit.file, unit.file, 1, "This file does not sit where its package declaration says it should, so its module and " +
|
|
117
|
+
"source set cannot be derived. Anchoring on the package alone would give two files in " +
|
|
118
|
+
"different source sets one identity — which Kotlin Multiplatform produces deliberately " +
|
|
119
|
+
"via expect/actual — so no node is emitted for this file. Measured: 4 such collisions on " +
|
|
120
|
+
"ktor, 0 on okhttp.");
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const moduleId = nodeId(scope, "MODULE", symbolQsp(unit.identityPackage, [fileStem(unit.file)]), LANGUAGE);
|
|
124
|
+
moduleIdOf.set(unit.file, moduleId);
|
|
125
|
+
nodes.push({
|
|
126
|
+
id: moduleId,
|
|
127
|
+
type: "MODULE",
|
|
128
|
+
name: fileStem(unit.file),
|
|
129
|
+
file: unit.file,
|
|
130
|
+
range: { startLine: 1, endLine: 1 },
|
|
131
|
+
language: LANGUAGE,
|
|
132
|
+
producedBy: input.producedBy,
|
|
133
|
+
resolution: 0,
|
|
134
|
+
attrs: {
|
|
135
|
+
packageName: unit.packageName,
|
|
136
|
+
...(unit.hasError ? { parsedWithErrors: true } : {}),
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
// Counted in parse.ts, disclosed here. Silence is the worst direction under
|
|
140
|
+
// rule 7, and a declaration this adapter deliberately declined to place is
|
|
141
|
+
// exactly the thing a reader would otherwise assume absent from the source.
|
|
142
|
+
if (unit.unparseableDeclarations > 0) {
|
|
143
|
+
refuse(moduleId, "IMPORTS", fileStem(unit.file), unit.file, 1, `${unit.unparseableDeclarations} declaration(s) in this file could not be placed. The pinned ` +
|
|
144
|
+
"grammar does not parse Kotlin's `fun interface`, so the body arrives as an orphaned lambda " +
|
|
145
|
+
"and its nested types would be minted at package level — a wrong owner rather than a missing " +
|
|
146
|
+
"one. They are declined instead.");
|
|
147
|
+
}
|
|
148
|
+
if (unit.anonymousMembers > 0) {
|
|
149
|
+
refuse(moduleId, "CALLS", fileStem(unit.file), unit.file, 1, `${unit.anonymousMembers} member(s) of anonymous \`object : X {}\` declarations in this file ` +
|
|
150
|
+
"have no qualified symbol path and are not emitted.");
|
|
151
|
+
}
|
|
152
|
+
for (const decl of unit.decls) {
|
|
153
|
+
const path = [...decl.owners, decl.name];
|
|
154
|
+
const key = typeKey(unit.identityPackage, path);
|
|
155
|
+
const typeCollisions = declNameCount.get(`type::${key}`) ?? 0;
|
|
156
|
+
if (typeCollisions > 1) {
|
|
157
|
+
refuse(moduleId, "IMPORTS", decl.name, unit.file, decl.startLine, `${typeCollisions} declarations named "${path.join(".")}" share one identity in package ` +
|
|
158
|
+
`"${packageOf(unit.identityPackage)}" — this adapter has no parameter-type or file component to ` +
|
|
159
|
+
"tell them apart. Refused rather than merged onto one arbitrary winner.");
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (typesByKey.has(key))
|
|
163
|
+
continue;
|
|
164
|
+
const id = nodeId(scope, "CLASS", symbolQsp(unit.identityPackage, path), LANGUAGE);
|
|
165
|
+
typesByKey.set(key, { id, kind: decl.kind, identityPackage: unit.identityPackage, file: unit.file, path });
|
|
166
|
+
nodes.push({
|
|
167
|
+
id,
|
|
168
|
+
type: "CLASS",
|
|
169
|
+
name: decl.name,
|
|
170
|
+
file: unit.file,
|
|
171
|
+
range: { startLine: decl.startLine, endLine: decl.endLine },
|
|
172
|
+
language: LANGUAGE,
|
|
173
|
+
producedBy: input.producedBy,
|
|
174
|
+
resolution: 0,
|
|
175
|
+
attrs: { declarationForm: decl.kind },
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
for (const fn of unit.funcs) {
|
|
179
|
+
const path = [...fn.owners, fn.name];
|
|
180
|
+
const key = typeKey(unit.identityPackage, path);
|
|
181
|
+
const funcCollisions = declNameCount.get(`func::${key}`) ?? 0;
|
|
182
|
+
if (funcCollisions > 1) {
|
|
183
|
+
refuse(moduleId, "IMPORTS", fn.name, unit.file, fn.startLine, `${funcCollisions} functions named "${path.join(".")}" share one identity in package ` +
|
|
184
|
+
`"${packageOf(unit.identityPackage)}" — this adapter has no parameter-type or file component to tell ` +
|
|
185
|
+
"an overload, or two same-named top-level declarations, apart. Refused rather than merged onto one arbitrary winner.");
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const id = fn.isTest
|
|
189
|
+
? nodeId(scope, "TEST_CASE", symbolQsp(unit.identityPackage, path), LANGUAGE)
|
|
190
|
+
: nodeId(scope, "FUNCTION", symbolQsp(unit.identityPackage, path), LANGUAGE);
|
|
191
|
+
if (funcsByKey.has(key))
|
|
192
|
+
continue;
|
|
193
|
+
funcsByKey.set(key, { id, identityPackage: unit.identityPackage, owners: fn.owners, name: fn.name, file: unit.file });
|
|
194
|
+
nodes.push({
|
|
195
|
+
id,
|
|
196
|
+
type: fn.isTest ? "TEST_CASE" : "FUNCTION",
|
|
197
|
+
name: fn.name,
|
|
198
|
+
file: unit.file,
|
|
199
|
+
range: { startLine: fn.startLine, endLine: fn.endLine },
|
|
200
|
+
language: LANGUAGE,
|
|
201
|
+
producedBy: input.producedBy,
|
|
202
|
+
resolution: 0,
|
|
203
|
+
attrs: fn.isTest
|
|
204
|
+
? { suite: fn.owners.join("."), owner: fn.owners.join("."), testStyle: fn.testStyle }
|
|
205
|
+
: fn.owners.length > 0
|
|
206
|
+
? { owner: fn.owners.join(".") }
|
|
207
|
+
: {},
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// Fields land on the owning type, which is what patterns 05/06 read.
|
|
212
|
+
for (const unit of input.files) {
|
|
213
|
+
if (unit.identityPackage === undefined)
|
|
214
|
+
continue;
|
|
215
|
+
const byOwner = new Map();
|
|
216
|
+
for (const property of unit.properties) {
|
|
217
|
+
const key = typeKey(unit.identityPackage, property.owners);
|
|
218
|
+
byOwner.set(key, [...(byOwner.get(key) ?? []), { name: property.name, type: property.type }]);
|
|
219
|
+
}
|
|
220
|
+
for (const [key, fields] of byOwner) {
|
|
221
|
+
const declared = typesByKey.get(key);
|
|
222
|
+
if (declared === undefined)
|
|
223
|
+
continue;
|
|
224
|
+
const index = nodes.findIndex((n) => n.id === declared.id);
|
|
225
|
+
if (index === -1)
|
|
226
|
+
continue;
|
|
227
|
+
const node = nodes[index];
|
|
228
|
+
if (node === undefined)
|
|
229
|
+
continue;
|
|
230
|
+
nodes[index] = {
|
|
231
|
+
...node,
|
|
232
|
+
attrs: {
|
|
233
|
+
...node.attrs,
|
|
234
|
+
// `type` is the element type and `list` a boolean, per the contract
|
|
235
|
+
// Lane E stated and every other adapter follows. Kotlin infers, so an
|
|
236
|
+
// absent type is "not written", never "no type".
|
|
237
|
+
fields: fields.map((f) => ({ name: f.name, type: f.type ?? null, list: false })),
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// -------------------------------------------------------------------------
|
|
243
|
+
// Pass 2 — edges.
|
|
244
|
+
// -------------------------------------------------------------------------
|
|
245
|
+
/** Types a file can name: its own package, plus everything it imports. */
|
|
246
|
+
function resolveType(unit, written) {
|
|
247
|
+
if (unit.identityPackage === undefined)
|
|
248
|
+
return undefined;
|
|
249
|
+
// A written type may be a dotted path to a NESTED type —
|
|
250
|
+
// `DiskLruCache.Snapshot`. The whole path is tried first, because resolving
|
|
251
|
+
// it to its outermost segment is how `snapshot.getSource()` came to be
|
|
252
|
+
// refused against `DiskLruCache`, where the member genuinely is not.
|
|
253
|
+
const segments = written.split(".");
|
|
254
|
+
const name = segments[segments.length - 1];
|
|
255
|
+
if (segments.length > 1) {
|
|
256
|
+
const nested = typesByKey.get(typeKey(unit.identityPackage, segments));
|
|
257
|
+
if (nested !== undefined)
|
|
258
|
+
return nested;
|
|
259
|
+
// The enclosing type may be imported from another package while the path
|
|
260
|
+
// is written relative to it, so the tail is matched against declared
|
|
261
|
+
// paths rather than assumed to be package-local.
|
|
262
|
+
const byPath = [...typesByKey.values()].filter((candidate) => candidate.path.join(".").endsWith(segments.join(".")));
|
|
263
|
+
if (byPath.length === 1)
|
|
264
|
+
return byPath[0];
|
|
265
|
+
if (byPath.length > 1)
|
|
266
|
+
return undefined;
|
|
267
|
+
}
|
|
268
|
+
// Innermost-outward: a nested type shadows a package-level one.
|
|
269
|
+
const own = typesByKey.get(typeKey(unit.identityPackage, [name]));
|
|
270
|
+
if (own !== undefined)
|
|
271
|
+
return own;
|
|
272
|
+
for (const candidate of typesByKey.values()) {
|
|
273
|
+
if (candidate.identityPackage !== unit.identityPackage)
|
|
274
|
+
continue;
|
|
275
|
+
if (candidate.path[candidate.path.length - 1] === name)
|
|
276
|
+
return candidate;
|
|
277
|
+
}
|
|
278
|
+
// An explicit import names the symbol; a wildcard names its package. Both
|
|
279
|
+
// are matched against the DECLARATION table rather than assumed to exist —
|
|
280
|
+
// an import of something this adapter never read resolves to nothing and is
|
|
281
|
+
// refused, not invented.
|
|
282
|
+
const matches = [];
|
|
283
|
+
for (const imported of unit.imports) {
|
|
284
|
+
const wanted = imported.isWildcard ? name : imported.path.split(".").pop();
|
|
285
|
+
if (wanted !== name && imported.alias !== name)
|
|
286
|
+
continue;
|
|
287
|
+
for (const candidate of typesByKey.values()) {
|
|
288
|
+
if (candidate.path[candidate.path.length - 1] !== name)
|
|
289
|
+
continue;
|
|
290
|
+
matches.push(candidate);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// Two candidates and no way to choose is an ambiguity, not a coin flip.
|
|
294
|
+
if (matches.length === 1)
|
|
295
|
+
return matches[0];
|
|
296
|
+
if (matches.length > 1)
|
|
297
|
+
return undefined;
|
|
298
|
+
// Same declared PACKAGE name, but a different source set — `src/test/kotlin`
|
|
299
|
+
// naming a class in `src/main/kotlin` with no import, which Kotlin compiles
|
|
300
|
+
// without one because a test source set's classpath includes main. Tried
|
|
301
|
+
// last, and only when there is exactly one candidate: two declarations
|
|
302
|
+
// sharing a package name across different SOURCE SETS OR MODULES is the
|
|
303
|
+
// exact collision `adapter-jvm`'s identity design exists to catch (airbyte's
|
|
304
|
+
// `RedisDataFactory`, declared under both `src/test/java` and
|
|
305
|
+
// `src/test-integration/java`) — so ambiguity here still refuses rather than
|
|
306
|
+
// guesses, the same rule Java's own `pick` applies.
|
|
307
|
+
if (unit.packageName === "")
|
|
308
|
+
return undefined;
|
|
309
|
+
const samePackageName = [...typesByKey.values()].filter((candidate) => packageOf(candidate.identityPackage) === unit.packageName &&
|
|
310
|
+
candidate.path[candidate.path.length - 1] === name);
|
|
311
|
+
return samePackageName.length === 1 ? samePackageName[0] : undefined;
|
|
312
|
+
}
|
|
313
|
+
function memberOf(owner, name) {
|
|
314
|
+
return funcsByKey.get(typeKey(owner.identityPackage, [...owner.path, name]));
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* A top-level function a bare unqualified call may name: one in the file's
|
|
318
|
+
* own package, or one reached through an explicit or wildcard import.
|
|
319
|
+
*
|
|
320
|
+
* The same gap `resolveType` closes for types, but for functions: an
|
|
321
|
+
* unqualified call to an imported top-level function (`validateOrder`,
|
|
322
|
+
* imported from a different package) was refused as "no declaration" because
|
|
323
|
+
* this lookup only ever checked the caller's OWN package. Kotlin needs no
|
|
324
|
+
* import for a same-package call, which is what made the gap invisible until
|
|
325
|
+
* a construct genuinely needing a cross-package import existed to hit it.
|
|
326
|
+
*/
|
|
327
|
+
function resolveFreeFunction(unit, name) {
|
|
328
|
+
if (unit.identityPackage === undefined)
|
|
329
|
+
return undefined;
|
|
330
|
+
const own = funcsByKey.get(typeKey(unit.identityPackage, [name]));
|
|
331
|
+
if (own !== undefined)
|
|
332
|
+
return own;
|
|
333
|
+
const matches = [];
|
|
334
|
+
for (const imported of unit.imports) {
|
|
335
|
+
if (!imported.isWildcard) {
|
|
336
|
+
const tail = imported.path.split(".").pop();
|
|
337
|
+
if (tail !== name && imported.alias !== name)
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
for (const fn of funcsByKey.values()) {
|
|
341
|
+
if (fn.owners.length > 0)
|
|
342
|
+
continue; // a member, not a free function
|
|
343
|
+
if (fn.name !== name)
|
|
344
|
+
continue;
|
|
345
|
+
const fqn = `${packageOf(fn.identityPackage)}.${fn.name}`;
|
|
346
|
+
if (imported.isWildcard ? packageOf(fn.identityPackage) !== imported.path : fqn !== imported.path)
|
|
347
|
+
continue;
|
|
348
|
+
matches.push(fn);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
// Two candidates and no way to choose is an ambiguity, not a coin flip —
|
|
352
|
+
// the same rule `resolveType` applies to an imported type name.
|
|
353
|
+
if (matches.length === 1)
|
|
354
|
+
return matches[0];
|
|
355
|
+
if (matches.length > 1)
|
|
356
|
+
return undefined;
|
|
357
|
+
// Same declared package, different source set — see `resolveType`'s
|
|
358
|
+
// identical fallback for the reasoning; a bare call to a same-package
|
|
359
|
+
// top-level function needs it exactly as a bare type reference does.
|
|
360
|
+
if (unit.packageName === "")
|
|
361
|
+
return undefined;
|
|
362
|
+
const samePackageName = [...funcsByKey.values()].filter((fn) => fn.owners.length === 0 && fn.name === name && packageOf(fn.identityPackage) === unit.packageName);
|
|
363
|
+
return samePackageName.length === 1 ? samePackageName[0] : undefined;
|
|
364
|
+
}
|
|
365
|
+
for (const unit of input.files) {
|
|
366
|
+
if (unit.identityPackage === undefined)
|
|
367
|
+
continue;
|
|
368
|
+
const moduleId = moduleIdOf.get(unit.file);
|
|
369
|
+
// IMPORTS — file-granular, to the module of the imported symbol.
|
|
370
|
+
if (moduleId !== undefined) {
|
|
371
|
+
for (const imported of unit.imports) {
|
|
372
|
+
// A STAR IMPORT IS REFUSED. `import io.ktor.util.*` names a package,
|
|
373
|
+
// and the IR has no PACKAGE node — so the only available edge is
|
|
374
|
+
// MODULE->MODULE, once per file in that package. Measured on ktor: 144
|
|
375
|
+
// of 183 sampled such edges point at a file whose declarations the
|
|
376
|
+
// importing file never names anywhere in its text, and ktor writes
|
|
377
|
+
// 9,222 star imports against 4,877 explicit ones.
|
|
378
|
+
//
|
|
379
|
+
// That made IMPORTS 89,080 of 98,551 edges at roughly 79% wrong — 90%
|
|
380
|
+
// of the graph, and the single reason both precision gates failed.
|
|
381
|
+
// Rule 2 decides it: a wrong edge corrupts every layer above, a missing
|
|
382
|
+
// one is a disclosed gap.
|
|
383
|
+
if (imported.isWildcard) {
|
|
384
|
+
refuse(moduleId, "IMPORTS", `${imported.path}.*`, unit.file, imported.line, "A star import names a package, and the IR has no PACKAGE node. The only edge " +
|
|
385
|
+
"available is one per file in that package, and most of those files declare nothing " +
|
|
386
|
+
"this file uses — measured at roughly 79% wrong on a reference repository. Refused " +
|
|
387
|
+
"rather than emitted; the dependency is real but not expressible at this resolution.");
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
// An explicit import is matched on its FULL PACKAGE PATH, not on the
|
|
391
|
+
// last segment. Matching by simple name gave `import okhttp3.Request` an
|
|
392
|
+
// edge to a nested `data class Request` inside another Gradle module's
|
|
393
|
+
// ShadowDnsResolver, and made mockwebserver and mockwebserver-deprecated
|
|
394
|
+
// resolve into each other in both directions.
|
|
395
|
+
const wanted = imported.path;
|
|
396
|
+
const targets = new Set();
|
|
397
|
+
for (const candidate of typesByKey.values()) {
|
|
398
|
+
const fqn = `${packageOf(candidate.identityPackage)}.${candidate.path.join(".")}`;
|
|
399
|
+
if (fqn !== wanted)
|
|
400
|
+
continue;
|
|
401
|
+
const target = moduleIdOf.get(candidate.file);
|
|
402
|
+
if (target !== undefined && target !== moduleId)
|
|
403
|
+
targets.add(target);
|
|
404
|
+
}
|
|
405
|
+
// A Kotlin import may name a TOP-LEVEL FUNCTION or property, not only a
|
|
406
|
+
// type — `import io.ktor.server.request.receive` is an extension
|
|
407
|
+
// function. Matching only against types made 21 of 22 adjudicated wrong
|
|
408
|
+
// refusals on ktor, and the shape looked like a filename problem from
|
|
409
|
+
// the outside because the declaring file is named for its subject
|
|
410
|
+
// (`ApplicationReceiveFunctions.kt`) rather than for the symbol.
|
|
411
|
+
if (targets.size === 0) {
|
|
412
|
+
for (const fn of funcsByKey.values()) {
|
|
413
|
+
if (fn.owners.length > 0)
|
|
414
|
+
continue; // a member, not importable alone
|
|
415
|
+
if (`${packageOf(fn.identityPackage)}.${fn.name}` !== wanted)
|
|
416
|
+
continue;
|
|
417
|
+
const target = moduleIdOf.get(fn.file);
|
|
418
|
+
if (target !== undefined && target !== moduleId)
|
|
419
|
+
targets.add(target);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (targets.size === 0) {
|
|
423
|
+
refuse(moduleId, "IMPORTS", imported.path, unit.file, imported.line, "No declaration for this import in the analysed set — it is a dependency, a standard " +
|
|
424
|
+
"library symbol, or a file outside the parsed tree.");
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
for (const target of targets)
|
|
428
|
+
push({ from: moduleId, to: target, type: "IMPORTS" }, 0.9, 1);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
// Supertypes — decided against the table, never from syntax alone.
|
|
432
|
+
for (const decl of unit.decls) {
|
|
433
|
+
const subject = typesByKey.get(typeKey(unit.identityPackage, [...decl.owners, decl.name]));
|
|
434
|
+
if (subject === undefined)
|
|
435
|
+
continue;
|
|
436
|
+
for (const supertype of decl.supertypes) {
|
|
437
|
+
const target = resolveType(unit, supertype.name);
|
|
438
|
+
if (target === undefined) {
|
|
439
|
+
refuse(subject.id, "IMPLEMENTS", supertype.name, unit.file, supertype.line, "No declaration for this supertype in the analysed set — it is a dependency or a " +
|
|
440
|
+
"standard library type.");
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
const edgeType = classifySupertype(target, supertype);
|
|
444
|
+
push({ from: subject.id, to: target.id, type: edgeType }, edgeType === "INHERITS" ? 0.95 : 0.9, 1);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
// Calls.
|
|
448
|
+
for (const fn of unit.funcs) {
|
|
449
|
+
const from = funcsByKey.get(typeKey(unit.identityPackage, [...fn.owners, fn.name]));
|
|
450
|
+
if (from === undefined)
|
|
451
|
+
continue;
|
|
452
|
+
for (const ref of fn.refs) {
|
|
453
|
+
if (ref.kind === "type") {
|
|
454
|
+
const target = resolveType(unit, ref.name);
|
|
455
|
+
if (target !== undefined)
|
|
456
|
+
push({ from: from.id, to: target.id, type: "USES_TYPE" }, 0.85, 1);
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
if (!ref.hasReceiver) {
|
|
460
|
+
// A bare call: a top-level function (own package or imported), or a
|
|
461
|
+
// member of the enclosing type.
|
|
462
|
+
const enclosing = fn.owners.length > 0
|
|
463
|
+
? typesByKey.get(typeKey(unit.identityPackage, fn.owners))
|
|
464
|
+
: undefined;
|
|
465
|
+
const member = enclosing === undefined ? undefined : memberOf(enclosing, ref.name);
|
|
466
|
+
const free = resolveFreeFunction(unit, ref.name);
|
|
467
|
+
const target = member ?? free;
|
|
468
|
+
if (target === undefined) {
|
|
469
|
+
// A constructor invocation is syntactically identical to a call —
|
|
470
|
+
// Kotlin has no `new` keyword — so a name that resolves to a TYPE
|
|
471
|
+
// rather than a function is a class or enum named as a VALUE
|
|
472
|
+
// (DEC-068), not a missing declaration.
|
|
473
|
+
const asType = resolveType(unit, ref.name);
|
|
474
|
+
if (asType !== undefined) {
|
|
475
|
+
push({ from: from.id, to: asType.id, type: "USES_TYPE" }, 0.85, 1);
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
refuse(from.id, "CALLS", ref.name, unit.file, ref.line, "No declaration for this name in the analysed set.");
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
push({ from: from.id, to: target.id, type: "CALLS" }, 0.85, 1);
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
if (ref.receiverType === undefined) {
|
|
485
|
+
// The receiver has no written LOCAL type — but the receiver name
|
|
486
|
+
// itself may resolve to a declared type, which is `object`,
|
|
487
|
+
// `companion object` and static-style access: `Routes.getOrder(id)`
|
|
488
|
+
// has no local variable named `Routes` to look up, because `Routes`
|
|
489
|
+
// IS the type. Tried before refusing, not instead of the ordinary
|
|
490
|
+
// path — a local variable always wins when one exists.
|
|
491
|
+
const asStatic = ref.receiver === undefined ? undefined : resolveType(unit, ref.receiver);
|
|
492
|
+
const staticMember = asStatic === undefined ? undefined : memberOf(asStatic, ref.name);
|
|
493
|
+
if (staticMember !== undefined) {
|
|
494
|
+
push({ from: from.id, to: staticMember.id, type: "CALLS" }, 0.9, 1);
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
refuse(from.id, "CALLS", ref.name, unit.file, ref.line, ref.receiver === undefined
|
|
498
|
+
? "The receiver is an expression, so it has no written type at this reference. " +
|
|
499
|
+
"Resolving it needs a type checker (R3)."
|
|
500
|
+
: `The type of receiver "${ref.receiver}" is not written at this reference. Kotlin ` +
|
|
501
|
+
"infers it; recovering it needs a type checker (R3).");
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
const owner = resolveType(unit, ref.receiverType);
|
|
505
|
+
const target = owner === undefined ? undefined : memberOf(owner, ref.name);
|
|
506
|
+
if (target === undefined) {
|
|
507
|
+
refuse(from.id, "CALLS", ref.name, unit.file, ref.line, owner === undefined
|
|
508
|
+
? `No declaration for receiver type "${ref.receiverType}" in the analysed set.`
|
|
509
|
+
: `"${ref.name}" is not declared on ${owner.path.join(".")} in the analysed set. It may ` +
|
|
510
|
+
"be inherited, an extension function, or defined in a dependency.");
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
// R1, NOT R2. The ladder defines R2 as "+ LSP references/definitions"
|
|
514
|
+
// and there is no LSP here — this edge rests on a WRITTEN type
|
|
515
|
+
// annotation resolved against the declaration table, which is
|
|
516
|
+
// module/import resolution plus a piece of source text. Claiming R2 for
|
|
517
|
+
// it would be claiming a capability the adapter does not have. Raised by
|
|
518
|
+
// the lane grading this adapter, who could not read this file and asked
|
|
519
|
+
// what the 2 was backed by; the honest answer was nothing.
|
|
520
|
+
push({ from: from.id, to: target.id, type: "CALLS" }, 0.9, 1);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
// TESTS — a test case covers what it calls.
|
|
525
|
+
for (const edge of [...edges]) {
|
|
526
|
+
if (edge.type !== "CALLS")
|
|
527
|
+
continue;
|
|
528
|
+
const source = nodes.find((n) => n.id === edge.from);
|
|
529
|
+
if (source?.type !== "TEST_CASE")
|
|
530
|
+
continue;
|
|
531
|
+
push({ from: edge.from, to: edge.to, type: "TESTS" }, edge.confidence, edge.resolution);
|
|
532
|
+
}
|
|
533
|
+
// ---------------------------------------------------------------------------
|
|
534
|
+
// Routes — Ktor's routing DSL. Last, because every ledger row is attributed
|
|
535
|
+
// to a MODULE the pass above has already minted.
|
|
536
|
+
// ---------------------------------------------------------------------------
|
|
537
|
+
const routeNodes = new Map();
|
|
538
|
+
const endpointNodes = new Map();
|
|
539
|
+
for (const unit of input.files) {
|
|
540
|
+
const moduleId = moduleIdOf.get(unit.file);
|
|
541
|
+
if (moduleId === undefined)
|
|
542
|
+
continue;
|
|
543
|
+
for (const refusal of unit.routeRefusals) {
|
|
544
|
+
refuse(moduleId, "SERVES_API", refusal.rawTarget, unit.file, refusal.line, refusal.reason);
|
|
545
|
+
}
|
|
546
|
+
// R2 and no lower — a route emitted from R0 or R1 is a claim stronger
|
|
547
|
+
// than the run that produced it, which the Normaliser rejects as
|
|
548
|
+
// RESOLUTION_EXCEEDS_BATCH.
|
|
549
|
+
if (input.reached < 2)
|
|
550
|
+
continue;
|
|
551
|
+
for (const route of unit.routes) {
|
|
552
|
+
const template = normaliseEndpointPath(route.template);
|
|
553
|
+
const name = `${route.method} ${route.template}`;
|
|
554
|
+
const routeId = nodeId(scope, "API_ROUTE", name, LANGUAGE);
|
|
555
|
+
if (!routeNodes.has(routeId)) {
|
|
556
|
+
routeNodes.set(routeId, {
|
|
557
|
+
id: routeId,
|
|
558
|
+
type: "API_ROUTE",
|
|
559
|
+
name,
|
|
560
|
+
file: unit.file,
|
|
561
|
+
range: { startLine: route.line, endLine: route.line },
|
|
562
|
+
language: LANGUAGE,
|
|
563
|
+
producedBy: input.producedBy,
|
|
564
|
+
resolution: 2,
|
|
565
|
+
attrs: {
|
|
566
|
+
method: route.method,
|
|
567
|
+
pathTemplate: template,
|
|
568
|
+
rawTemplate: route.template,
|
|
569
|
+
written: route.written,
|
|
570
|
+
framework: route.framework,
|
|
571
|
+
},
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
// Fileless, language-less, minted with the SAME endpointQsp every other
|
|
575
|
+
// producer uses (DEC-115) — two producers that mint different ids do
|
|
576
|
+
// not conflict, they silently fail to join.
|
|
577
|
+
const endpointId = nodeId(scope, "API_ENDPOINT", endpointQsp(route.method, route.template), null);
|
|
578
|
+
if (!endpointNodes.has(endpointId)) {
|
|
579
|
+
endpointNodes.set(endpointId, {
|
|
580
|
+
id: endpointId,
|
|
581
|
+
type: "API_ENDPOINT",
|
|
582
|
+
name: `${route.method} ${template}`,
|
|
583
|
+
file: null,
|
|
584
|
+
range: null,
|
|
585
|
+
language: null,
|
|
586
|
+
producedBy: input.producedBy,
|
|
587
|
+
resolution: 2,
|
|
588
|
+
attrs: { method: route.method, pathTemplate: template },
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
push({ from: routeId, to: endpointId, type: "SERVES_API" }, 0.9, 2);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
// ---------------------------------------------------------------------------
|
|
595
|
+
// Client calls — the caller half. Minted through the same `endpointQsp`, so a
|
|
596
|
+
// call and the route that serves it land on ONE endpoint node rather than two
|
|
597
|
+
// that never meet.
|
|
598
|
+
// ---------------------------------------------------------------------------
|
|
599
|
+
/**
|
|
600
|
+
* DEC-242's `attrs.blockedBy`/`attrs.refusalClass`, plus the diagnostic-only
|
|
601
|
+
* `argumentKind` — `undefined` when `refusalClass` was never set, so
|
|
602
|
+
* `refuse` omits `attrs` entirely rather than sending it with an
|
|
603
|
+
* `undefined` field (unset stays legal and means unclassified).
|
|
604
|
+
*/
|
|
605
|
+
const clientAttrsOf = (refusal) => refusal.refusalClass === undefined
|
|
606
|
+
? undefined
|
|
607
|
+
: {
|
|
608
|
+
blockedBy: refusal.blockedBy,
|
|
609
|
+
refusalClass: refusal.refusalClass,
|
|
610
|
+
...(refusal.argumentKind === undefined ? {} : { argumentKind: refusal.argumentKind }),
|
|
611
|
+
};
|
|
612
|
+
for (const unit of input.files) {
|
|
613
|
+
const moduleId = moduleIdOf.get(unit.file);
|
|
614
|
+
if (moduleId === undefined)
|
|
615
|
+
continue;
|
|
616
|
+
for (const refusal of unit.clientRefusals) {
|
|
617
|
+
refuse(moduleId, "USES_API", refusal.rawTarget, unit.file, refusal.line, refusal.reason, clientAttrsOf(refusal));
|
|
618
|
+
}
|
|
619
|
+
if (input.reached < 2)
|
|
620
|
+
continue;
|
|
621
|
+
for (const call of unit.clientCalls) {
|
|
622
|
+
// The caller is the innermost function whose span contains the call, so
|
|
623
|
+
// the edge starts at the thing a reader would blame. A call outside any
|
|
624
|
+
// function — a property initialiser at file scope — is attributed to the
|
|
625
|
+
// MODULE, which is the file-scoped carrier by DEC-124.
|
|
626
|
+
const enclosing = unit.funcs
|
|
627
|
+
.filter((fn) => fn.startLine <= call.line && call.line <= fn.endLine)
|
|
628
|
+
.sort((a, b) => b.startLine - a.startLine)[0];
|
|
629
|
+
// A file that does not sit where its package declaration says has no
|
|
630
|
+
// identity package, so its functions have no ids to point at (DEC-173).
|
|
631
|
+
// The MODULE still does, and it is the file-scoped carrier by DEC-124 —
|
|
632
|
+
// so the edge degrades to the file rather than being dropped.
|
|
633
|
+
const from = enclosing === undefined || unit.identityPackage === undefined
|
|
634
|
+
? moduleId
|
|
635
|
+
: funcsByKey.get(typeKey(unit.identityPackage, [...enclosing.owners, enclosing.name]))?.id ?? moduleId;
|
|
636
|
+
const template = normaliseEndpointPath(call.path);
|
|
637
|
+
const endpointId = nodeId(scope, "API_ENDPOINT", endpointQsp(call.method, call.path), null);
|
|
638
|
+
if (!endpointNodes.has(endpointId)) {
|
|
639
|
+
endpointNodes.set(endpointId, {
|
|
640
|
+
id: endpointId,
|
|
641
|
+
type: "API_ENDPOINT",
|
|
642
|
+
name: `${call.method} ${template}`,
|
|
643
|
+
file: null,
|
|
644
|
+
range: null,
|
|
645
|
+
language: null,
|
|
646
|
+
producedBy: input.producedBy,
|
|
647
|
+
resolution: 2,
|
|
648
|
+
attrs: { method: call.method, pathTemplate: template },
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
push({ from, to: endpointId, type: "USES_API", attrs: { client: call.client, rawPath: call.rawPath } }, 0.9, 2);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
// ---------------------------------------------------------------------------
|
|
655
|
+
// Retrofit — a declarative client interface. Unlike every call above, there is
|
|
656
|
+
// no call SITE to attribute the edge to: the annotated function's own id IS
|
|
657
|
+
// the caller, and ordinary CALLS resolution (unchanged) is what connects a
|
|
658
|
+
// real caller to it, one hop further out. See `retrofit.ts`'s own header.
|
|
659
|
+
// ---------------------------------------------------------------------------
|
|
660
|
+
for (const unit of input.files) {
|
|
661
|
+
const moduleId = moduleIdOf.get(unit.file);
|
|
662
|
+
if (moduleId === undefined)
|
|
663
|
+
continue;
|
|
664
|
+
for (const refusal of unit.retrofitRefusals) {
|
|
665
|
+
refuse(moduleId, "USES_API", refusal.rawTarget, unit.file, refusal.line, refusal.reason);
|
|
666
|
+
}
|
|
667
|
+
if (input.reached < 2)
|
|
668
|
+
continue;
|
|
669
|
+
for (const endpoint of unit.retrofitEndpoints) {
|
|
670
|
+
const from = unit.identityPackage === undefined
|
|
671
|
+
? undefined
|
|
672
|
+
: funcsByKey.get(typeKey(unit.identityPackage, [...endpoint.owners, endpoint.funcName]))?.id;
|
|
673
|
+
if (from === undefined) {
|
|
674
|
+
// Either the file has no derivable identity, or the declaring
|
|
675
|
+
// function itself collided with another of the same name and was
|
|
676
|
+
// refused in Pass 1 — both already disclosed there. Attributing this
|
|
677
|
+
// edge to the MODULE instead would be a second, misleading claim
|
|
678
|
+
// about a function this adapter declined to name.
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
const template = normaliseEndpointPath(endpoint.path);
|
|
682
|
+
const endpointId = nodeId(scope, "API_ENDPOINT", endpointQsp(endpoint.method, endpoint.path), null);
|
|
683
|
+
if (!endpointNodes.has(endpointId)) {
|
|
684
|
+
endpointNodes.set(endpointId, {
|
|
685
|
+
id: endpointId,
|
|
686
|
+
type: "API_ENDPOINT",
|
|
687
|
+
name: `${endpoint.method} ${template}`,
|
|
688
|
+
file: null,
|
|
689
|
+
range: null,
|
|
690
|
+
language: null,
|
|
691
|
+
producedBy: input.producedBy,
|
|
692
|
+
resolution: 2,
|
|
693
|
+
attrs: { method: endpoint.method, pathTemplate: template },
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
push({ from, to: endpointId, type: "USES_API", attrs: { client: "retrofit", rawPath: endpoint.written } }, 0.9, 2);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
nodes.push(...routeNodes.values(), ...endpointNodes.values());
|
|
700
|
+
return { nodes, edges, unresolved };
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* `INHERITS` or `IMPLEMENTS`, decided against the declaration table.
|
|
704
|
+
*
|
|
705
|
+
* The parenthesised form is Kotlin's own discriminator and it is checked first
|
|
706
|
+
* — but only as a **confirmation** of what the table says. A `class` target that
|
|
707
|
+
* was invoked is unambiguously the superclass; an `interface` target is
|
|
708
|
+
* unambiguously implemented whatever the syntax. Anything else falls to
|
|
709
|
+
* `IMPLEMENTS`, which is the weaker claim of the two.
|
|
710
|
+
*/
|
|
711
|
+
function classifySupertype(target, written) {
|
|
712
|
+
if (target.kind === "interface")
|
|
713
|
+
return "IMPLEMENTS";
|
|
714
|
+
if (written.invoked)
|
|
715
|
+
return "INHERITS";
|
|
716
|
+
// A bare class supertype: `interface A : B` where B resolved to a class is
|
|
717
|
+
// malformed Kotlin, so the likelier reading is that the table is incomplete.
|
|
718
|
+
// IMPLEMENTS understates rather than inventing an inheritance chain.
|
|
719
|
+
return target.kind === "class" ? "INHERITS" : "IMPLEMENTS";
|
|
720
|
+
}
|
|
721
|
+
const fileStem = (file) => file.slice(file.lastIndexOf("/") + 1).replace(/\.kt$/, "");
|
|
722
|
+
//# sourceMappingURL=extract.js.map
|