@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.
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js +86 -14
- package/dist/adapter.js.map +1 -1
- package/dist/client-base.d.ts +114 -0
- package/dist/client-base.d.ts.map +1 -0
- package/dist/client-base.js +102 -0
- package/dist/client-base.js.map +1 -0
- package/dist/client.d.ts +23 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +207 -13
- package/dist/client.js.map +1 -1
- package/dist/external.d.ts +149 -0
- package/dist/external.d.ts.map +1 -0
- package/dist/external.js +226 -0
- package/dist/external.js.map +1 -0
- package/dist/extract.d.ts +34 -0
- package/dist/extract.d.ts.map +1 -1
- package/dist/extract.js +476 -47
- package/dist/extract.js.map +1 -1
- package/dist/jpa-reads.d.ts +26 -0
- package/dist/jpa-reads.d.ts.map +1 -0
- package/dist/jpa-reads.js +35 -0
- package/dist/jpa-reads.js.map +1 -0
- package/dist/jpa.d.ts +147 -0
- package/dist/jpa.d.ts.map +1 -0
- package/dist/jpa.js +415 -0
- package/dist/jpa.js.map +1 -0
- package/dist/parse.d.ts +44 -0
- package/dist/parse.d.ts.map +1 -1
- package/dist/parse.js +106 -9
- package/dist/parse.js.map +1 -1
- package/dist/retrofit.d.ts +12 -0
- package/dist/retrofit.d.ts.map +1 -1
- package/dist/retrofit.js +53 -0
- package/dist/retrofit.js.map +1 -1
- package/dist/routes.d.ts +15 -0
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js.map +1 -1
- package/dist/serialization.d.ts +47 -0
- package/dist/serialization.d.ts.map +1 -0
- package/dist/serialization.js +55 -0
- package/dist/serialization.js.map +1 -0
- package/dist/shape-reach.d.ts +97 -0
- package/dist/shape-reach.d.ts.map +1 -0
- package/dist/shape-reach.js +156 -0
- package/dist/shape-reach.js.map +1 -0
- package/dist/spring.d.ts.map +1 -1
- package/dist/spring.js +28 -6
- package/dist/spring.js.map +1 -1
- 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
|
-
|
|
156
|
-
|
|
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:
|
|
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:
|
|
166
|
-
attrs
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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:
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
//
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
//
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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 =
|
|
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({
|
|
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
|