@descryy/adapter-java 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/extract.js CHANGED
@@ -18,12 +18,27 @@
18
18
  * ambiguous wildcard import. Each goes to the ledger with its reason — a
19
19
  * missing edge is a disclosed gap, a guessed one corrupts every layer above.
20
20
  */
21
- import { edgeId, endpointQsp, nodeId, normaliseEndpointPath, symbolQsp, testCaseQsp, } from "@descryy/ir";
21
+ import { edgeId, endpointQsp, externalQsp, nodeId, normaliseEndpointPath, symbolQsp, testCaseQsp, } from "@descryy/ir";
22
+ import { fileNode } from "@descryy/adapter-common";
22
23
  import { jpaModels } from "./jpa.js";
24
+ import { readTransportShapes, SCHEMA as DTO_SCHEMA } from "./dto.js";
25
+ import { hasJacksonShapeEvidence } from "./jackson.js";
23
26
  import { SHAPE_LEVEL } from "./shape-reach.js";
24
27
  import { jaxrsRoutes, springRoutes } from "./spring.js";
25
28
  import { clientCalls, endpointFor } from "./client.js";
29
+ import { resolveBaseSite } from "./client-base.js";
30
+ import { attributeExternalCall, attributeExternalStaticCall, attributeExternalType, } from "./external.js";
26
31
  export const LANGUAGE = "java";
32
+ /** Which packaging world an external package name belongs to. The name is the
33
+ * ecosystem's, not a resolved classpath's: `org.slf4j:slf4j-api` ships the
34
+ * package `org.slf4j`, and `org.slf4j` is what the source writes and what the
35
+ * node is identified by. */
36
+ const EXTERNAL_ECOSYSTEM = "maven";
37
+ /** An external node is attributed from the file's own `import` block — module
38
+ * resolution and nothing more. R1 exactly: never R0 (the import IS the
39
+ * resolution), never R4 (nothing observed it run).
40
+ * `DEC-NEXT-external-symbol-nodes-at-the-dependency-boundary`. */
41
+ const EXTERNAL_RESOLUTION = 1;
27
42
  /** Confidence by the level an edge's evidence earned, never the level reached. */
28
43
  const CONFIDENCE = { 0: 0.5, 1: 0.7, 2: 0.9, 3: 0.95 };
29
44
  /** A compilation unit with no `package` declaration. Not a legal package name,
@@ -58,6 +73,39 @@ const REASONS = {
58
73
  "run's analysed set — most often inherited from a supertype or interface this run did not " +
59
74
  "read, rather than declared directly on the controller.",
60
75
  };
76
+ /** The two spellings `isX()` is allowed to stand for. */
77
+ const BOOLEANS = new Set(["boolean", "Boolean"]);
78
+ /**
79
+ * `java.beans.Introspector.decapitalize`, to the letter — `TotalAmount`
80
+ * becomes `totalAmount`, and `URL` stays `URL`. Written out rather than
81
+ * lower-casing the first character, because the two-leading-capitals rule is
82
+ * the half that decides whether `getURL()` names a field called `uRL` (it
83
+ * does not) and a mapping lookup on the wrong spelling silently finds nothing.
84
+ */
85
+ function decapitalize(name) {
86
+ const second = name[1];
87
+ if (second !== undefined && second !== second.toLowerCase() && /[A-Z]/.test(second))
88
+ return name;
89
+ return name[0].toLowerCase() + name.slice(1);
90
+ }
91
+ /** The property a JavaBeans accessor name denotes, or `undefined` for a name
92
+ * that is not one. `isForm` distinguishes the boolean spelling, which is only
93
+ * legal over a `boolean`/`Boolean`. */
94
+ function beansProperty(name) {
95
+ if (name.startsWith("get") && name.length > 3)
96
+ return { name: decapitalize(name.slice(3)), isForm: false };
97
+ if (name.startsWith("is") && name.length > 2)
98
+ return { name: decapitalize(name.slice(2)), isForm: true };
99
+ return undefined;
100
+ }
101
+ /** A declared type reduced to the name the two sides can be compared on:
102
+ * generics dropped, package qualification dropped. `java.lang.String` and
103
+ * `String` are one type written two ways, and refusing the edge over the
104
+ * spelling would lose a real read. */
105
+ function simpleTypeName(written) {
106
+ const bare = written.replace(/<.*$/s, "").replace(/\[\]/g, "").trim();
107
+ return bare.slice(bare.lastIndexOf(".") + 1);
108
+ }
61
109
  /**
62
110
  * Every declared type, indexed before any node id exists, then every JPA
63
111
  * entity resolved against that index.
@@ -105,6 +153,51 @@ export function readEntities(units) {
105
153
  }
106
154
  return entitiesByType;
107
155
  }
156
+ /**
157
+ * The fallback graph when `extract()` throws mid-run, per
158
+ * `DEC-NEXT-degraded-graph-shape-for-adapters-without-one.md`.
159
+ *
160
+ * `FILE`, not `MODULE`: a Java module's identity here is `identityPackage`,
161
+ * and `identityPackage` is derived from `unit.packageName` — the `package
162
+ * a.b.c;` statement *inside* the file (`roots.ts`'s `identityPackage`,
163
+ * layered over a source-set anchor only when one resolves; the package name
164
+ * itself is never filesystem-derivable, since Java allows — and this corpus
165
+ * contains — a file whose directory does not match its declared package).
166
+ * A mid-extraction throw means exactly this machinery cannot be trusted to
167
+ * have run to completion. Unlike Go's import path or Rust's crate/module
168
+ * path — both resolved from the file's own location plus a manifest, never
169
+ * from parsed content — nothing about a Java file's identity is recoverable
170
+ * without a successful parse. `FILE` (via the shared
171
+ * `@descryy/adapter-common#fileNode` helper) is the fact that survives: the
172
+ * file exists, on disk, at this path, and that is all this function claims.
173
+ *
174
+ * One disclosure row, not one per file — the fact is about the run. Anchored
175
+ * to the first file's `FILE` node because the ledger requires a `fromNodeId`
176
+ * and `FILE` is the only node type this path emits.
177
+ */
178
+ export function degradedGraph(input) {
179
+ const scope = { repo: input.repo, workspace: input.workspace };
180
+ const nodes = input.files.map((file) => fileNode(scope, file, input.producedBy));
181
+ const anchor = nodes[0];
182
+ const unresolved = anchor === undefined
183
+ ? []
184
+ : [
185
+ {
186
+ fromNodeId: anchor.id,
187
+ edgeType: "IMPORTS",
188
+ rawTarget: "(entire repository)",
189
+ file: anchor.file,
190
+ line: 1,
191
+ producedBy: input.producedBy,
192
+ reason: `NO GRAPH WAS PRODUCED for ${String(input.files.length)} Java file(s). ${input.cause}. ` +
193
+ "Files are listed because their existence and path are facts about the filesystem; their " +
194
+ "contents are absent. Every finding, coverage figure and 'not affected' statement over " +
195
+ "this repository is UNSUPPORTED — this is a failed analysis, not an empty repository.",
196
+ attrs: { refusalClass: "capability-gap" },
197
+ },
198
+ ];
199
+ return { nodes, edges: [], unresolved };
200
+ }
108
201
  /**
109
202
  * The qualified symbol path for a Java symbol.
110
203
  *
@@ -226,6 +319,40 @@ export function extract(input) {
226
319
  const scope = input.scope;
227
320
  /** Outbound HTTP calls, collected in the method loop and emitted with the routes. */
228
321
  const clientCallSites = [];
322
+ /**
323
+ * DEC-164: caller node id -> the base this reader traced for it. First
324
+ * writer wins, so a method making two calls is described by the first one
325
+ * read. Patched onto the node after the walk, the way `adapter-csharp`
326
+ * does it — the node exists before its calls are read.
327
+ */
328
+ const functionClientBase = new Map();
329
+ /**
330
+ * ONE PROGRAM, READ TWICE — DEC-164's origin rule needs every base in the
331
+ * program before any single call site can be judged.
332
+ *
333
+ * `client-base.ts` admits an absolute call path only at an origin some base
334
+ * in this same program resolves to, and Java is the language that forces
335
+ * this to be program-wide rather than per file: one public class per file is
336
+ * not a style here but a rule, so the constant and the absolute call site
337
+ * CANNOT be in the same file. This pre-pass reads only the declaration
338
+ * tables the parse already built — no call sites, no scope resolution — so
339
+ * it is cheap, and with no absolute base anywhere it yields an empty set and
340
+ * changes nothing.
341
+ */
342
+ const knownOrigins = new Set();
343
+ for (const unit of input.units) {
344
+ const sites = [];
345
+ for (const type of unit.types) {
346
+ sites.push(...type.fieldBases.values());
347
+ for (const method of type.methods)
348
+ sites.push(...method.localBases.values());
349
+ }
350
+ for (const site of sites) {
351
+ const { origin } = resolveBaseSite(site, unit.stringConstants);
352
+ if (origin !== undefined)
353
+ knownOrigins.add(origin);
354
+ }
355
+ }
229
356
  /**
230
357
  * Returns `false` when the edge's evidence outranks the level this run
231
358
  * reached, so the caller can disclose it instead of dropping it silently.
@@ -254,6 +381,72 @@ export function extract(input) {
254
381
  file: unit.file,
255
382
  line: ref.line,
256
383
  });
384
+ /**
385
+ * Node ids already minted for a dependency symbol — one node per symbol and
386
+ * per node type, not per reference site.
387
+ *
388
+ * The three settled rules are asserted here rather than argued: the qsp
389
+ * carries an `ext:` marker (`externalQsp`), the node claims R1 and may never
390
+ * claim R0 or R4, and it owns no outgoing edge because nothing read its body.
391
+ * `thirdParty` and `binding` ride in `attrs`, outside the hash — a package
392
+ * that moved between the JDK and a jar would otherwise change identity with
393
+ * no line of source changing.
394
+ */
395
+ const externalIds = new Map();
396
+ const mintExternal = (attribution, type) => {
397
+ if (input.reached < EXTERNAL_RESOLUTION)
398
+ return null;
399
+ const qsp = externalQsp(attribution.moduleOrNamespace, attribution.symbolPath);
400
+ const key = `${type}\u0000${qsp}`;
401
+ const existing = externalIds.get(key);
402
+ if (existing !== undefined)
403
+ return existing;
404
+ const id = nodeId(scope, type, qsp, LANGUAGE);
405
+ externalIds.set(key, id);
406
+ nodes.push({
407
+ id,
408
+ type,
409
+ name: attribution.symbolPath[attribution.symbolPath.length - 1] ?? "",
410
+ // No file and no range: the symbol lives in a dependency, and a node that
411
+ // named a file here would be invalidated by a source file it has nothing
412
+ // to do with.
413
+ file: null,
414
+ range: null,
415
+ language: LANGUAGE,
416
+ producedBy: input.producedBy,
417
+ resolution: EXTERNAL_RESOLUTION,
418
+ attrs: { thirdParty: attribution.thirdParty, binding: attribution.binding },
419
+ external: {
420
+ ecosystem: EXTERNAL_ECOSYSTEM,
421
+ // Identity, and it is the package the source wrote — never a resolved
422
+ // classpath entry, never a Maven coordinate, never a version.
423
+ moduleOrNamespace: attribution.moduleOrNamespace,
424
+ },
425
+ });
426
+ return id;
427
+ };
428
+ /**
429
+ * The context `external.ts` attributes against, for one compilation unit.
430
+ * `ownPackages` and `analysed` are the repository's own; the import tables are
431
+ * the file's. Built lazily per unit and cached, since the two repository-wide
432
+ * sets are the same object every time.
433
+ */
434
+ const externalContexts = new Map();
435
+ const externalContextFor = (unit) => {
436
+ const cached = externalContexts.get(unit.file);
437
+ if (cached !== undefined)
438
+ return cached;
439
+ const context = {
440
+ imports: {
441
+ singleTypeImports: unit.singleTypeImports,
442
+ staticMemberImports: unit.staticMemberImports,
443
+ },
444
+ ownPackages,
445
+ analysed: analysedFqns,
446
+ };
447
+ externalContexts.set(unit.file, context);
448
+ return context;
449
+ };
257
450
  const ledger = (from, edgeType, rawTarget, at, reason, attrs) => {
258
451
  unresolved.push({
259
452
  fromNodeId: from,
@@ -284,6 +477,9 @@ export function extract(input) {
284
477
  // step earlier — it decides the run's resolution level from it — so it is
285
478
  // read there and handed on here rather than computed twice (`shape-reach.ts`).
286
479
  const entitiesByType = input.entities ?? readEntities(input.units);
480
+ // Transport shapes a Spring ROUTE declares — never a class this reader
481
+ // guessed at from its name (DEC-043). Own file, `dto.ts`.
482
+ const shapesByType = readTransportShapes(input.units);
287
483
  for (const unit of input.units) {
288
484
  const pkg = unit.identityPackage;
289
485
  // Named by its *primary* type, which Java requires to match the file name
@@ -324,7 +520,12 @@ export function extract(input) {
324
520
  // whole batch above, since inherited mappings cross compilation units.
325
521
  for (const type of unit.types) {
326
522
  const model = entitiesByType.get(`${unit.identityPackage}::${type.fqn}`);
327
- const kind = model === undefined ? "CLASS" : "MODEL";
523
+ // A mapped entity outranks a declared transport shape where both somehow
524
+ // apply: a wrong `MODEL` is a claim about a database and a wrong `DTO` a
525
+ // claim about a wire format, so the narrower evidence wins — the same
526
+ // tie-break, for the same reason, as `adapter-python`'s.
527
+ const shape = model === undefined ? shapesByType.get(`${unit.identityPackage}::${type.fqn}`) : undefined;
528
+ const kind = model !== undefined ? "MODEL" : shape !== undefined ? "DTO" : "CLASS";
328
529
  const id = nodeId(scope, kind, qspFor(type.fqn, unit), LANGUAGE);
329
530
  const declaredAs = byFqn.get(type.fqn);
330
531
  if (declaredAs === undefined)
@@ -345,11 +546,35 @@ export function extract(input) {
345
546
  * (one annotation, read at R0/R1) and *this is its mapped shape* (the
346
547
  * column-by-column read below) — and only the second is R3-grade. So
347
548
  * the shape's level is also stated separately in `attrs.shapeResolution`,
348
- * DEC-058's carrier, which `descry-core`'s contract engine already
349
- * reads: a consumer comparing shapes gets the shape's evidence, not the
350
- * node's weakest.
549
+ * DEC-058's carrier.
550
+ *
551
+ * **Node-scoped, and deliberately unrelated to the `USES_API`-edge
552
+ * `shapeResolution` this file also now writes** (see
553
+ * `responseShapeEvidence` below, `DEC-NEXT-dto-shape-evidence-for-api-
554
+ * contracts`). This one is the JPA-mapped, PERSISTED database shape —
555
+ * `descry-core`'s contract engine (`engine.ts:286`) reads only the
556
+ * edge-level attribute for API wire-shape comparison and never reads a
557
+ * node-level `shapeResolution` at all, on a `MODEL` or otherwise. A
558
+ * class can be both — an entity Jackson also serializes straight to the
559
+ * wire — but the two facts rest on different annotations (JPA's vs.
560
+ * Jackson's) and are asserted independently; this one is not the
561
+ * mechanism the API-shape ruling built.
351
562
  */
352
563
  let level = 0;
564
+ if (shape !== undefined) {
565
+ // The declaration this node rests on, named the way a `MODEL` names
566
+ // its ORM — a node type minted from evidence that does not state the
567
+ // evidence is indistinguishable from a guess outside the adapter.
568
+ attrs["schema"] = DTO_SCHEMA;
569
+ attrs["declaredDirections"] = shape.roles;
570
+ // NAMES only, and no `shapeResolution`/`fieldDetail` and no level
571
+ // raise: whether a plain class body's field list is R3-grade evidence
572
+ // is a filed, unanswered question, and `shape-reach.ts` is untouched
573
+ // by design. Golden 05 asks for exactly this — the DTO nodes without
574
+ // field detail rather than guessed fields.
575
+ if (shape.fields.length > 0)
576
+ attrs["fields"] = shape.fields;
577
+ }
353
578
  if (model !== undefined) {
354
579
  attrs["orm"] = "jpa";
355
580
  if (model.table !== null)
@@ -465,6 +690,41 @@ export function extract(input) {
465
690
  }
466
691
  return { reason: bySimpleName.has(written) ? REASONS.unknownName : REASONS.outside };
467
692
  };
693
+ /**
694
+ * `DEC-NEXT-dto-shape-evidence-for-api-contracts`'s ruling, applied at the
695
+ * one place a Java `USES_API` edge states a response type: a class literal
696
+ * on the call (`getForObject(url, OrderResponse.class)`, read by
697
+ * `classLiteralArgumentOf` in `parse.ts`). Mirrors `adapter-python`'s
698
+ * `extract.ts:3512-3519` gate — resolve the written name through the SAME
699
+ * scope rules every other name in this adapter goes through, then require
700
+ * real Jackson evidence before claiming R3, never the class literal alone.
701
+ *
702
+ * Gated on `input.reached >= 3` INSIDE the returned attrs rather than by an
703
+ * early return one level up — same reasoning as `adapter-python`'s own
704
+ * comment at that site: a guard placed above this function would be green
705
+ * at R3 and silently wrong at R2, the monotonic sweep's own failure mode.
706
+ *
707
+ * A `MODEL` is excluded even when Jackson-annotated: that is a
708
+ * database-persistence claim (see the comment beside `attrs.shapeResolution`
709
+ * at the `MODEL` node above), and DEC-NEXT scopes this ruling to *wire*
710
+ * shape evidence, a different fact resting on different annotations.
711
+ */
712
+ const responseShapeEvidence = (unit, written) => {
713
+ if (input.reached < 3 || written === undefined)
714
+ return {};
715
+ const resolved = resolveType(unit, written);
716
+ if (!("declared" in resolved))
717
+ return {};
718
+ const { declared } = resolved;
719
+ const key = `${declared.unit.identityPackage}::${declared.type.fqn}`;
720
+ if (entitiesByType.has(key))
721
+ return {}; // a MODEL: persisted shape, not wire shape
722
+ if (shapesByType.get(key) === undefined)
723
+ return {}; // not an established DTO
724
+ if (!hasJacksonShapeEvidence(declared.type, declared.unit))
725
+ return {};
726
+ return { responseType: declared.id, shapeResolution: 3 };
727
+ };
468
728
  /**
469
729
  * The declared type of a field, following the scopes Java says are in scope.
470
730
  *
@@ -530,6 +790,21 @@ export function extract(input) {
530
790
  }
531
791
  return undefined;
532
792
  };
793
+ /**
794
+ * Packages this repository declares for itself, and every fully-qualified
795
+ * name this run read. Both are what `external.ts` refuses against: a type
796
+ * under one of our own packages that went unanalysed is unanalysed code of
797
+ * OURS, and calling it a dependency is the error that never surfaces.
798
+ *
799
+ * This is also the only guard against `REASONS.outside`'s disclosed
800
+ * cross-language case — 238 of `okhttp`'s 458 `outside` rows name a type
801
+ * declared in a `.kt` file in this same repository. A Kotlin class in a
802
+ * package this repository's Java sources also declare is ours by this check.
803
+ * One in a package no Java source declares is not caught, and stays a
804
+ * disclosed limit of a Java-only reader.
805
+ */
806
+ const ownPackages = new Set(input.units.map((unit) => unit.packageName).filter((name) => name !== "" && name !== DEFAULT_PACKAGE));
807
+ const analysedFqns = new Set(byFqn.keys());
533
808
  for (const unit of input.units) {
534
809
  const moduleId = moduleIdOf.get(unit.file);
535
810
  if (moduleId === undefined)
@@ -583,8 +858,32 @@ export function extract(input) {
583
858
  const read = clientCalls(method, member.id, (name) => writtenReceiverType(method, declared, name),
584
859
  // Source set read from the identity-package anchor, not pattern-
585
860
  // matched off the file path.
586
- isTestSourceSet(unit.identityPackage));
587
- clientCallSites.push(...read.calls);
861
+ isTestSourceSet(unit.identityPackage), (name) => {
862
+ // DEC-164, resolved with the same scope rules `writtenReceiverType`
863
+ // uses: a local shadows a field, and `this.x` names the field.
864
+ const viaThis = name.startsWith("this.");
865
+ const bare = viaThis ? name.slice("this.".length) : name;
866
+ if (bare.includes("."))
867
+ return undefined;
868
+ const site = (viaThis ? undefined : method.localBases.get(bare)) ?? type.fieldBases.get(bare);
869
+ return site === undefined ? undefined : resolveBaseSite(site, unit.stringConstants).base;
870
+ }, knownOrigins);
871
+ for (const call of read.calls) {
872
+ clientCallSites.push({
873
+ ...call,
874
+ ...responseShapeEvidence(unit, call.responseTypeWritten),
875
+ });
876
+ if (call.clientBase !== undefined && !functionClientBase.has(call.fromId)) {
877
+ functionClientBase.set(call.fromId, call.clientBase);
878
+ }
879
+ }
880
+ for (const refusal of read.refusals) {
881
+ // A refusal still states a base when DEC-164 reached one — 08b's
882
+ // whole point is that `unresolved` is a reading, not a silence.
883
+ if (refusal.clientBase !== undefined && !functionClientBase.has(refusal.fromId)) {
884
+ functionClientBase.set(refusal.fromId, refusal.clientBase);
885
+ }
886
+ }
588
887
  for (const refusal of read.refusals) {
589
888
  ledger(refusal.fromId, "USES_API", refusal.raw, { file: unit.file, line: refusal.line }, refusal.reason,
590
889
  // Unset `refusalClass` means unclassified (DEC-242) — `attrs`
@@ -595,9 +894,20 @@ export function extract(input) {
595
894
  blockedBy: refusal.blockedBy,
596
895
  refusalClass: refusal.refusalClass,
597
896
  ...(refusal.argumentKind === undefined ? {} : { argumentKind: refusal.argumentKind }),
897
+ ...(refusal.clientBase === undefined ? {} : { clientBase: refusal.clientBase }),
898
+ ...(refusal.callPath === undefined ? {} : { callPath: refusal.callPath }),
598
899
  });
599
900
  }
600
901
  }
902
+ /**
903
+ * Mapped-field reads, grouped by the entity they land on. Edge
904
+ * identity is `(from, to, type)`, so a method reading three columns
905
+ * of one entity is **one** edge naming three fields, not three edges
906
+ * — a singular `field` attribute would make the second read
907
+ * unrepresentable (DEC-046). Sorted on the way out so the attribute
908
+ * is a property of the source rather than of statement order.
909
+ */
910
+ const fieldReads = new Map();
601
911
  for (const ref of method.refs) {
602
912
  if (ref.kind === "type") {
603
913
  emitTypeRef(member.id, unit, ref);
@@ -612,6 +922,14 @@ export function extract(input) {
612
922
  emitTypeRef(member.id, unit, ref);
613
923
  continue;
614
924
  }
925
+ const read = mappedFieldRead(method, declared, unit, ref);
926
+ if (read !== undefined) {
927
+ const fields = fieldReads.get(read.to);
928
+ if (fields === undefined)
929
+ fieldReads.set(read.to, new Set([read.field]));
930
+ else
931
+ fields.add(read.field);
932
+ }
615
933
  // `method`, not `member.method` — an overload set's node carries
616
934
  // only the *first* overload, so resolving against
617
935
  // `member.method.locals` would type overload A's variables with
@@ -619,6 +937,11 @@ export function extract(input) {
619
937
  // could see.
620
938
  emitCall(member, method, declared, unit, ref);
621
939
  }
940
+ for (const [to, fields] of fieldReads) {
941
+ if (push({ from: member.id, to, type: "READS", attrs: { fields: [...fields].sort() } }, 2))
942
+ continue;
943
+ ledger(member.id, "READS", [...fields].sort().join(", "), { file: unit.file, line: method.startLine }, REASONS.belowLevel);
944
+ }
622
945
  }
623
946
  }
624
947
  }
@@ -630,6 +953,27 @@ export function extract(input) {
630
953
  }
631
954
  return;
632
955
  }
956
+ // A type reference that left the repository. When a binding the developer
957
+ // wrote settles which package and which type, it mints a `CLASS` standing
958
+ // for that type in the dependency and is reached by `USES_TYPE` — the node
959
+ // type follows the EDGE, because that is what the edge means. An attributed
960
+ // site files no ledger row: a site that resolves to a node is not an
961
+ // `UnresolvedRef`, and filing both would count it twice.
962
+ //
963
+ // `member` refs are excluded. `parse.ts` calls them a *candidate* only —
964
+ // `Status.PENDING` is an enum constant and `Map.Entry` is a nested type, and
965
+ // nothing written tells them apart. Minting a `CLASS` for the first is a
966
+ // wrong fact, so both are declined.
967
+ if (ref.kind === "type" && resolved.reason === REASONS.outside) {
968
+ const attribution = attributeExternalType(ref.name, externalContextFor(unit));
969
+ if (attribution !== null) {
970
+ const to = mintExternal(attribution, "CLASS");
971
+ if (to !== null) {
972
+ push({ from, to, type: "USES_TYPE" }, EXTERNAL_RESOLUTION);
973
+ return;
974
+ }
975
+ }
976
+ }
633
977
  ledger(from, "USES_TYPE", ref.name, file(unit, ref), resolved.reason);
634
978
  }
635
979
  function emitCall(member, method, owner, unit, ref) {
@@ -640,6 +984,18 @@ export function extract(input) {
640
984
  const resolvedType = method.isTest ? "TESTS" : "CALLS";
641
985
  const target = receiverTarget(method, owner, unit, ref);
642
986
  if ("reason" in target) {
987
+ // A call whose receiver's **written** type left the repository. Java
988
+ // writes its types down where Go and PHP do not, so this is the half that
989
+ // makes the boundary worth attributing here at all: `Logger log` under
990
+ // `import org.slf4j.Logger` says which package `log.info(...)` reaches.
991
+ //
992
+ // Only `outside` — `untypedReceiver` means no type was written at this
993
+ // reference (a `var`, a chained call, a generic variable) and there is
994
+ // nothing to read; `unknownName` means the simple name IS declared in this
995
+ // repository and calling it a dependency would be wrong.
996
+ if (target.reason === REASONS.outside && externalCall(member, method, owner, unit, ref)) {
997
+ return;
998
+ }
643
999
  ledger(member.id, "CALLS", ref.raw, file(unit, ref), target.reason);
644
1000
  return;
645
1001
  }
@@ -656,6 +1012,21 @@ export function extract(input) {
656
1012
  }
657
1013
  }
658
1014
  if (found === undefined) {
1015
+ // An unqualified name bound by a `static` import whose declaring type is
1016
+ // not ours. This is how every JUnit assertion is written, and it arrives
1017
+ // here rather than at `outside` because the *enclosing* type resolved
1018
+ // fine — it simply declares no such member. Python's `from x import y`
1019
+ // shape, and the larger half there too.
1020
+ if (ref.receiver === undefined) {
1021
+ const attribution = attributeExternalStaticCall(ref.name, externalContextFor(unit));
1022
+ if (attribution !== null) {
1023
+ const to = mintExternal(attribution, "FUNCTION");
1024
+ if (to !== null) {
1025
+ push({ from: member.id, to, type: "CALLS" }, EXTERNAL_RESOLUTION);
1026
+ return;
1027
+ }
1028
+ }
1029
+ }
659
1030
  ledger(member.id, "CALLS", ref.raw, file(unit, ref), REASONS.unmodelled);
660
1031
  return;
661
1032
  }
@@ -666,6 +1037,113 @@ export function extract(input) {
666
1037
  ledger(member.id, "CALLS", ref.raw, file(unit, ref), REASONS.belowLevel);
667
1038
  }
668
1039
  }
1040
+ /**
1041
+ * `FUNCTION --READS--> MODEL` for `order.getTotalAmount()` — golden 07.
1042
+ *
1043
+ * **Why the accessor and not the field.** A JPA entity's mapped fields are
1044
+ * `private` in essentially all real code, because field-access mapping is
1045
+ * the default, so a route handler in another class cannot name the field at
1046
+ * all. An adapter admitting only `order.totalAmount` would declare `READS`
1047
+ * and then emit it almost nowhere — a declaration that is true of the code
1048
+ * and useless about it. The read Java actually writes is the accessor.
1049
+ *
1050
+ * **Why that is not the name heuristic rule 2 forbids.** Four *written*
1051
+ * things have to agree before an edge exists, and no name decides any of
1052
+ * them on its own:
1053
+ *
1054
+ * 1. the receiver's declared type resolves — same `receiverTarget` every
1055
+ * `CALLS` edge here goes through, so the receiver is typed by the
1056
+ * source or refused;
1057
+ * 2. that type was admitted as a `MODEL` by `jpa.ts`, on an
1058
+ * `@Entity`/`@Document` annotation;
1059
+ * 3. the property the accessor denotes under the JavaBeans contract is a
1060
+ * **mapped field** of that entity — present in the mapping that was
1061
+ * read, not merely a declared member; and
1062
+ * 4. the entity declares a **zero-parameter** method of exactly that name
1063
+ * whose **declared return type is the field's declared type**.
1064
+ *
1065
+ * (4) is the corroboration that makes (3) more than a spelling: a computed
1066
+ * `getDisplayName()` names no column, and a `getTotal()` returning `Money`
1067
+ * over a `long total` column does not agree with the mapping and is
1068
+ * refused. JPA's own property-access mode maps *through* this contract, so
1069
+ * following it reads a declaration rather than inferring from a name.
1070
+ *
1071
+ * **Level 2, not 3.** `shape-reach.ts` records that exactly one fact kind
1072
+ * in this package reaches R3 — the mapped field *shape* on a `MODEL` — and
1073
+ * this is not that fact. This edge says a member is read **by name**; it
1074
+ * compares no shapes and proves none, and the resolution cap makes a
1075
+ * name-level fact R2. The shape itself is already on the `MODEL` node at
1076
+ * R3, carrying its own `shapeResolution`; this edge points at it and claims
1077
+ * no more than the pointing.
1078
+ *
1079
+ * **Reads only.** `order.setTotal(x)` is a write and is deliberately not
1080
+ * admitted here: a write filed as a read is not a thinner claim, it is the
1081
+ * opposite one, and data propagation is built on the direction.
1082
+ */
1083
+ function mappedFieldRead(method, owner, unit, ref) {
1084
+ if (ref.receiver === undefined || ref.receiver === "this")
1085
+ return undefined;
1086
+ const property = beansProperty(ref.name);
1087
+ if (property === undefined)
1088
+ return undefined;
1089
+ const target = receiverTarget(method, owner, unit, ref);
1090
+ if ("reason" in target)
1091
+ return undefined;
1092
+ const entity = entitiesByType.get(`${target.declared.unit.identityPackage}::${target.declared.type.fqn}`);
1093
+ if (entity === undefined)
1094
+ return undefined;
1095
+ if (!entity.fields.some((each) => each.name === property.name))
1096
+ return undefined;
1097
+ const accessor = target.declared.type.methods.find((each) => each.name === ref.name && each.parameterNames.size === 0);
1098
+ if (accessor === undefined)
1099
+ return undefined;
1100
+ const field = target.declared.type.fieldDeclarations.find((each) => each.name === property.name);
1101
+ if (field?.writtenType === undefined)
1102
+ return undefined;
1103
+ const returns = accessor.returnType;
1104
+ if (returns.kind !== "named" && returns.kind !== "primitive")
1105
+ return undefined;
1106
+ const column = simpleTypeName(field.writtenType);
1107
+ if (simpleTypeName(returns.name) !== column)
1108
+ return undefined;
1109
+ // `isX()` is the boolean accessor and nothing else — JavaBeans says so,
1110
+ // and without the check an `isbn` column would be read out of `isBn()`.
1111
+ if (property.isForm && !BOOLEANS.has(column))
1112
+ return undefined;
1113
+ return { to: target.declared.id, field: property.name };
1114
+ }
1115
+ /**
1116
+ * Mint the dependency symbol a call's receiver reaches, and the `CALLS` edge
1117
+ * into it. `true` when it did; `false` leaves the site to the ledger.
1118
+ *
1119
+ * The receiver's type is read **as written** and never inferred, by the same
1120
+ * two scopes `receiverTarget` reads — a local or parameter's declared type,
1121
+ * then a field's — plus the static case where the receiver is itself a type
1122
+ * name. `writtenReceiverType` returns `undefined` for a `var`, a name declared
1123
+ * twice, a chained call and a dotted receiver, and each of those is a site
1124
+ * where the source wrote no type down.
1125
+ */
1126
+ function externalCall(member, method, owner, unit, ref) {
1127
+ const receiver = ref.receiver;
1128
+ if (receiver === undefined || receiver === "this")
1129
+ return false;
1130
+ const bare = receiver.startsWith("this.") ? receiver.slice("this.".length) : receiver;
1131
+ if (bare === "" || bare.includes("."))
1132
+ return false;
1133
+ // A local or field shadows a type of the same spelling, as the language
1134
+ // says — so the written type is asked for first and the receiver is read as
1135
+ // a type name only when nothing local bound it.
1136
+ const written = writtenReceiverType(method, owner, receiver) ??
1137
+ (/^[A-Z]/.test(bare) ? bare : undefined);
1138
+ const attribution = attributeExternalCall(written, ref.name, externalContextFor(unit));
1139
+ if (attribution === null)
1140
+ return false;
1141
+ const to = mintExternal(attribution, "FUNCTION");
1142
+ if (to === null)
1143
+ return false;
1144
+ push({ from: member.id, to, type: "CALLS" }, EXTERNAL_RESOLUTION);
1145
+ return true;
1146
+ }
669
1147
  /** Which type a call's receiver names, from what the source wrote down. */
670
1148
  function receiverTarget(method, owner, unit, ref) {
671
1149
  const receiver = ref.receiver;
@@ -830,11 +1308,33 @@ export function extract(input) {
830
1308
  // 12 of Java's 16 relative-path call sites are test files, none
831
1309
  // production — `test` feeds Verification status (integration
832
1310
  // coverage), not the confidence label. DEC-189.
833
- attrs: { callerKind: call.callerKind, via: call.method },
1311
+ attrs: {
1312
+ callerKind: call.callerKind,
1313
+ via: call.method,
1314
+ ...(call.responseType === undefined
1315
+ ? {}
1316
+ : { responseType: call.responseType, shapeResolution: call.shapeResolution }),
1317
+ },
834
1318
  }, 2);
835
1319
  }
836
1320
  nodes.push(...routeNodes.values(), ...endpointNodes.values());
837
1321
  }
1322
+ // DEC-164's client base, patched onto the caller. NOT behind the R2 gate
1323
+ // the `USES_API` edge sits behind: the golden asserts `attrs.clientBase` at
1324
+ // R2 and the field is a reading of the source, not of a rung.
1325
+ // `adapter-python` shipped this patch after an `input.reached` guard and it
1326
+ // was green at R3 and absent at R2 — only the monotonic sweep saw it.
1327
+ //
1328
+ // The field's ABSENCE is DEC-164's fourth state: a method whose receiver's
1329
+ // declaration this reader never saw says nothing about a base, rather than
1330
+ // saying `none`.
1331
+ for (let i = 0; i < nodes.length; i += 1) {
1332
+ const node = nodes[i];
1333
+ const base = functionClientBase.get(node.id);
1334
+ if (base === undefined)
1335
+ continue;
1336
+ nodes[i] = { ...node, attrs: { ...node.attrs, clientBase: base } };
1337
+ }
838
1338
  return { nodes, edges, unresolved };
839
1339
  }
840
1340
  //# sourceMappingURL=extract.js.map