@descryy/adapter-java 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/extract.js CHANGED
@@ -18,12 +18,25 @@
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
22
  import { jpaModels } from "./jpa.js";
23
+ import { readTransportShapes, SCHEMA as DTO_SCHEMA } from "./dto.js";
23
24
  import { SHAPE_LEVEL } from "./shape-reach.js";
24
25
  import { jaxrsRoutes, springRoutes } from "./spring.js";
25
26
  import { clientCalls, endpointFor } from "./client.js";
27
+ import { resolveBaseSite } from "./client-base.js";
28
+ import { attributeExternalCall, attributeExternalStaticCall, attributeExternalType, } from "./external.js";
26
29
  export const LANGUAGE = "java";
30
+ /** Which packaging world an external package name belongs to. The name is the
31
+ * ecosystem's, not a resolved classpath's: `org.slf4j:slf4j-api` ships the
32
+ * package `org.slf4j`, and `org.slf4j` is what the source writes and what the
33
+ * node is identified by. */
34
+ const EXTERNAL_ECOSYSTEM = "maven";
35
+ /** An external node is attributed from the file's own `import` block — module
36
+ * resolution and nothing more. R1 exactly: never R0 (the import IS the
37
+ * resolution), never R4 (nothing observed it run).
38
+ * `DEC-NEXT-external-symbol-nodes-at-the-dependency-boundary`. */
39
+ const EXTERNAL_RESOLUTION = 1;
27
40
  /** Confidence by the level an edge's evidence earned, never the level reached. */
28
41
  const CONFIDENCE = { 0: 0.5, 1: 0.7, 2: 0.9, 3: 0.95 };
29
42
  /** A compilation unit with no `package` declaration. Not a legal package name,
@@ -58,6 +71,39 @@ const REASONS = {
58
71
  "run's analysed set — most often inherited from a supertype or interface this run did not " +
59
72
  "read, rather than declared directly on the controller.",
60
73
  };
74
+ /** The two spellings `isX()` is allowed to stand for. */
75
+ const BOOLEANS = new Set(["boolean", "Boolean"]);
76
+ /**
77
+ * `java.beans.Introspector.decapitalize`, to the letter — `TotalAmount`
78
+ * becomes `totalAmount`, and `URL` stays `URL`. Written out rather than
79
+ * lower-casing the first character, because the two-leading-capitals rule is
80
+ * the half that decides whether `getURL()` names a field called `uRL` (it
81
+ * does not) and a mapping lookup on the wrong spelling silently finds nothing.
82
+ */
83
+ function decapitalize(name) {
84
+ const second = name[1];
85
+ if (second !== undefined && second !== second.toLowerCase() && /[A-Z]/.test(second))
86
+ return name;
87
+ return name[0].toLowerCase() + name.slice(1);
88
+ }
89
+ /** The property a JavaBeans accessor name denotes, or `undefined` for a name
90
+ * that is not one. `isForm` distinguishes the boolean spelling, which is only
91
+ * legal over a `boolean`/`Boolean`. */
92
+ function beansProperty(name) {
93
+ if (name.startsWith("get") && name.length > 3)
94
+ return { name: decapitalize(name.slice(3)), isForm: false };
95
+ if (name.startsWith("is") && name.length > 2)
96
+ return { name: decapitalize(name.slice(2)), isForm: true };
97
+ return undefined;
98
+ }
99
+ /** A declared type reduced to the name the two sides can be compared on:
100
+ * generics dropped, package qualification dropped. `java.lang.String` and
101
+ * `String` are one type written two ways, and refusing the edge over the
102
+ * spelling would lose a real read. */
103
+ function simpleTypeName(written) {
104
+ const bare = written.replace(/<.*$/s, "").replace(/\[\]/g, "").trim();
105
+ return bare.slice(bare.lastIndexOf(".") + 1);
106
+ }
61
107
  /**
62
108
  * Every declared type, indexed before any node id exists, then every JPA
63
109
  * entity resolved against that index.
@@ -226,6 +272,40 @@ export function extract(input) {
226
272
  const scope = input.scope;
227
273
  /** Outbound HTTP calls, collected in the method loop and emitted with the routes. */
228
274
  const clientCallSites = [];
275
+ /**
276
+ * DEC-164: caller node id -> the base this reader traced for it. First
277
+ * writer wins, so a method making two calls is described by the first one
278
+ * read. Patched onto the node after the walk, the way `adapter-csharp`
279
+ * does it — the node exists before its calls are read.
280
+ */
281
+ const functionClientBase = new Map();
282
+ /**
283
+ * ONE PROGRAM, READ TWICE — DEC-164's origin rule needs every base in the
284
+ * program before any single call site can be judged.
285
+ *
286
+ * `client-base.ts` admits an absolute call path only at an origin some base
287
+ * in this same program resolves to, and Java is the language that forces
288
+ * this to be program-wide rather than per file: one public class per file is
289
+ * not a style here but a rule, so the constant and the absolute call site
290
+ * CANNOT be in the same file. This pre-pass reads only the declaration
291
+ * tables the parse already built — no call sites, no scope resolution — so
292
+ * it is cheap, and with no absolute base anywhere it yields an empty set and
293
+ * changes nothing.
294
+ */
295
+ const knownOrigins = new Set();
296
+ for (const unit of input.units) {
297
+ const sites = [];
298
+ for (const type of unit.types) {
299
+ sites.push(...type.fieldBases.values());
300
+ for (const method of type.methods)
301
+ sites.push(...method.localBases.values());
302
+ }
303
+ for (const site of sites) {
304
+ const { origin } = resolveBaseSite(site, unit.stringConstants);
305
+ if (origin !== undefined)
306
+ knownOrigins.add(origin);
307
+ }
308
+ }
229
309
  /**
230
310
  * Returns `false` when the edge's evidence outranks the level this run
231
311
  * reached, so the caller can disclose it instead of dropping it silently.
@@ -254,6 +334,72 @@ export function extract(input) {
254
334
  file: unit.file,
255
335
  line: ref.line,
256
336
  });
337
+ /**
338
+ * Node ids already minted for a dependency symbol — one node per symbol and
339
+ * per node type, not per reference site.
340
+ *
341
+ * The three settled rules are asserted here rather than argued: the qsp
342
+ * carries an `ext:` marker (`externalQsp`), the node claims R1 and may never
343
+ * claim R0 or R4, and it owns no outgoing edge because nothing read its body.
344
+ * `thirdParty` and `binding` ride in `attrs`, outside the hash — a package
345
+ * that moved between the JDK and a jar would otherwise change identity with
346
+ * no line of source changing.
347
+ */
348
+ const externalIds = new Map();
349
+ const mintExternal = (attribution, type) => {
350
+ if (input.reached < EXTERNAL_RESOLUTION)
351
+ return null;
352
+ const qsp = externalQsp(attribution.moduleOrNamespace, attribution.symbolPath);
353
+ const key = `${type}\u0000${qsp}`;
354
+ const existing = externalIds.get(key);
355
+ if (existing !== undefined)
356
+ return existing;
357
+ const id = nodeId(scope, type, qsp, LANGUAGE);
358
+ externalIds.set(key, id);
359
+ nodes.push({
360
+ id,
361
+ type,
362
+ name: attribution.symbolPath[attribution.symbolPath.length - 1] ?? "",
363
+ // No file and no range: the symbol lives in a dependency, and a node that
364
+ // named a file here would be invalidated by a source file it has nothing
365
+ // to do with.
366
+ file: null,
367
+ range: null,
368
+ language: LANGUAGE,
369
+ producedBy: input.producedBy,
370
+ resolution: EXTERNAL_RESOLUTION,
371
+ attrs: { thirdParty: attribution.thirdParty, binding: attribution.binding },
372
+ external: {
373
+ ecosystem: EXTERNAL_ECOSYSTEM,
374
+ // Identity, and it is the package the source wrote — never a resolved
375
+ // classpath entry, never a Maven coordinate, never a version.
376
+ moduleOrNamespace: attribution.moduleOrNamespace,
377
+ },
378
+ });
379
+ return id;
380
+ };
381
+ /**
382
+ * The context `external.ts` attributes against, for one compilation unit.
383
+ * `ownPackages` and `analysed` are the repository's own; the import tables are
384
+ * the file's. Built lazily per unit and cached, since the two repository-wide
385
+ * sets are the same object every time.
386
+ */
387
+ const externalContexts = new Map();
388
+ const externalContextFor = (unit) => {
389
+ const cached = externalContexts.get(unit.file);
390
+ if (cached !== undefined)
391
+ return cached;
392
+ const context = {
393
+ imports: {
394
+ singleTypeImports: unit.singleTypeImports,
395
+ staticMemberImports: unit.staticMemberImports,
396
+ },
397
+ ownPackages,
398
+ analysed: analysedFqns,
399
+ };
400
+ externalContexts.set(unit.file, context);
401
+ return context;
402
+ };
257
403
  const ledger = (from, edgeType, rawTarget, at, reason, attrs) => {
258
404
  unresolved.push({
259
405
  fromNodeId: from,
@@ -284,6 +430,9 @@ export function extract(input) {
284
430
  // step earlier — it decides the run's resolution level from it — so it is
285
431
  // read there and handed on here rather than computed twice (`shape-reach.ts`).
286
432
  const entitiesByType = input.entities ?? readEntities(input.units);
433
+ // Transport shapes a Spring ROUTE declares — never a class this reader
434
+ // guessed at from its name (DEC-043). Own file, `dto.ts`.
435
+ const shapesByType = readTransportShapes(input.units);
287
436
  for (const unit of input.units) {
288
437
  const pkg = unit.identityPackage;
289
438
  // Named by its *primary* type, which Java requires to match the file name
@@ -324,7 +473,12 @@ export function extract(input) {
324
473
  // whole batch above, since inherited mappings cross compilation units.
325
474
  for (const type of unit.types) {
326
475
  const model = entitiesByType.get(`${unit.identityPackage}::${type.fqn}`);
327
- const kind = model === undefined ? "CLASS" : "MODEL";
476
+ // A mapped entity outranks a declared transport shape where both somehow
477
+ // apply: a wrong `MODEL` is a claim about a database and a wrong `DTO` a
478
+ // claim about a wire format, so the narrower evidence wins — the same
479
+ // tie-break, for the same reason, as `adapter-python`'s.
480
+ const shape = model === undefined ? shapesByType.get(`${unit.identityPackage}::${type.fqn}`) : undefined;
481
+ const kind = model !== undefined ? "MODEL" : shape !== undefined ? "DTO" : "CLASS";
328
482
  const id = nodeId(scope, kind, qspFor(type.fqn, unit), LANGUAGE);
329
483
  const declaredAs = byFqn.get(type.fqn);
330
484
  if (declaredAs === undefined)
@@ -350,6 +504,20 @@ export function extract(input) {
350
504
  * node's weakest.
351
505
  */
352
506
  let level = 0;
507
+ if (shape !== undefined) {
508
+ // The declaration this node rests on, named the way a `MODEL` names
509
+ // its ORM — a node type minted from evidence that does not state the
510
+ // evidence is indistinguishable from a guess outside the adapter.
511
+ attrs["schema"] = DTO_SCHEMA;
512
+ attrs["declaredDirections"] = shape.roles;
513
+ // NAMES only, and no `shapeResolution`/`fieldDetail` and no level
514
+ // raise: whether a plain class body's field list is R3-grade evidence
515
+ // is a filed, unanswered question, and `shape-reach.ts` is untouched
516
+ // by design. Golden 05 asks for exactly this — the DTO nodes without
517
+ // field detail rather than guessed fields.
518
+ if (shape.fields.length > 0)
519
+ attrs["fields"] = shape.fields;
520
+ }
353
521
  if (model !== undefined) {
354
522
  attrs["orm"] = "jpa";
355
523
  if (model.table !== null)
@@ -530,6 +698,21 @@ export function extract(input) {
530
698
  }
531
699
  return undefined;
532
700
  };
701
+ /**
702
+ * Packages this repository declares for itself, and every fully-qualified
703
+ * name this run read. Both are what `external.ts` refuses against: a type
704
+ * under one of our own packages that went unanalysed is unanalysed code of
705
+ * OURS, and calling it a dependency is the error that never surfaces.
706
+ *
707
+ * This is also the only guard against `REASONS.outside`'s disclosed
708
+ * cross-language case — 238 of `okhttp`'s 458 `outside` rows name a type
709
+ * declared in a `.kt` file in this same repository. A Kotlin class in a
710
+ * package this repository's Java sources also declare is ours by this check.
711
+ * One in a package no Java source declares is not caught, and stays a
712
+ * disclosed limit of a Java-only reader.
713
+ */
714
+ const ownPackages = new Set(input.units.map((unit) => unit.packageName).filter((name) => name !== "" && name !== DEFAULT_PACKAGE));
715
+ const analysedFqns = new Set(byFqn.keys());
533
716
  for (const unit of input.units) {
534
717
  const moduleId = moduleIdOf.get(unit.file);
535
718
  if (moduleId === undefined)
@@ -583,8 +766,29 @@ export function extract(input) {
583
766
  const read = clientCalls(method, member.id, (name) => writtenReceiverType(method, declared, name),
584
767
  // Source set read from the identity-package anchor, not pattern-
585
768
  // matched off the file path.
586
- isTestSourceSet(unit.identityPackage));
587
- clientCallSites.push(...read.calls);
769
+ isTestSourceSet(unit.identityPackage), (name) => {
770
+ // DEC-164, resolved with the same scope rules `writtenReceiverType`
771
+ // uses: a local shadows a field, and `this.x` names the field.
772
+ const viaThis = name.startsWith("this.");
773
+ const bare = viaThis ? name.slice("this.".length) : name;
774
+ if (bare.includes("."))
775
+ return undefined;
776
+ const site = (viaThis ? undefined : method.localBases.get(bare)) ?? type.fieldBases.get(bare);
777
+ return site === undefined ? undefined : resolveBaseSite(site, unit.stringConstants).base;
778
+ }, knownOrigins);
779
+ for (const call of read.calls) {
780
+ clientCallSites.push(call);
781
+ if (call.clientBase !== undefined && !functionClientBase.has(call.fromId)) {
782
+ functionClientBase.set(call.fromId, call.clientBase);
783
+ }
784
+ }
785
+ for (const refusal of read.refusals) {
786
+ // A refusal still states a base when DEC-164 reached one — 08b's
787
+ // whole point is that `unresolved` is a reading, not a silence.
788
+ if (refusal.clientBase !== undefined && !functionClientBase.has(refusal.fromId)) {
789
+ functionClientBase.set(refusal.fromId, refusal.clientBase);
790
+ }
791
+ }
588
792
  for (const refusal of read.refusals) {
589
793
  ledger(refusal.fromId, "USES_API", refusal.raw, { file: unit.file, line: refusal.line }, refusal.reason,
590
794
  // Unset `refusalClass` means unclassified (DEC-242) — `attrs`
@@ -595,9 +799,20 @@ export function extract(input) {
595
799
  blockedBy: refusal.blockedBy,
596
800
  refusalClass: refusal.refusalClass,
597
801
  ...(refusal.argumentKind === undefined ? {} : { argumentKind: refusal.argumentKind }),
802
+ ...(refusal.clientBase === undefined ? {} : { clientBase: refusal.clientBase }),
803
+ ...(refusal.callPath === undefined ? {} : { callPath: refusal.callPath }),
598
804
  });
599
805
  }
600
806
  }
807
+ /**
808
+ * Mapped-field reads, grouped by the entity they land on. Edge
809
+ * identity is `(from, to, type)`, so a method reading three columns
810
+ * of one entity is **one** edge naming three fields, not three edges
811
+ * — a singular `field` attribute would make the second read
812
+ * unrepresentable (DEC-046). Sorted on the way out so the attribute
813
+ * is a property of the source rather than of statement order.
814
+ */
815
+ const fieldReads = new Map();
601
816
  for (const ref of method.refs) {
602
817
  if (ref.kind === "type") {
603
818
  emitTypeRef(member.id, unit, ref);
@@ -612,6 +827,14 @@ export function extract(input) {
612
827
  emitTypeRef(member.id, unit, ref);
613
828
  continue;
614
829
  }
830
+ const read = mappedFieldRead(method, declared, unit, ref);
831
+ if (read !== undefined) {
832
+ const fields = fieldReads.get(read.to);
833
+ if (fields === undefined)
834
+ fieldReads.set(read.to, new Set([read.field]));
835
+ else
836
+ fields.add(read.field);
837
+ }
615
838
  // `method`, not `member.method` — an overload set's node carries
616
839
  // only the *first* overload, so resolving against
617
840
  // `member.method.locals` would type overload A's variables with
@@ -619,6 +842,11 @@ export function extract(input) {
619
842
  // could see.
620
843
  emitCall(member, method, declared, unit, ref);
621
844
  }
845
+ for (const [to, fields] of fieldReads) {
846
+ if (push({ from: member.id, to, type: "READS", attrs: { fields: [...fields].sort() } }, 2))
847
+ continue;
848
+ ledger(member.id, "READS", [...fields].sort().join(", "), { file: unit.file, line: method.startLine }, REASONS.belowLevel);
849
+ }
622
850
  }
623
851
  }
624
852
  }
@@ -630,6 +858,27 @@ export function extract(input) {
630
858
  }
631
859
  return;
632
860
  }
861
+ // A type reference that left the repository. When a binding the developer
862
+ // wrote settles which package and which type, it mints a `CLASS` standing
863
+ // for that type in the dependency and is reached by `USES_TYPE` — the node
864
+ // type follows the EDGE, because that is what the edge means. An attributed
865
+ // site files no ledger row: a site that resolves to a node is not an
866
+ // `UnresolvedRef`, and filing both would count it twice.
867
+ //
868
+ // `member` refs are excluded. `parse.ts` calls them a *candidate* only —
869
+ // `Status.PENDING` is an enum constant and `Map.Entry` is a nested type, and
870
+ // nothing written tells them apart. Minting a `CLASS` for the first is a
871
+ // wrong fact, so both are declined.
872
+ if (ref.kind === "type" && resolved.reason === REASONS.outside) {
873
+ const attribution = attributeExternalType(ref.name, externalContextFor(unit));
874
+ if (attribution !== null) {
875
+ const to = mintExternal(attribution, "CLASS");
876
+ if (to !== null) {
877
+ push({ from, to, type: "USES_TYPE" }, EXTERNAL_RESOLUTION);
878
+ return;
879
+ }
880
+ }
881
+ }
633
882
  ledger(from, "USES_TYPE", ref.name, file(unit, ref), resolved.reason);
634
883
  }
635
884
  function emitCall(member, method, owner, unit, ref) {
@@ -640,6 +889,18 @@ export function extract(input) {
640
889
  const resolvedType = method.isTest ? "TESTS" : "CALLS";
641
890
  const target = receiverTarget(method, owner, unit, ref);
642
891
  if ("reason" in target) {
892
+ // A call whose receiver's **written** type left the repository. Java
893
+ // writes its types down where Go and PHP do not, so this is the half that
894
+ // makes the boundary worth attributing here at all: `Logger log` under
895
+ // `import org.slf4j.Logger` says which package `log.info(...)` reaches.
896
+ //
897
+ // Only `outside` — `untypedReceiver` means no type was written at this
898
+ // reference (a `var`, a chained call, a generic variable) and there is
899
+ // nothing to read; `unknownName` means the simple name IS declared in this
900
+ // repository and calling it a dependency would be wrong.
901
+ if (target.reason === REASONS.outside && externalCall(member, method, owner, unit, ref)) {
902
+ return;
903
+ }
643
904
  ledger(member.id, "CALLS", ref.raw, file(unit, ref), target.reason);
644
905
  return;
645
906
  }
@@ -656,6 +917,21 @@ export function extract(input) {
656
917
  }
657
918
  }
658
919
  if (found === undefined) {
920
+ // An unqualified name bound by a `static` import whose declaring type is
921
+ // not ours. This is how every JUnit assertion is written, and it arrives
922
+ // here rather than at `outside` because the *enclosing* type resolved
923
+ // fine — it simply declares no such member. Python's `from x import y`
924
+ // shape, and the larger half there too.
925
+ if (ref.receiver === undefined) {
926
+ const attribution = attributeExternalStaticCall(ref.name, externalContextFor(unit));
927
+ if (attribution !== null) {
928
+ const to = mintExternal(attribution, "FUNCTION");
929
+ if (to !== null) {
930
+ push({ from: member.id, to, type: "CALLS" }, EXTERNAL_RESOLUTION);
931
+ return;
932
+ }
933
+ }
934
+ }
659
935
  ledger(member.id, "CALLS", ref.raw, file(unit, ref), REASONS.unmodelled);
660
936
  return;
661
937
  }
@@ -666,6 +942,113 @@ export function extract(input) {
666
942
  ledger(member.id, "CALLS", ref.raw, file(unit, ref), REASONS.belowLevel);
667
943
  }
668
944
  }
945
+ /**
946
+ * `FUNCTION --READS--> MODEL` for `order.getTotalAmount()` — golden 07.
947
+ *
948
+ * **Why the accessor and not the field.** A JPA entity's mapped fields are
949
+ * `private` in essentially all real code, because field-access mapping is
950
+ * the default, so a route handler in another class cannot name the field at
951
+ * all. An adapter admitting only `order.totalAmount` would declare `READS`
952
+ * and then emit it almost nowhere — a declaration that is true of the code
953
+ * and useless about it. The read Java actually writes is the accessor.
954
+ *
955
+ * **Why that is not the name heuristic rule 2 forbids.** Four *written*
956
+ * things have to agree before an edge exists, and no name decides any of
957
+ * them on its own:
958
+ *
959
+ * 1. the receiver's declared type resolves — same `receiverTarget` every
960
+ * `CALLS` edge here goes through, so the receiver is typed by the
961
+ * source or refused;
962
+ * 2. that type was admitted as a `MODEL` by `jpa.ts`, on an
963
+ * `@Entity`/`@Document` annotation;
964
+ * 3. the property the accessor denotes under the JavaBeans contract is a
965
+ * **mapped field** of that entity — present in the mapping that was
966
+ * read, not merely a declared member; and
967
+ * 4. the entity declares a **zero-parameter** method of exactly that name
968
+ * whose **declared return type is the field's declared type**.
969
+ *
970
+ * (4) is the corroboration that makes (3) more than a spelling: a computed
971
+ * `getDisplayName()` names no column, and a `getTotal()` returning `Money`
972
+ * over a `long total` column does not agree with the mapping and is
973
+ * refused. JPA's own property-access mode maps *through* this contract, so
974
+ * following it reads a declaration rather than inferring from a name.
975
+ *
976
+ * **Level 2, not 3.** `shape-reach.ts` records that exactly one fact kind
977
+ * in this package reaches R3 — the mapped field *shape* on a `MODEL` — and
978
+ * this is not that fact. This edge says a member is read **by name**; it
979
+ * compares no shapes and proves none, and the resolution cap makes a
980
+ * name-level fact R2. The shape itself is already on the `MODEL` node at
981
+ * R3, carrying its own `shapeResolution`; this edge points at it and claims
982
+ * no more than the pointing.
983
+ *
984
+ * **Reads only.** `order.setTotal(x)` is a write and is deliberately not
985
+ * admitted here: a write filed as a read is not a thinner claim, it is the
986
+ * opposite one, and data propagation is built on the direction.
987
+ */
988
+ function mappedFieldRead(method, owner, unit, ref) {
989
+ if (ref.receiver === undefined || ref.receiver === "this")
990
+ return undefined;
991
+ const property = beansProperty(ref.name);
992
+ if (property === undefined)
993
+ return undefined;
994
+ const target = receiverTarget(method, owner, unit, ref);
995
+ if ("reason" in target)
996
+ return undefined;
997
+ const entity = entitiesByType.get(`${target.declared.unit.identityPackage}::${target.declared.type.fqn}`);
998
+ if (entity === undefined)
999
+ return undefined;
1000
+ if (!entity.fields.some((each) => each.name === property.name))
1001
+ return undefined;
1002
+ const accessor = target.declared.type.methods.find((each) => each.name === ref.name && each.parameterNames.size === 0);
1003
+ if (accessor === undefined)
1004
+ return undefined;
1005
+ const field = target.declared.type.fieldDeclarations.find((each) => each.name === property.name);
1006
+ if (field?.writtenType === undefined)
1007
+ return undefined;
1008
+ const returns = accessor.returnType;
1009
+ if (returns.kind !== "named" && returns.kind !== "primitive")
1010
+ return undefined;
1011
+ const column = simpleTypeName(field.writtenType);
1012
+ if (simpleTypeName(returns.name) !== column)
1013
+ return undefined;
1014
+ // `isX()` is the boolean accessor and nothing else — JavaBeans says so,
1015
+ // and without the check an `isbn` column would be read out of `isBn()`.
1016
+ if (property.isForm && !BOOLEANS.has(column))
1017
+ return undefined;
1018
+ return { to: target.declared.id, field: property.name };
1019
+ }
1020
+ /**
1021
+ * Mint the dependency symbol a call's receiver reaches, and the `CALLS` edge
1022
+ * into it. `true` when it did; `false` leaves the site to the ledger.
1023
+ *
1024
+ * The receiver's type is read **as written** and never inferred, by the same
1025
+ * two scopes `receiverTarget` reads — a local or parameter's declared type,
1026
+ * then a field's — plus the static case where the receiver is itself a type
1027
+ * name. `writtenReceiverType` returns `undefined` for a `var`, a name declared
1028
+ * twice, a chained call and a dotted receiver, and each of those is a site
1029
+ * where the source wrote no type down.
1030
+ */
1031
+ function externalCall(member, method, owner, unit, ref) {
1032
+ const receiver = ref.receiver;
1033
+ if (receiver === undefined || receiver === "this")
1034
+ return false;
1035
+ const bare = receiver.startsWith("this.") ? receiver.slice("this.".length) : receiver;
1036
+ if (bare === "" || bare.includes("."))
1037
+ return false;
1038
+ // A local or field shadows a type of the same spelling, as the language
1039
+ // says — so the written type is asked for first and the receiver is read as
1040
+ // a type name only when nothing local bound it.
1041
+ const written = writtenReceiverType(method, owner, receiver) ??
1042
+ (/^[A-Z]/.test(bare) ? bare : undefined);
1043
+ const attribution = attributeExternalCall(written, ref.name, externalContextFor(unit));
1044
+ if (attribution === null)
1045
+ return false;
1046
+ const to = mintExternal(attribution, "FUNCTION");
1047
+ if (to === null)
1048
+ return false;
1049
+ push({ from: member.id, to, type: "CALLS" }, EXTERNAL_RESOLUTION);
1050
+ return true;
1051
+ }
669
1052
  /** Which type a call's receiver names, from what the source wrote down. */
670
1053
  function receiverTarget(method, owner, unit, ref) {
671
1054
  const receiver = ref.receiver;
@@ -835,6 +1218,22 @@ export function extract(input) {
835
1218
  }
836
1219
  nodes.push(...routeNodes.values(), ...endpointNodes.values());
837
1220
  }
1221
+ // DEC-164's client base, patched onto the caller. NOT behind the R2 gate
1222
+ // the `USES_API` edge sits behind: the golden asserts `attrs.clientBase` at
1223
+ // R2 and the field is a reading of the source, not of a rung.
1224
+ // `adapter-python` shipped this patch after an `input.reached` guard and it
1225
+ // was green at R3 and absent at R2 — only the monotonic sweep saw it.
1226
+ //
1227
+ // The field's ABSENCE is DEC-164's fourth state: a method whose receiver's
1228
+ // declaration this reader never saw says nothing about a base, rather than
1229
+ // saying `none`.
1230
+ for (let i = 0; i < nodes.length; i += 1) {
1231
+ const node = nodes[i];
1232
+ const base = functionClientBase.get(node.id);
1233
+ if (base === undefined)
1234
+ continue;
1235
+ nodes[i] = { ...node, attrs: { ...node.attrs, clientBase: base } };
1236
+ }
838
1237
  return { nodes, edges, unresolved };
839
1238
  }
840
1239
  //# sourceMappingURL=extract.js.map