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