@descryy/adapter-kotlin 0.4.1 → 0.5.1

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.
Files changed (50) hide show
  1. package/dist/adapter.d.ts.map +1 -1
  2. package/dist/adapter.js +86 -14
  3. package/dist/adapter.js.map +1 -1
  4. package/dist/client-base.d.ts +114 -0
  5. package/dist/client-base.d.ts.map +1 -0
  6. package/dist/client-base.js +102 -0
  7. package/dist/client-base.js.map +1 -0
  8. package/dist/client.d.ts +23 -1
  9. package/dist/client.d.ts.map +1 -1
  10. package/dist/client.js +207 -13
  11. package/dist/client.js.map +1 -1
  12. package/dist/external.d.ts +149 -0
  13. package/dist/external.d.ts.map +1 -0
  14. package/dist/external.js +226 -0
  15. package/dist/external.js.map +1 -0
  16. package/dist/extract.d.ts +34 -0
  17. package/dist/extract.d.ts.map +1 -1
  18. package/dist/extract.js +476 -47
  19. package/dist/extract.js.map +1 -1
  20. package/dist/jpa-reads.d.ts +26 -0
  21. package/dist/jpa-reads.d.ts.map +1 -0
  22. package/dist/jpa-reads.js +35 -0
  23. package/dist/jpa-reads.js.map +1 -0
  24. package/dist/jpa.d.ts +147 -0
  25. package/dist/jpa.d.ts.map +1 -0
  26. package/dist/jpa.js +415 -0
  27. package/dist/jpa.js.map +1 -0
  28. package/dist/parse.d.ts +44 -0
  29. package/dist/parse.d.ts.map +1 -1
  30. package/dist/parse.js +106 -9
  31. package/dist/parse.js.map +1 -1
  32. package/dist/retrofit.d.ts +12 -0
  33. package/dist/retrofit.d.ts.map +1 -1
  34. package/dist/retrofit.js +53 -0
  35. package/dist/retrofit.js.map +1 -1
  36. package/dist/routes.d.ts +15 -0
  37. package/dist/routes.d.ts.map +1 -1
  38. package/dist/routes.js.map +1 -1
  39. package/dist/serialization.d.ts +47 -0
  40. package/dist/serialization.d.ts.map +1 -0
  41. package/dist/serialization.js +55 -0
  42. package/dist/serialization.js.map +1 -0
  43. package/dist/shape-reach.d.ts +97 -0
  44. package/dist/shape-reach.d.ts.map +1 -0
  45. package/dist/shape-reach.js +156 -0
  46. package/dist/shape-reach.js.map +1 -0
  47. package/dist/spring.d.ts.map +1 -1
  48. package/dist/spring.js +28 -6
  49. package/dist/spring.js.map +1 -1
  50. package/package.json +7 -7
package/dist/extract.js CHANGED
@@ -22,8 +22,25 @@
22
22
  * runs Java→Kotlin; ktor is 2,324 `.kt` against 1 `.java`. A rule with zero witnesses is declared
23
23
  * absent rather than written blind.
24
24
  */
25
- import { endpointQsp, nodeId, normaliseEndpointPath, symbolQsp, } from "@descryy/ir";
25
+ import { endpointQsp, externalQsp, nodeId, normaliseEndpointPath, symbolQsp, } from "@descryy/ir";
26
+ import { fileNode } from "@descryy/adapter-common";
27
+ import { SHAPE_LEVEL } from "./shape-reach.js";
28
+ import { fieldReadsIn as jpaFieldReadsIn } from "./jpa-reads.js";
29
+ import { hasSerializationEvidence } from "./serialization.js";
30
+ import { attributeExternal, externalImportTable, } from "./external.js";
26
31
  const LANGUAGE = "kotlin";
32
+ /** Which packaging world an external package name belongs to. Kotlin publishes
33
+ * to Maven repositories like Java does, and the identity is the package the
34
+ * source writes — never a coordinate, never a resolved classpath entry. */
35
+ const EXTERNAL_ECOSYSTEM = "maven";
36
+ /** An external node is attributed from the file's own `import` block — module
37
+ * resolution and nothing more. R1 exactly: never R0 (the import IS the
38
+ * resolution), never R4 (nothing observed it run).
39
+ * `DEC-NEXT-external-symbol-nodes-at-the-dependency-boundary`. */
40
+ const EXTERNAL_RESOLUTION = 1;
41
+ /** The confidence an R1 edge carries in this adapter, matching every other R1
42
+ * edge it emits. */
43
+ const EXTERNAL_CONFIDENCE = 0.7;
27
44
  const typeKey = (identityPackage, path) => `${identityPackage}::${path.join(".")}`;
28
45
  // identityPackage is `module[/sourceSet]/package`; the package is the segment
29
46
  // after the last slash and is what an import path states.
@@ -34,6 +51,15 @@ export function extract(input) {
34
51
  const unresolved = [];
35
52
  const scope = input.scope;
36
53
  const typesByKey = new Map();
54
+ /**
55
+ * The raw declaration and its own file behind each `typesByKey` entry —
56
+ * `DeclaredType` itself carries no annotations or import list, and
57
+ * `responseShapeEvidence` (DEC-NEXT-dto-shape-evidence-for-api-contracts)
58
+ * needs both to verify kotlinx.serialization evidence. Populated in
59
+ * lockstep with `typesByKey` below so the two can never disagree on which
60
+ * key names which declaration.
61
+ */
62
+ const declByKey = new Map();
37
63
  const funcsByKey = new Map();
38
64
  const moduleIdOf = new Map();
39
65
  const seenEdges = new Set();
@@ -67,6 +93,93 @@ export function extract(input) {
67
93
  else if (!seen.includes(to))
68
94
  seen.push(to);
69
95
  };
96
+ /**
97
+ * Node ids already minted for a dependency symbol — one node per symbol and
98
+ * per node type, not per reference site.
99
+ *
100
+ * The three settled rules are asserted here rather than argued: the qsp
101
+ * carries an `ext:` marker (`externalQsp`), the node claims R1 and may never
102
+ * claim R0 or R4, and it owns no outgoing edge because nothing read its body.
103
+ * `thirdParty` and `binding` ride in `attrs`, outside the hash — a package
104
+ * that moved between the standard library and a jar would otherwise change
105
+ * identity with no line of source changing.
106
+ */
107
+ const externalIds = new Map();
108
+ const mintExternal = (attribution, type) => {
109
+ if (input.reached < EXTERNAL_RESOLUTION)
110
+ return null;
111
+ const qsp = externalQsp(attribution.moduleOrNamespace, attribution.symbolPath);
112
+ const key = `${type}\u0000${qsp}`;
113
+ const existing = externalIds.get(key);
114
+ if (existing !== undefined)
115
+ return existing;
116
+ const id = nodeId(scope, type, qsp, LANGUAGE);
117
+ externalIds.set(key, id);
118
+ nodes.push({
119
+ id,
120
+ type,
121
+ name: attribution.symbolPath[attribution.symbolPath.length - 1] ?? "",
122
+ // No file and no range: the symbol lives in a dependency, and a node that
123
+ // named a file here would be invalidated by a source file it has nothing
124
+ // to do with.
125
+ file: null,
126
+ range: null,
127
+ language: LANGUAGE,
128
+ producedBy: input.producedBy,
129
+ resolution: EXTERNAL_RESOLUTION,
130
+ attrs: { thirdParty: attribution.thirdParty, binding: attribution.binding },
131
+ external: {
132
+ ecosystem: EXTERNAL_ECOSYSTEM,
133
+ moduleOrNamespace: attribution.moduleOrNamespace,
134
+ },
135
+ });
136
+ return id;
137
+ };
138
+ /**
139
+ * Mint the dependency symbol a reference reaches, and the edge into it.
140
+ * `true` when it did; `false` leaves the site to the caller's refusal.
141
+ *
142
+ * The node type follows the EDGE, not the shape of the name: `USES_TYPE`
143
+ * names a type so it mints a `CLASS`, `CALLS` names a callable so it mints a
144
+ * `FUNCTION`. A call out of a test function is `CALLS` and never `TESTS` —
145
+ * `TESTS` asserts coverage, and this run read nothing of the dependency's
146
+ * body (`DEC-NEXT-external-edge-is-calls-not-tests`).
147
+ */
148
+ const externalEdge = (from, unit, written, extra, edgeType) => {
149
+ const attribution = attributeExternal(written, extra, externalContextFor(unit));
150
+ if (attribution === null)
151
+ return false;
152
+ const to = mintExternal(attribution, edgeType === "USES_TYPE" ? "CLASS" : "FUNCTION");
153
+ if (to === null)
154
+ return false;
155
+ push({ from, to, type: edgeType }, EXTERNAL_CONFIDENCE, EXTERNAL_RESOLUTION);
156
+ return true;
157
+ };
158
+ /** The context `external.ts` attributes against, for one file. The import
159
+ * table is the file's; `ownPackages` is the repository's. */
160
+ const externalContexts = new Map();
161
+ const externalContextFor = (unit) => {
162
+ const cached = externalContexts.get(unit.file);
163
+ if (cached !== undefined)
164
+ return cached;
165
+ const context = {
166
+ imports: externalImportTable(unit.imports),
167
+ ownPackages,
168
+ };
169
+ externalContexts.set(unit.file, context);
170
+ return context;
171
+ };
172
+ /**
173
+ * Packages this repository declares for itself. A symbol under one of them is
174
+ * OURS whether or not this run read it.
175
+ *
176
+ * This is also the only guard against the cross-language case a Kotlin-only
177
+ * reader cannot see: `okhttp` declares types in both `.kt` and `.java`, and a
178
+ * Java class in a package this repository's Kotlin sources also declare is
179
+ * ours by this check. One in a package no Kotlin source declares is not
180
+ * caught, and stays a disclosed limit.
181
+ */
182
+ const ownPackages = new Set(input.files.map((each) => each.packageName).filter((name) => name !== ""));
70
183
  const refuse = (fromNodeId, edgeType, rawTarget, file, line, reason, attrs) => {
71
184
  unresolved.push({
72
185
  fromNodeId,
@@ -152,20 +265,92 @@ export function extract(input) {
152
265
  }
153
266
  if (typesByKey.has(key))
154
267
  continue;
155
- const id = nodeId(scope, "CLASS", symbolQsp(unit.identityPackage, path), LANGUAGE);
156
- typesByKey.set(key, { id, kind: decl.kind, identityPackage: unit.identityPackage, file: unit.file, path });
268
+ /**
269
+ * Kind is decided BEFORE the id is minted (DEC-004), never patched on
270
+ * after: the kind is part of the id, and everything downstream resolves
271
+ * references out of `typesByKey`. A `MODEL` is a framework claim earned
272
+ * from one annotation in `jpa.ts` — never from the name, which golden
273
+ * 07 and 12 exist to punish.
274
+ */
275
+ const model = input.reach?.entities.get(key);
276
+ const kind = model === undefined ? "CLASS" : "MODEL";
277
+ const id = nodeId(scope, kind, symbolQsp(unit.identityPackage, path), LANGUAGE);
278
+ // `isModel` is what golden 07's READS resolver asks: an edge to a model
279
+ // is a different claim from an edge to a plain class.
280
+ typesByKey.set(key, {
281
+ id,
282
+ kind: decl.kind,
283
+ identityPackage: unit.identityPackage,
284
+ file: unit.file,
285
+ path,
286
+ isModel: model !== undefined,
287
+ });
288
+ declByKey.set(key, { decl, unit });
289
+ const attrs = { declarationForm: decl.kind };
290
+ /**
291
+ * The level this node's strongest fact carries. A `MODEL` bundles two
292
+ * different claims — *this class is persisted* (one annotation, read at
293
+ * R0) and *this is its mapped shape* (the column-by-column read) — and
294
+ * only the second is R3-grade, so the shape's level is stated separately
295
+ * in `attrs.shapeResolution`, DEC-058's carrier.
296
+ *
297
+ * **Node-scoped, and deliberately unrelated to the `USES_API`-edge
298
+ * `shapeResolution` this file also now writes** (see
299
+ * `responseShapeEvidence` above,
300
+ * `DEC-NEXT-dto-shape-evidence-for-api-contracts`). This one is the
301
+ * JPA-mapped, PERSISTED database shape — `descry-core`'s contract
302
+ * engine (`engine.ts:286`) reads only the edge-level attribute for API
303
+ * wire-shape comparison and never reads a node-level `shapeResolution`
304
+ * at all, on a `MODEL` or otherwise. A class can be both — an entity
305
+ * also serialized straight to the wire via kotlinx.serialization — but
306
+ * the two facts rest on different annotations (JPA's vs.
307
+ * kotlinx.serialization's) and are asserted independently; this one is
308
+ * not the mechanism the API-shape ruling built.
309
+ */
310
+ let level = 0;
311
+ if (model !== undefined) {
312
+ attrs["orm"] = "jpa";
313
+ if (model.table !== null)
314
+ attrs["table"] = model.table;
315
+ if (model.fields.length > 0) {
316
+ attrs["fields"] = model.fields;
317
+ /**
318
+ * **Never above what the run reached.** A caller that capped this
319
+ * adapter at R2 gets the same shape one level down; a node stamped
320
+ * R3 unconditionally is rejected as `RESOLUTION_EXCEEDS_BATCH` at
321
+ * every lower ceiling and takes the rest of the batch with it. The
322
+ * R0-R2 monotonic sweep is what catches that, not the headline run.
323
+ */
324
+ level = Math.min(SHAPE_LEVEL, input.reached);
325
+ attrs["shapeResolution"] = level;
326
+ }
327
+ if (model.undecided.length > 0)
328
+ attrs["undecidedNullability"] = model.undecided;
329
+ }
157
330
  nodes.push({
158
331
  id,
159
- type: "CLASS",
332
+ type: kind,
160
333
  name: decl.name,
161
334
  file: unit.file,
162
335
  range: { startLine: decl.startLine, endLine: decl.endLine },
163
336
  language: LANGUAGE,
164
337
  producedBy: input.producedBy,
165
- resolution: 0,
166
- attrs: { declarationForm: decl.kind },
338
+ resolution: level,
339
+ attrs,
167
340
  });
168
341
  }
342
+ /**
343
+ * A shape `jpa.ts` withheld because it could not prove the column set
344
+ * complete. Disclosed rather than silently absent (rule 7): the `MODEL`
345
+ * node stands and carries no `fields`, and without this a consumer cannot
346
+ * tell "this entity has no columns" from "this entity's columns were not
347
+ * enumerable".
348
+ */
349
+ for (const refusal of input.reach?.refusals ?? []) {
350
+ if (refusal.file !== unit.file)
351
+ continue;
352
+ refuse(moduleId, "USES_TYPE", refusal.className, unit.file, 1, refusal.reason);
353
+ }
169
354
  for (const fn of unit.funcs) {
170
355
  const path = [...fn.owners, fn.name];
171
356
  const key = typeKey(unit.identityPackage, path);
@@ -176,15 +361,17 @@ export function extract(input) {
176
361
  "an overload, or two same-named top-level declarations, apart. Refused rather than merged onto one arbitrary winner.");
177
362
  continue;
178
363
  }
179
- const id = fn.isTest
180
- ? nodeId(scope, "TEST_CASE", symbolQsp(unit.identityPackage, path), LANGUAGE)
181
- : nodeId(scope, "FUNCTION", symbolQsp(unit.identityPackage, path), LANGUAGE);
364
+ // `COMPONENT` is earned by Compose's own declaration (`composableOf` in
365
+ // parse.ts), never by a name. A `@Composable` that returns a value is a
366
+ // state factory and stays a FUNCTION.
367
+ const funcType = fn.isTest ? "TEST_CASE" : fn.isComposable ? "COMPONENT" : "FUNCTION";
368
+ const id = nodeId(scope, funcType, symbolQsp(unit.identityPackage, path), LANGUAGE);
182
369
  if (funcsByKey.has(key))
183
370
  continue;
184
371
  funcsByKey.set(key, { id, identityPackage: unit.identityPackage, owners: fn.owners, name: fn.name, file: unit.file });
185
372
  nodes.push({
186
373
  id,
187
- type: fn.isTest ? "TEST_CASE" : "FUNCTION",
374
+ type: funcType,
188
375
  name: fn.name,
189
376
  file: unit.file,
190
377
  range: { startLine: fn.startLine, endLine: fn.endLine },
@@ -218,16 +405,35 @@ export function extract(input) {
218
405
  const node = nodes[index];
219
406
  if (node === undefined)
220
407
  continue;
221
- nodes[index] = {
222
- ...node,
223
- attrs: {
224
- ...node.attrs,
225
- // `type` is the element type, `list` a boolean — the contract Lane E
226
- // stated and every adapter follows. Kotlin infers, so an absent
227
- // type means "not written," never "no type."
228
- fields: fields.map((f) => ({ name: f.name, type: f.type ?? null, list: false })),
229
- },
230
- };
408
+ /**
409
+ * **Two producers write `attrs.fields`, and they are not the same fact.**
410
+ * The language pass below reads every declared property and says what
411
+ * its type is *written* as — R0/R1 evidence. `jpa.ts` reads the mapped
412
+ * column set and says whether each column is *nullable* — R3 evidence,
413
+ * and the assertion golden 06 exists to make. A plain overwrite here
414
+ * silently destroyed the second: the shape was minted, then replaced by
415
+ * a list that had lost `nullable` before anything downstream saw it.
416
+ *
417
+ * So they are merged, on the ORM list's spine. The column set is the
418
+ * mapping's — it drops `@Transient`, adds every `@MappedSuperclass`
419
+ * column, and omits what `jpa.ts` could not decide — and the written
420
+ * type is carried onto each entry that has one. Neither consumer loses
421
+ * anything, and the two claims stay distinguishable by their keys.
422
+ */
423
+ const written = new Map(fields.map((f) => [f.name, f.type]));
424
+ // `type` is the element type, `list` a boolean — the contract Lane E
425
+ // stated and every adapter follows. Kotlin infers, so an absent
426
+ // type means "not written," never "no type."
427
+ const asLanguageFields = fields.map((f) => ({ name: f.name, type: f.type ?? null, list: false }));
428
+ const mapped = node.attrs?.["fields"];
429
+ const merged = node.type === "MODEL" && Array.isArray(mapped)
430
+ ? mapped.map((f) => ({
431
+ ...f,
432
+ type: written.get(f.name) ?? null,
433
+ list: false,
434
+ }))
435
+ : asLanguageFields;
436
+ nodes[index] = { ...node, attrs: { ...node.attrs, fields: merged } };
231
437
  }
232
438
  }
233
439
  // Pass 2 — edges.
@@ -294,6 +500,49 @@ export function extract(input) {
294
500
  candidate.path[candidate.path.length - 1] === name);
295
501
  return samePackageName.length === 1 ? samePackageName[0] : undefined;
296
502
  }
503
+ /**
504
+ * `DEC-NEXT-dto-shape-evidence-for-api-contracts`'s ruling, applied at the
505
+ * one place a Kotlin `USES_API` edge states a response type: a Retrofit
506
+ * endpoint function's own declared return shape (`returnTypeWritten`, read
507
+ * by `retrofit.ts`'s `returnTypeOf`). Mirrors `adapter-java`'s
508
+ * `responseShapeEvidence` and `adapter-csharp`'s twin — resolve the written
509
+ * name through the SAME scope rules every other name in this adapter goes
510
+ * through, then require real kotlinx.serialization evidence before
511
+ * claiming R3, never the return type alone.
512
+ *
513
+ * Gated on `input.reached >= 3` INSIDE the returned attrs, not by an early
514
+ * return one level up — a guard placed above this function would be green
515
+ * at R3 and silently wrong at R2, the monotonic sweep's own failure mode.
516
+ *
517
+ * A `MODEL` is excluded even when `@Serializable`: that is a JPA
518
+ * persistence claim (see the comment beside `attrs.shapeResolution` at the
519
+ * `MODEL` node above), and DEC-NEXT scopes this ruling to *wire* shape
520
+ * evidence, a different fact resting on a different annotation.
521
+ *
522
+ * **Kotlin has no `DTO` node kind at all** (confirmed: this file mints only
523
+ * `CLASS`/`MODEL`, unlike `adapter-java`/`adapter-csharp`/`adapter-python`).
524
+ * So this gate rests on the annotation evidence alone — a resolved
525
+ * declaration, not a `MODEL`, carrying real `@Serializable` — rather than
526
+ * ALSO requiring the type be independently classified `DTO` by route
527
+ * evidence the way `adapter-java`/`adapter-csharp` additionally require.
528
+ * That extra requirement in those two adapters is a narrowing choice on top
529
+ * of what the ruling itself demands, not a second thing this ruling asks
530
+ * for; `@Serializable` here is exactly the "genuine serialization-schema
531
+ * signal" the ruling names, so gating on it alone is not a lowered bar.
532
+ */
533
+ function responseShapeEvidence(unit, written) {
534
+ if (input.reached < 3 || written === undefined)
535
+ return {};
536
+ const target = resolveType(unit, written);
537
+ if (target === undefined || target.isModel)
538
+ return {};
539
+ const source = declByKey.get(typeKey(target.identityPackage, target.path));
540
+ if (source === undefined)
541
+ return {};
542
+ if (!hasSerializationEvidence(source.unit, source.decl))
543
+ return {};
544
+ return { responseType: target.id, shapeResolution: 3 };
545
+ }
297
546
  function memberOf(owner, name) {
298
547
  return funcsByKey.get(typeKey(owner.identityPackage, [...owner.path, name]));
299
548
  }
@@ -420,11 +669,33 @@ export function extract(input) {
420
669
  const from = funcsByKey.get(typeKey(unit.identityPackage, [...fn.owners, fn.name]));
421
670
  if (from === undefined)
422
671
  continue;
672
+ // `order.totalAmount` where a binding WROTE `order: Order` — golden 07.
673
+ // The same type resolver that serves CALLS answers this; a receiver whose
674
+ // type Kotlin inferred states nothing at the binding site and produces
675
+ // nothing here, which is a disclosed gap rather than a guess.
676
+ for (const read of jpaFieldReadsIn(fn, (written) => {
677
+ const target = resolveType(unit, written);
678
+ return target?.isModel === true ? target.id : undefined;
679
+ })) {
680
+ if (read.model === from.id)
681
+ continue;
682
+ push({ from: from.id, to: read.model, type: "READS", attrs: { fields: read.fields } }, 0.95,
683
+ // Both halves are written names resolved to a declaration — R2, the
684
+ // rung every other edge here rests on. The SHAPE lives on the MODEL
685
+ // node; the edge claims no more than it saw.
686
+ 2);
687
+ }
423
688
  for (const ref of fn.refs) {
424
689
  if (ref.kind === "type") {
425
690
  const target = resolveType(unit, ref.name);
426
- if (target !== undefined)
691
+ if (target !== undefined) {
427
692
  push({ from: from.id, to: target.id, type: "USES_TYPE" }, 0.85, 1);
693
+ continue;
694
+ }
695
+ // A type reference that left the repository. When an import the
696
+ // developer wrote settles which package and which symbol, it mints a
697
+ // `CLASS` standing for that type in the dependency.
698
+ externalEdge(from.id, unit, ref.name, [], "USES_TYPE");
428
699
  continue;
429
700
  }
430
701
  if (!ref.hasReceiver) {
@@ -446,6 +717,21 @@ export function extract(input) {
446
717
  push({ from: from.id, to: asType.id, type: "USES_TYPE" }, 0.85, 1);
447
718
  continue;
448
719
  }
720
+ // A bare name bound by an import: `import kotlin.test.assertEquals`
721
+ // then `assertEquals(a, b)`, or an imported extension function.
722
+ // The language guarantees the binding, so following it is
723
+ // resolution, not inference — Python's `from x import y`, which was
724
+ // the larger half there too.
725
+ //
726
+ // Kotlin has no `new`, so an imported CLASS called bare is a
727
+ // constructor call and is a class used as a VALUE (DEC-068). The
728
+ // in-repo branch above already reads it as `USES_TYPE`; the same
729
+ // distinction is unavailable here, because nothing read the
730
+ // dependency to know whether the name is a type or a function. The
731
+ // edge follows what was written — a call — and the node follows the
732
+ // edge.
733
+ if (externalEdge(from.id, unit, ref.name, [], "CALLS"))
734
+ continue;
449
735
  refuse(from.id, "CALLS", ref.name, unit.file, ref.line, "No declaration for this name in the analysed set.");
450
736
  continue;
451
737
  }
@@ -465,6 +751,13 @@ export function extract(input) {
465
751
  push({ from: from.id, to: staticMember.id, type: "CALLS" }, 0.9, 1);
466
752
  continue;
467
753
  }
754
+ // `Json.encodeToString(x)` under `import kotlinx.serialization.json.Json`
755
+ // — the receiver IS the imported symbol, so the import settles it
756
+ // without any type being written. A local of the same spelling would
757
+ // have won above, exactly as it does in the in-repo branch.
758
+ if (ref.receiver !== undefined && externalEdge(from.id, unit, ref.receiver, [ref.name], "CALLS")) {
759
+ continue;
760
+ }
468
761
  refuse(from.id, "CALLS", ref.name, unit.file, ref.line, ref.receiver === undefined
469
762
  ? "The receiver is an expression, so it has no written type at this reference. " +
470
763
  "Resolving it needs a type checker (R3)."
@@ -475,6 +768,16 @@ export function extract(input) {
475
768
  const owner = resolveType(unit, ref.receiverType);
476
769
  const target = owner === undefined ? undefined : memberOf(owner, ref.name);
477
770
  if (target === undefined) {
771
+ // `val client: HttpClient` under `import io.ktor.client.HttpClient`,
772
+ // then `client.get(url)`. The type is written at the reference and
773
+ // the import says which package it lives in.
774
+ //
775
+ // Only when the owner did not resolve. An owner that IS ours and
776
+ // declares no such member is an analysis gap — an inherited member or
777
+ // an extension function — and calling it a dependency would be wrong.
778
+ if (owner === undefined && externalEdge(from.id, unit, ref.receiverType, [ref.name], "CALLS")) {
779
+ continue;
780
+ }
478
781
  refuse(from.id, "CALLS", ref.name, unit.file, ref.line, owner === undefined
479
782
  ? `No declaration for receiver type "${ref.receiverType}" in the analysed set.`
480
783
  : `"${ref.name}" is not declared on ${owner.path.join(".")} in the analysed set. It may ` +
@@ -491,9 +794,19 @@ export function extract(input) {
491
794
  }
492
795
  }
493
796
  // TESTS — a test case covers what it calls.
797
+ //
798
+ // **Never into a dependency.** `TESTS` asserts coverage, and this run read
799
+ // nothing of an external symbol's body: a test that calls
800
+ // `kotlin.test.assertEquals` does not cover `assertEquals`. Python and PHP
801
+ // made the same call at the site that mints the edge; here the edge is
802
+ // mirrored by a later pass, so the exclusion has to live in the pass.
803
+ // `DEC-NEXT-external-edge-is-calls-not-tests`.
804
+ const externalNodeIds = new Set(nodes.filter((n) => n.external !== undefined).map((n) => n.id));
494
805
  for (const edge of [...edges]) {
495
806
  if (edge.type !== "CALLS")
496
807
  continue;
808
+ if (externalNodeIds.has(edge.to))
809
+ continue;
497
810
  const source = nodes.find((n) => n.id === edge.from);
498
811
  if (source?.type !== "TEST_CASE")
499
812
  continue;
@@ -558,24 +871,30 @@ export function extract(input) {
558
871
  push({ from: routeId, to: endpointId, type: "SERVES_API" }, 0.9, 2);
559
872
  // --- Which function actually serves it (golden pattern 19) -----------
560
873
  //
561
- // `SERVES_API` stops at `API_ROUTE`. Ktor never names its handler —
562
- // `get("/orders/{id}") { Routes.getOrder(id) }` puts the body inline —
563
- // so the serving function is one hop in, called from the lambda. The
564
- // targets those calls ALREADY resolved to are read back by line span, so
565
- // the route pass and the call pass cannot disagree about what
566
- // `Routes.getOrder` means.
567
- //
568
- // Exactly one in-repository call is the handler. Zero means the lambda
569
- // serves the request itself and no declared function does; more than one
570
- // means which is the handler and which a helper is not written down
571
- // anywhere, and a wrong handler edge is worse than none — root-cause
572
- // traversal would walk it. Both are disclosed, never guessed.
573
- //
574
- // Emitted at R1, NOT at the route's own R2: the registration's position
575
- // is R2 provenance, but the hop rests on this adapter's call resolution,
576
- // which is R1 (a written type annotation against the declaration table,
577
- // no LSP). An edge's resolution is the weakest evidence under it.
578
- if (route.handlerBody !== undefined) {
874
+ // Spring has no lambda-dispatch ambiguity to resolve: the annotated method IS the
875
+ // handler unconditionally, already identified by owner chain + name in `spring.ts`.
876
+ // Resolved through the same `funcsByKey` table Pass 1 built for every FUNCTION node —
877
+ // a lookup, not a second resolution pass — mirroring `adapter-java/src/spring.ts`'s
878
+ // `handlerFqn` -> `byMember` join. Emitted at R2, the route's own level: unlike Ktor's
879
+ // hop (which rests on this adapter's R1 call resolution), nothing here needs more than
880
+ // the cross-file reference resolution the route registration itself already rests on.
881
+ if (route.handler !== undefined && unit.identityPackage !== undefined) {
882
+ const key = typeKey(unit.identityPackage, [...route.handler.owners, route.handler.name]);
883
+ const handlerFunc = funcsByKey.get(key);
884
+ if (handlerFunc !== undefined) {
885
+ push({ from: routeId, to: handlerFunc.id, type: "CALLS" }, 0.9, 2);
886
+ }
887
+ else {
888
+ // Shouldn't normally happen — the handler is this same file's already-parsed AST —
889
+ // but a name collision Pass 1 refused (two same-named declarations sharing an
890
+ // identity key), or another gap, leaves no entry to join to. Disclosed rather than
891
+ // silently skipped.
892
+ refuse(routeId, "CALLS", [...route.handler.owners, route.handler.name].join("."), unit.file, route.line, "The annotated handler method does not resolve to a declared function in the analysed " +
893
+ "set — most likely a name collision this file's own declarations were refused over in " +
894
+ "an earlier pass. The route is real; the code serving it is not established.");
895
+ }
896
+ }
897
+ else if (route.handlerBody !== undefined) {
579
898
  const inside = [];
580
899
  for (let ln = route.handlerBody.startLine; ln <= route.handlerBody.endLine; ln += 1) {
581
900
  for (const to of callTargetsAt.get(`${unit.file}:${ln}`) ?? []) {
@@ -598,6 +917,16 @@ export function extract(input) {
598
917
  "as if it had been read from source.");
599
918
  }
600
919
  }
920
+ else if (route.framework === "spring") {
921
+ // Defensive fallback only — `readSpringRoutes` withholds `handler` when the annotated
922
+ // method's own name could not be read back, or (unreachably in practice, since a route
923
+ // only exists once its file's identity resolved) `identityPackage` is absent. Distinct
924
+ // wording from Ktor's "no handler lambda" case below: a Spring route was never carrying
925
+ // a lambda to begin with, so that message would misstate why this one is unresolved.
926
+ refuse(routeId, "CALLS", `${route.method} ${route.template}`, unit.file, route.line, "The annotated handler method's own name could not be read back from this file's parse, " +
927
+ "so nothing here identifies the function that serves it. The route is real; the code " +
928
+ "serving it is not established.");
929
+ }
601
930
  else {
602
931
  refuse(routeId, "CALLS", `${route.method} ${route.template}`, unit.file, route.line, "The route registration carries no handler lambda, so nothing here names the function " +
603
932
  "that serves it. The route is real; the code serving it is not established.");
@@ -618,13 +947,56 @@ export function extract(input) {
618
947
  blockedBy: refusal.blockedBy,
619
948
  refusalClass: refusal.refusalClass,
620
949
  ...(refusal.argumentKind === undefined ? {} : { argumentKind: refusal.argumentKind }),
950
+ ...(refusal.clientBase === undefined ? {} : { clientBase: refusal.clientBase }),
951
+ ...(refusal.callPath === undefined ? {} : { callPath: refusal.callPath }),
621
952
  };
953
+ /**
954
+ * DEC-164: caller node id -> the base this reader traced for it. First
955
+ * writer wins, so a function making two calls is described by the first one
956
+ * read. Patched onto the node after the walk.
957
+ */
958
+ const functionClientBase = new Map();
959
+ /**
960
+ * The innermost function containing `line`, resolved to its node id — the
961
+ * thing a reader would blame, and where DEC-164's `attrs.clientBase`
962
+ * belongs. Lifted out of the call loop because a REFUSAL needs it too: a
963
+ * refusal's own ledger row attributes to the MODULE (DEC-124), which is
964
+ * deliberate and unchanged, but 08b's `attrs.clientBase` has to land on the
965
+ * caller the golden binds.
966
+ */
967
+ const callerAt = (unit, line, moduleId) => {
968
+ const enclosing = unit.funcs
969
+ .filter((fn) => fn.startLine <= line && line <= fn.endLine)
970
+ .sort((a, b) => b.startLine - a.startLine)[0];
971
+ if (enclosing === undefined || unit.identityPackage === undefined)
972
+ return moduleId;
973
+ return funcsByKey.get(typeKey(unit.identityPackage, [...enclosing.owners, enclosing.name]))?.id ?? moduleId;
974
+ };
622
975
  for (const unit of input.files) {
623
976
  const moduleId = moduleIdOf.get(unit.file);
624
977
  if (moduleId === undefined)
625
978
  continue;
626
979
  for (const refusal of unit.clientRefusals) {
627
980
  refuse(moduleId, "USES_API", refusal.rawTarget, unit.file, refusal.line, refusal.reason, clientAttrsOf(refusal));
981
+ // A refusal still states a base when DEC-164 reached one — 08b's whole
982
+ // point is that `unresolved` is a reading, not a silence.
983
+ if (refusal.clientBase !== undefined) {
984
+ const caller = callerAt(unit, refusal.line, moduleId);
985
+ if (!functionClientBase.has(caller))
986
+ functionClientBase.set(caller, refusal.clientBase);
987
+ }
988
+ }
989
+ // Collected ABOVE the R2 gate on purpose: the golden asserts
990
+ // `attrs.clientBase` at R2 and the field is a reading of the source, not
991
+ // of a rung. `adapter-python` shipped exactly this collection below its
992
+ // own resolution guard and it was green at R3 and absent at R2, caught
993
+ // only by the monotonic sweep.
994
+ for (const call of unit.clientCalls) {
995
+ if (call.clientBase === undefined)
996
+ continue;
997
+ const caller = callerAt(unit, call.line, moduleId);
998
+ if (!functionClientBase.has(caller))
999
+ functionClientBase.set(caller, call.clientBase);
628
1000
  }
629
1001
  if (input.reached < 2)
630
1002
  continue;
@@ -633,15 +1005,10 @@ export function extract(input) {
633
1005
  // would blame). Outside any function (a file-scope property
634
1006
  // initialiser) attributes to the MODULE, the file-scoped carrier
635
1007
  // (DEC-124).
636
- const enclosing = unit.funcs
637
- .filter((fn) => fn.startLine <= call.line && call.line <= fn.endLine)
638
- .sort((a, b) => b.startLine - a.startLine)[0];
639
1008
  // A file with no derivable identity package has no function ids to
640
1009
  // point at (DEC-173); the MODULE still does (DEC-124), so the edge
641
- // degrades to the file rather than dropping.
642
- const from = enclosing === undefined || unit.identityPackage === undefined
643
- ? moduleId
644
- : funcsByKey.get(typeKey(unit.identityPackage, [...enclosing.owners, enclosing.name]))?.id ?? moduleId;
1010
+ // degrades to the file rather than dropping — `callerAt`'s own rule.
1011
+ const from = callerAt(unit, call.line, moduleId);
645
1012
  const template = normaliseEndpointPath(call.path);
646
1013
  const endpointId = nodeId(scope, "API_ENDPOINT", endpointQsp(call.method, call.path), null);
647
1014
  if (!endpointNodes.has(endpointId)) {
@@ -697,12 +1064,74 @@ export function extract(input) {
697
1064
  attrs: { method: endpoint.method, pathTemplate: template },
698
1065
  });
699
1066
  }
700
- push({ from, to: endpointId, type: "USES_API", attrs: { client: "retrofit", rawPath: endpoint.written } }, 0.9, 2);
1067
+ push({
1068
+ from,
1069
+ to: endpointId,
1070
+ type: "USES_API",
1071
+ attrs: {
1072
+ client: "retrofit",
1073
+ rawPath: endpoint.written,
1074
+ ...responseShapeEvidence(unit, endpoint.returnTypeWritten),
1075
+ },
1076
+ }, 0.9, 2);
701
1077
  }
702
1078
  }
703
1079
  nodes.push(...routeNodes.values(), ...endpointNodes.values());
1080
+ // DEC-164's client base, patched onto the caller. The field's ABSENCE is
1081
+ // the fourth state: a function whose receiver's construction this reader
1082
+ // never saw says nothing about a base, rather than saying `none`.
1083
+ for (let i = 0; i < nodes.length; i += 1) {
1084
+ const node = nodes[i];
1085
+ const base = functionClientBase.get(node.id);
1086
+ if (base === undefined)
1087
+ continue;
1088
+ nodes[i] = { ...node, attrs: { ...node.attrs, clientBase: base } };
1089
+ }
704
1090
  return { nodes, edges, unresolved };
705
1091
  }
1092
+ /**
1093
+ * The filesystem-level fallback for a throw mid-extraction — `FILE`-per-file
1094
+ * via the shared `@descryy/adapter-common#fileNode` helper, plus exactly one
1095
+ * disclosure row, mirroring `adapter-typescript`'s `degradedGraph`.
1096
+ *
1097
+ * `identityPackage` is already computed per file by `adapter.ts`'s
1098
+ * `prepare()` by the time `extract()` could throw, so a MODULE-based fallback
1099
+ * built from it was a real option here (unlike TypeScript, where identity
1100
+ * depends on the same resolution machinery that just failed). Declined for
1101
+ * two reasons: (1) it only covers "anchored" files — Kotlin deliberately
1102
+ * refuses identity for the rest (DEC-173), so a MODULE path would still need
1103
+ * a second fallback for the unanchored subset; (2) `identityPackage`'s
1104
+ * derivation includes the KMP source-set fallback (`anchor.sourceSet === ""`,
1105
+ * see `adapter.ts`'s `prepare()`) that itself needed a dedicated fix for
1106
+ * identity collisions on ktor — reusing it inside the degraded path
1107
+ * reintroduces exactly the collision risk that fix exists to prevent, for
1108
+ * uncertain benefit over a plain FILE node, which still merges cleanly with
1109
+ * git-history's FILE nodes for the same path. `FILE`-per-file is simpler,
1110
+ * safer, and uniform with the mechanism's established shape.
1111
+ */
1112
+ export function degradedGraph(input) {
1113
+ const scope = { repo: input.repo, workspace: input.workspace };
1114
+ const nodes = input.files.map((file) => fileNode(scope, file, input.producedBy));
1115
+ const anchor = nodes[0];
1116
+ const unresolved = anchor === undefined
1117
+ ? []
1118
+ : [
1119
+ {
1120
+ fromNodeId: anchor.id,
1121
+ edgeType: "IMPORTS",
1122
+ rawTarget: "(entire repository)",
1123
+ file: anchor.file,
1124
+ line: 1,
1125
+ producedBy: input.producedBy,
1126
+ reason: `NO GRAPH WAS PRODUCED for ${String(input.files.length)} Kotlin file(s). ${input.cause}. ` +
1127
+ "Files are listed because their existence and path are facts about the filesystem; their " +
1128
+ "contents are absent. Every finding, coverage figure and 'not affected' statement over " +
1129
+ "this repository is UNSUPPORTED — this is a failed analysis, not an empty repository.",
1130
+ attrs: { refusalClass: "capability-gap" },
1131
+ },
1132
+ ];
1133
+ return { nodes, edges: [], unresolved };
1134
+ }
706
1135
  /**
707
1136
  * `INHERITS` or `IMPLEMENTS`, decided against the declaration table — the
708
1137
  * parenthesised invocation form is checked only as confirmation. An invoked