@descryy/adapter-python 0.1.0 → 0.3.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
@@ -34,7 +34,7 @@ import { databaseTableNodesFromModel, edgeId, endpointQsp, isTypeLikeNodeType, n
34
34
  import { importRoots, modulePathOf, moduleNameOf, resolveImport, } from "./module.js";
35
35
  import { DECLARATIVE_BASE_FACTORIES, ORM_BASE_MODULES, isFrameworkBaseName, ormShapeOf, } from "./orm.js";
36
36
  import { globToRegExp, PYTEST_DEFAULTS } from "./pytest-config.js";
37
- import { drfShapeOf, dtoShapeOf, isDrfSerializerRoot, isPydanticRoot, isValidatorDecorator, } from "./pydantic.js";
37
+ import { drfShapeOf, dtoShapeOf, isDrfSerializerRoot, isPydanticRoot, isValidatorDecorator, PYDANTIC_MODEL_MEMBERS, } from "./pydantic.js";
38
38
  import { DRF_ACTIONS, drfRegisterCallsIn, drfRouterLocalsIn, djangoUrlEntriesIn, HTTP_METHODS, isApplicationRouter, joinPath, mountsIn, odooRouteFromDecorator, routeFromDecorator, routersIn, } from "./routes.js";
39
39
  import { GRAPHQL_METHOD, fieldFromMethod, fieldsFromAttributes, operationPath, schemaDeclsIn, } from "./graphql.js";
40
40
  import { clientCallsIn, clientLocalsIn } from "./client.js";
@@ -85,8 +85,12 @@ const REASONS = {
85
85
  callPathFromLocal: "an HTTP call whose path is a local assembled earlier in the function. Distinguished from an " +
86
86
  "opaque path because it is reachable by further work, and filing the two together would " +
87
87
  "report one number for two populations.",
88
- routeNeverMounted: "this route is declared on a router that no source file mounts, so the path it serves is " +
89
- "unknowable. Commonly a plugin router mounted by the framework at runtime.",
88
+ routeNeverMounted: "this route is declared on a router that no source file mounts, so this run cannot prove it " +
89
+ "is ever served. The API_ROUTE node is still emitted, with attrs.mounted: false and the path " +
90
+ "it would serve if mounted at the application root with no further prefix — but no " +
91
+ "API_ENDPOINT or SERVES_API edge, because minting one would claim a path this application " +
92
+ "does not actually expose. Sometimes a real defect (a router nobody registered); sometimes a " +
93
+ "plugin router mounted by the framework at runtime, outside what this reader can see.",
90
94
  routeIdCollision: "this route's method and path are identical to one already emitted, so it collapsed onto " +
91
95
  "the same API_ROUTE node and this declaration's own SERVES_API edge and location were " +
92
96
  "dropped. The endpoint is real; this specific declaration is not the one the graph kept.",
@@ -741,6 +745,23 @@ export function extract(input) {
741
745
  ...(parsed.migrationOps !== undefined && parsed.migrationOps.length > 0
742
746
  ? { migrationOps: parsed.migrationOps }
743
747
  : {}),
748
+ // `migrationRevision`/`migrationDownRevision`/`migrationDir`: the
749
+ // Alembic chain a consumer needs for "the revision graph is broken" and
750
+ // "this migration lives outside the migrations directory" — neither
751
+ // check exists yet, but both need this to exist first. `migrationDir`
752
+ // is the file's own containing directory, repeated here rather than
753
+ // left for a consumer to re-derive from `file` on the node, because
754
+ // deriving it is a POSIX-path detail with one right answer, not a
755
+ // judgement — the judgement (which directory is *the* migrations
756
+ // directory, and whether this one disagrees with it) stays a
757
+ // consumer's to make.
758
+ ...(parsed.migrationRevision != null
759
+ ? {
760
+ migrationRevision: parsed.migrationRevision.revision,
761
+ migrationDownRevision: parsed.migrationRevision.downRevision,
762
+ migrationDir: file.includes("/") ? file.slice(0, file.lastIndexOf("/")) : "",
763
+ }
764
+ : {}),
744
765
  };
745
766
  nodes.push({
746
767
  id: moduleId,
@@ -1092,6 +1113,172 @@ export function extract(input) {
1092
1113
  const target = followName(file, chosen.typeName);
1093
1114
  return target !== undefined && isTypeLikeNodeType(target.type) ? target : undefined;
1094
1115
  }
1116
+ /**
1117
+ * A function parameter's own declared type — the receiver-type source
1118
+ * `instantiatedType` cannot see, because a parameter is never a bare
1119
+ * `x = ClassName()` instantiation. `def get(org: Organization)` is exactly
1120
+ * the shape UAT round 3's ground truth needs (`src/apis/reviews.py:31-32`
1121
+ * reads two attributes `Organization` does not declare, on a parameter, not
1122
+ * a local).
1123
+ *
1124
+ * Requires the annotation to resolve to **exactly one** type-like
1125
+ * declaration. `Optional[Organization]`, `Organization | None` and
1126
+ * `Union[Organization, Business]` all name more than one identifier once
1127
+ * `typing`'s own constructors are in the mix; only the unambiguous case is
1128
+ * read, because attributing an access to the wrong half of a union would be
1129
+ * a worse error than not attributing it at all.
1130
+ */
1131
+ function parameterAnnotatedType(container, local) {
1132
+ const param = container.declaration.params?.find((p) => p.name === local);
1133
+ if (param === undefined)
1134
+ return undefined;
1135
+ const candidates = typeNamesIn(param.annotation)
1136
+ .map((name) => followName(container.file, name))
1137
+ .filter((target) => target !== undefined && isTypeLikeNodeType(target.type));
1138
+ return candidates.length === 1 ? candidates[0] : undefined;
1139
+ }
1140
+ const localAnnotationIndex = new Map();
1141
+ const localAnnotationsOf = (file, local) => {
1142
+ let index = localAnnotationIndex.get(file);
1143
+ if (index === undefined) {
1144
+ index = new Map();
1145
+ for (const entry of input.parsed.files[file]?.localAnnotations ?? []) {
1146
+ const bucket = index.get(entry.local);
1147
+ if (bucket === undefined)
1148
+ index.set(entry.local, [entry]);
1149
+ else
1150
+ bucket.push(entry);
1151
+ }
1152
+ localAnnotationIndex.set(file, index);
1153
+ }
1154
+ return index.get(local) ?? [];
1155
+ };
1156
+ /**
1157
+ * A local variable's *own* type annotation — `org: Organization | None =
1158
+ * await get_org_for_user(...)` inside a function body. Neither
1159
+ * `instantiatedType` (a bare constructor call only) nor
1160
+ * `parameterAnnotatedType` (a function parameter only) can see this shape;
1161
+ * it is the actual root cause of UAT round 4's missed defect
1162
+ * (`sherpa-backend`'s `src/apis/reviews.py`: `org` is a local, not a
1163
+ * parameter, and its annotation was never read at all).
1164
+ *
1165
+ * Same scope discipline as `instantiatedType` — innermost enclosing scope
1166
+ * wins over a module-level annotation of the same name — and the same
1167
+ * "exactly one type-like candidate" discipline `parameterAnnotatedType`
1168
+ * already applies: `Optional[X]`, `X | None` and `Union[X, Y]` all still
1169
+ * resolve to nothing where more than one type-like name survives.
1170
+ */
1171
+ function localAnnotatedType(file, scopePath, local) {
1172
+ const scopeKey = scopePath.join(".");
1173
+ const inScope = localAnnotationsOf(file, local).filter((a) => a.scope.length === 0 || a.scope.join(".") === scopeKey);
1174
+ const chosen = inScope.find((a) => a.scope.length > 0 && a.scope.join(".") === scopeKey) ??
1175
+ inScope.find((a) => a.scope.length === 0);
1176
+ if (chosen === undefined)
1177
+ return undefined;
1178
+ const candidates = typeNamesIn(chosen.annotation)
1179
+ .map((name) => followName(file, name))
1180
+ .filter((target) => target !== undefined && isTypeLikeNodeType(target.type));
1181
+ return candidates.length === 1 ? candidates[0] : undefined;
1182
+ }
1183
+ /**
1184
+ * Is "this attribute is not declared" a claim this adapter can actually
1185
+ * stand behind for this type?
1186
+ *
1187
+ * Two hazards, both real and both measured against a shipped framework
1188
+ * rather than imagined:
1189
+ *
1190
+ * 1. **Field inheritance is not resolved anywhere in this adapter.** A
1191
+ * mixin's own fields never merge into a subclass's `declaration.fields`
1192
+ * — `class Organization(Base, TimestampMixin)` would have `created_at`
1193
+ * genuinely declared and this reader would not know it. Every base must
1194
+ * therefore resolve to *nothing in this repository* (the framework's own
1195
+ * external root, `BaseModel`/`Base`/`Model`) for this class's own field
1196
+ * list to be complete rather than partial. A base written and unreadable
1197
+ * (`null`) is treated the same as a resolvable in-repo one: unknown, so
1198
+ * unsafe.
1199
+ * 2. **Two of the four framework shapes this adapter recognises inject
1200
+ * fields no source line ever names.** Django gives every model an
1201
+ * implicit `id`/`pk` whether or not the class declares one, and a DRF
1202
+ * `ModelSerializer` can populate its whole field list from
1203
+ * `Meta.fields = "__all__"` — `dtoShapes` holds a DRF shape and a
1204
+ * Pydantic shape indistinguishably (both `drfShapeOf`/`dtoShapeOf`
1205
+ * produce a `DtoShape`), so `isPydanticClass` is asked directly rather
1206
+ * than trusted from `dtoShapes`' bare presence. Plain Pydantic
1207
+ * (`BaseModel`, validation rejects unknown fields, no implicit ones) and
1208
+ * SQLAlchemy (columns are always explicit) carry neither hazard.
1209
+ */
1210
+ function attributeExistenceIsProvable(receiverType) {
1211
+ const orm = ormShapes.get(receiverType.id);
1212
+ const safeFramework = orm !== undefined
1213
+ ? orm.framework === "sqlalchemy"
1214
+ : dtoShapes.has(receiverType.id) &&
1215
+ isPydanticClass(receiverType.file, receiverType.declaration.path[0] ?? "");
1216
+ if (!safeFramework)
1217
+ return false;
1218
+ const bases = receiverType.declaration.bases ?? [];
1219
+ return bases.every((base) => {
1220
+ if (base === null)
1221
+ return false;
1222
+ const leaf = base.split(".").pop().split("[")[0];
1223
+ return baseChainIsFieldFree(receiverType.file, leaf);
1224
+ });
1225
+ }
1226
+ /**
1227
+ * Is this a read of `BaseModel`'s own surface, reached through a
1228
+ * Pydantic-classified receiver (the same classification
1229
+ * `attributeExistenceIsProvable` uses, not SQLAlchemy's)?
1230
+ *
1231
+ * `baseChainIsFieldFree` treats any unresolvable base as field-free —
1232
+ * correct for SQLAlchemy's `DeclarativeBase` marker, but not for
1233
+ * `BaseModel`, which is equally unresolvable-in-repo yet has a real,
1234
+ * stable member set of its own (`model_dump`, `model_fields_set`, ...).
1235
+ * Rather than changing `baseChainIsFieldFree` itself — which would also
1236
+ * change the SQLAlchemy path, and that path's assumption is correct —
1237
+ * this is checked narrowly at the one call site that turns "not a
1238
+ * declared field" into a reported `unresolvedFields` fact.
1239
+ */
1240
+ function isPydanticModelMember(receiverType, name) {
1241
+ if (!PYDANTIC_MODEL_MEMBERS.has(name))
1242
+ return false;
1243
+ const orm = ormShapes.get(receiverType.id);
1244
+ if (orm !== undefined)
1245
+ return false; // SQLAlchemy (or another ORM) — untouched, not this path
1246
+ return dtoShapes.has(receiverType.id) && isPydanticClass(receiverType.file, receiverType.declaration.path[0] ?? "");
1247
+ }
1248
+ /**
1249
+ * Walks a class's own base chain, and is safe to "see through" only where
1250
+ * every hop is either external (nothing this run declares — the
1251
+ * framework's own root, `DeclarativeBase`/`BaseModel`) or an in-repo class
1252
+ * that itself declares no attributes of its own.
1253
+ *
1254
+ * The distinction this exists for: SQLAlchemy 2.0's own idiom is a
1255
+ * **project-local, field-free** declarative base —
1256
+ * `class Base(DeclarativeBase): pass` then `class Organization(Base):` —
1257
+ * and treating every in-repo base as an unknown-fields hazard would refuse
1258
+ * to ever run on exactly that shape. A base that *does* declare its own
1259
+ * attributes (`class TimestampMixin: created_at = Column(...)`) is the
1260
+ * genuine hazard `attributeExistenceIsProvable`'s docstring names, and
1261
+ * still stops the walk.
1262
+ */
1263
+ function baseChainIsFieldFree(file, name, seen = new Set()) {
1264
+ const key = `${file}:${name}`;
1265
+ if (seen.has(key) || seen.size > 32)
1266
+ return false; // a cycle or runaway depth: unsafe, not a guess
1267
+ seen.add(key);
1268
+ const target = followName(file, name);
1269
+ if (target === undefined)
1270
+ return true; // resolves to nothing this run declares: an external, framework-owned name
1271
+ const ownFields = (target.declaration.attributes ?? []).length > 0 || (target.declaration.fields ?? []).length > 0;
1272
+ if (ownFields)
1273
+ return false;
1274
+ const parentBases = target.declaration.bases ?? [];
1275
+ return parentBases.every((base) => {
1276
+ if (base === null)
1277
+ return false;
1278
+ const leaf = base.split(".").pop().split("[")[0];
1279
+ return baseChainIsFieldFree(target.file, leaf, seen);
1280
+ });
1281
+ }
1095
1282
  for (const file of files) {
1096
1283
  const parsed = input.parsed.files[file];
1097
1284
  const byFull = byFullName.get(file);
@@ -1605,8 +1792,14 @@ export function extract(input) {
1605
1792
  return result;
1606
1793
  };
1607
1794
  /**
1608
- * Every type name a route's contract mentions — its handler's parameter
1609
- * annotations, its `response_model=`, and its return annotation.
1795
+ * Every type name a route's contract mentions, split by which side of the
1796
+ * contract wrote it — `adapter-openapi`'s own `roles` vocabulary
1797
+ * (`adapter-openapi/src/adapter.ts` ~L355-370), matched rather than
1798
+ * reinvented so the contract engine sees one convention from either
1799
+ * producer. A parameter's own annotation is the request shape; a
1800
+ * `response_model=` or the handler's return annotation is the response
1801
+ * shape. Both are read straight off the route declaration — no inference,
1802
+ * so no extra resolution beyond what the route pass already needs.
1610
1803
  *
1611
1804
  * Names, not resolutions: the caller decides which of them is a `DTO`. A
1612
1805
  * route naming a type that is not a validated shape produces no edge and
@@ -1617,17 +1810,17 @@ export function extract(input) {
1617
1810
  const handler = route.handler === null
1618
1811
  ? undefined
1619
1812
  : parsed?.declarations.find((d) => d.name === route.handler.join("."));
1620
- const written = [
1621
- route.responseModel,
1622
- handler?.returns ?? null,
1623
- ...(handler?.params ?? []).map((p) => p.annotation),
1624
- ];
1625
- const out = new Set();
1626
- for (const annotation of written) {
1813
+ const request = new Set();
1814
+ for (const param of handler?.params ?? []) {
1815
+ for (const name of typeNamesIn(param.annotation))
1816
+ request.add(name);
1817
+ }
1818
+ const response = new Set();
1819
+ for (const annotation of [route.responseModel, handler?.returns ?? null]) {
1627
1820
  for (const name of typeNamesIn(annotation))
1628
- out.add(name);
1821
+ response.add(name);
1629
1822
  }
1630
- return [...out];
1823
+ return { request, response };
1631
1824
  };
1632
1825
  for (const route of routeDecls) {
1633
1826
  const key = lookupRouter(route.file, route.scope, route.routerLocal);
@@ -1658,9 +1851,16 @@ export function extract(input) {
1658
1851
  const level = (chain.level === 1 || crossesFile(route.file, route.routerLocal) ? 1 : 0);
1659
1852
  if (level > input.reached)
1660
1853
  continue;
1661
- // A router nothing mounts serves nothing, and the mount is where the
1662
- // served path is decided. dispatch declares four such routes in a Slack
1663
- // plugin whose router is mounted by plugin machinery at runtime.
1854
+ // A router nothing mounts serves nothing at the path this run can prove,
1855
+ // and the mount is where the served path used to be decided — but a
1856
+ // route declared on an unmounted router is a fact in its own right
1857
+ // (UAT round 3, item 2: `documents/process/descry-notes-sherpa-backend-3.md`
1858
+ // in `descry-core`), not a reason to withhold the node entirely. dispatch
1859
+ // declares four such routes in a Slack plugin whose router is mounted by
1860
+ // plugin machinery at runtime — `mounted: false` on the node is the
1861
+ // honest fact for that case too, not a defect claim; the check that
1862
+ // reads it decides which of the two this is, this adapter only reports
1863
+ // what the graph can prove.
1664
1864
  //
1665
1865
  // **The guard used to fire only when the router had no prefix of its own**
1666
1866
  // — `!parentOf.has(key) && routers.get(key)?.prefix === ""` — which meant
@@ -1668,10 +1868,17 @@ export function extract(input) {
1668
1868
  // anyway. It happened to produce correct paths on flask because those
1669
1869
  // blueprints *are* registered, so the reasoning was unsound while the
1670
1870
  // answer was right, which is the combination that survives a draw.
1671
- if (!parentOf.has(key) && !isApplicationRouter(router)) {
1871
+ const mounted = parentOf.has(key) || isApplicationRouter(router);
1872
+ if (!mounted) {
1672
1873
  disclose(from, "SERVES_API", `${route.methods.join("/")} ${route.path}`, route.file, route.line, "routeNeverMounted");
1673
- continue;
1674
1874
  }
1875
+ // `prefix` is the router's own local prefix chain when nothing mounts
1876
+ // it — real, local information (DEC-069's own standard: a fact read off
1877
+ // this router's own declaration), never a guess about where an external
1878
+ // caller mounts it. It is exactly the path this route would serve if
1879
+ // mounted at the application's root with no further prefix, and
1880
+ // `attrs.mounted` on the node below is what keeps that conditional
1881
+ // honest rather than implied.
1675
1882
  const served = joinPath(prefix, route.path);
1676
1883
  for (const method of route.methods) {
1677
1884
  // The route keeps the template AS WRITTEN in its identity and the
@@ -1695,6 +1902,13 @@ export function extract(input) {
1695
1902
  pathTemplate: normaliseEndpointPath(served),
1696
1903
  rawTemplate: served,
1697
1904
  framework: router.framework,
1905
+ // A stated fact, not an inference: whether *this run's* mount
1906
+ // graph reaches this router. `false` is exactly the shape
1907
+ // "declared and not mounted" needs — a route with no
1908
+ // corresponding `SERVES_API` edge below, which is how "declared
1909
+ // but unreachable" is expressed inside the frozen 15/15
1910
+ // vocabulary rather than through a new edge type.
1911
+ mounted,
1698
1912
  },
1699
1913
  });
1700
1914
  }
@@ -1707,29 +1921,39 @@ export function extract(input) {
1707
1921
  // one declaration exists".
1708
1922
  disclose(from, "SERVES_API", `${method} ${served}`, route.file, route.line, "routeIdCollision");
1709
1923
  }
1710
- // Fileless AND language-less (DEC-014). `nodeId` throws if a fileless
1711
- // type is hashed with a language, which is the guard against an id that
1712
- // disagrees with the node it labels — a failure whose only symptom
1713
- // would be a join that silently does not happen.
1714
- const endpointId = nodeId(scope, "API_ENDPOINT", endpointQsp(method, served), null);
1715
- if (!routeSeen.has(endpointId)) {
1716
- routeSeen.add(endpointId);
1717
- nodes.push({
1718
- id: endpointId,
1719
- type: "API_ENDPOINT",
1720
- name: `${method} ${normaliseEndpointPath(served)}`,
1721
- file: null,
1722
- range: null,
1723
- language: null,
1724
- producedBy: input.producedBy,
1725
- resolution: level,
1726
- attrs: { method, pathTemplate: normaliseEndpointPath(served) },
1727
- });
1924
+ // **Nothing served, nothing to join.** An unmounted router's route has
1925
+ // no real served path — `served` above is only what it *would* be at
1926
+ // the application root, and minting a workspace-scoped `API_ENDPOINT`
1927
+ // for it would let an unrelated caller in another repository join to
1928
+ // a path this application does not actually expose. The route's own
1929
+ // contract (`USES_TYPE`, below) still holds regardless — a shape is a
1930
+ // fact about the handler, not about routing — so only the endpoint and
1931
+ // `SERVES_API` are withheld here.
1932
+ if (mounted) {
1933
+ // Fileless AND language-less (DEC-014). `nodeId` throws if a fileless
1934
+ // type is hashed with a language, which is the guard against an id that
1935
+ // disagrees with the node it labels — a failure whose only symptom
1936
+ // would be a join that silently does not happen.
1937
+ const endpointId = nodeId(scope, "API_ENDPOINT", endpointQsp(method, served), null);
1938
+ if (!routeSeen.has(endpointId)) {
1939
+ routeSeen.add(endpointId);
1940
+ nodes.push({
1941
+ id: endpointId,
1942
+ type: "API_ENDPOINT",
1943
+ name: `${method} ${normaliseEndpointPath(served)}`,
1944
+ file: null,
1945
+ range: null,
1946
+ language: null,
1947
+ producedBy: input.producedBy,
1948
+ resolution: level,
1949
+ attrs: { method, pathTemplate: normaliseEndpointPath(served) },
1950
+ });
1951
+ }
1952
+ // `SERVES_API` and nothing else *to the endpoint*. Golden pattern 04
1953
+ // asserts this edge alone, and a route-to-handler edge would be a
1954
+ // second claim with no golden behind it and no draw measuring it.
1955
+ push({ from: routeId, to: endpointId, type: "SERVES_API" }, level);
1728
1956
  }
1729
- // `SERVES_API` and nothing else *to the endpoint*. Golden pattern 04
1730
- // asserts this edge alone, and a route-to-handler edge would be a
1731
- // second claim with no golden behind it and no draw measuring it.
1732
- push({ from: routeId, to: endpointId, type: "SERVES_API" }, level);
1733
1957
  // The route's contract: which validated shapes it names. `USES_TYPE`
1734
1958
  // is the edge `adapter-openapi` already emits from a route to a schema
1735
1959
  // it names, and matching it is not cosmetic — two producers describing
@@ -1739,12 +1963,31 @@ export function extract(input) {
1739
1963
  // **R2, and gated.** Deciding which `OrderRead` a name means is
1740
1964
  // reference resolution through re-exports, which is what R2 buys and
1741
1965
  // what `CALLS` and `USES_TYPE` already claim in this adapter.
1966
+ //
1967
+ // **`attrs.roles`, `adapter-openapi`'s own convention.** Without it the
1968
+ // contract engine cannot tell a request schema from a response schema
1969
+ // on this edge — `contracts/engine.ts` ~L325-330 refuses to run shape
1970
+ // comparison at all until every route->DTO edge carries one. A name
1971
+ // seen on both sides of the contract (a `PUT` taking and returning the
1972
+ // same shape) carries both roles on the one edge DEC-012 allows, never
1973
+ // two edges for one `hash(from, to, type)`.
1742
1974
  if (input.reached >= 2) {
1743
- for (const name of routeContractNames(route)) {
1975
+ const { request, response } = routeContractNames(route);
1976
+ for (const name of new Set([...request, ...response])) {
1744
1977
  const target = followName(route.file, name);
1745
1978
  if (target === undefined || !dtoShapes.has(target.id))
1746
1979
  continue;
1747
- push({ from: routeId, to: target.id, type: "USES_TYPE" }, 2);
1980
+ const roles = [];
1981
+ if (request.has(name))
1982
+ roles.push("request");
1983
+ if (response.has(name))
1984
+ roles.push("response");
1985
+ push({
1986
+ from: routeId,
1987
+ to: target.id,
1988
+ type: "USES_TYPE",
1989
+ ...(roles.length === 0 ? {} : { attrs: { roles } }),
1990
+ }, 2);
1748
1991
  }
1749
1992
  }
1750
1993
  }
@@ -2175,7 +2418,16 @@ export function extract(input) {
2175
2418
  const container = containerOf(reference.scope);
2176
2419
  if (container === undefined || container.type !== "FUNCTION")
2177
2420
  continue;
2178
- const receiverType = instantiatedType(file, reference.scope, reference.receiver);
2421
+ // A local instantiation (`org = Organization()`) first, a parameter's
2422
+ // own annotation (`def get(org: Organization)`) second, a local
2423
+ // variable's own annotation (`org: Organization | None = await
2424
+ // get_org_for_user(...)`) third — the ground truth this pass exists to
2425
+ // catch is the third shape (`sherpa-backend`'s `src/apis/reviews.py`),
2426
+ // which neither `instantiatedType` nor `parameterAnnotatedType` alone
2427
+ // has ever been able to see.
2428
+ const receiverType = instantiatedType(file, reference.scope, reference.receiver) ??
2429
+ parameterAnnotatedType(container, reference.receiver) ??
2430
+ localAnnotatedType(file, reference.scope, reference.receiver);
2179
2431
  if (receiverType === undefined)
2180
2432
  continue;
2181
2433
  // **A mapped column is a declared member, and `fields` cannot see one.**
@@ -2186,7 +2438,14 @@ export function extract(input) {
2186
2438
  // absence would have looked like a language limit rather than an oversight.
2187
2439
  const declaresField = (receiverType.declaration.fields ?? []).some((f) => f.name === reference.name) ||
2188
2440
  (ormShapes.get(receiverType.id)?.fields ?? []).some((f) => f.name === reference.name);
2189
- if (!declaresField)
2441
+ if (!declaresField && !attributeExistenceIsProvable(receiverType))
2442
+ continue;
2443
+ // `BaseModel`'s own members (`model_dump`, `model_fields_set`, ...) are
2444
+ // not a declared field, but they are also not an undeclared one — see
2445
+ // `isPydanticModelMember`. Neither `fields` (a data-field claim) nor
2446
+ // `unresolvedFields` (an absence claim) fits; the read is simply not
2447
+ // evidence either way, so it contributes nothing to this edge.
2448
+ if (!declaresField && isPydanticModelMember(receiverType, reference.name))
2190
2449
  continue;
2191
2450
  const edgeType = isWrite ? "WRITES" : "READS";
2192
2451
  const key = `${container.id}|${receiverType.id}|${edgeType}`;
@@ -2195,12 +2454,24 @@ export function extract(input) {
2195
2454
  to: receiverType,
2196
2455
  type: edgeType,
2197
2456
  fields: new Set(),
2457
+ unresolvedFields: new Set(),
2198
2458
  };
2199
- entry.fields.add(reference.name);
2459
+ if (declaresField)
2460
+ entry.fields.add(reference.name);
2461
+ else
2462
+ entry.unresolvedFields.add(reference.name);
2200
2463
  fieldsByEdge.set(key, entry);
2201
2464
  }
2202
- for (const { from, to, type, fields } of fieldsByEdge.values()) {
2203
- push({ from: from.id, to: to.id, type, attrs: { fields: [...fields].sort() } }, 3);
2465
+ for (const { from, to, type, fields, unresolvedFields } of fieldsByEdge.values()) {
2466
+ push({
2467
+ from: from.id,
2468
+ to: to.id,
2469
+ type,
2470
+ attrs: {
2471
+ ...(fields.size > 0 ? { fields: [...fields].sort() } : {}),
2472
+ ...(unresolvedFields.size > 0 ? { unresolvedFields: [...unresolvedFields].sort() } : {}),
2473
+ },
2474
+ }, 3);
2204
2475
  }
2205
2476
  }
2206
2477
  // --- the repository-level test-discovery audit -----------------------------