@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/LICENSE +8 -0
- package/dist/adapter.d.ts +13 -20
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js +91 -114
- package/dist/adapter.js.map +1 -1
- package/dist/client.d.ts +22 -41
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +35 -62
- package/dist/client.js.map +1 -1
- package/dist/extract.d.ts +31 -0
- package/dist/extract.d.ts.map +1 -1
- package/dist/extract.js +319 -48
- package/dist/extract.js.map +1 -1
- package/dist/extract.py +102 -0
- package/dist/graphql.d.ts +57 -123
- package/dist/graphql.d.ts.map +1 -1
- package/dist/graphql.js +62 -130
- package/dist/graphql.js.map +1 -1
- package/dist/module.d.ts +23 -42
- package/dist/module.d.ts.map +1 -1
- package/dist/module.js +26 -47
- package/dist/module.js.map +1 -1
- package/dist/orm.d.ts +31 -46
- package/dist/orm.d.ts.map +1 -1
- package/dist/orm.js +53 -89
- package/dist/orm.js.map +1 -1
- package/dist/pydantic.d.ts +76 -91
- package/dist/pydantic.d.ts.map +1 -1
- package/dist/pydantic.js +106 -99
- package/dist/pydantic.js.map +1 -1
- package/dist/routes.d.ts +46 -93
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +65 -129
- package/dist/routes.js.map +1 -1
- package/package.json +14 -5
- package/src/extract.py +102 -0
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
|
|
89
|
-
"
|
|
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
|
|
1609
|
-
*
|
|
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
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
const
|
|
1626
|
-
for (const annotation of
|
|
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
|
-
|
|
1821
|
+
response.add(name);
|
|
1629
1822
|
}
|
|
1630
|
-
return
|
|
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
|
|
1662
|
-
//
|
|
1663
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
1711
|
-
//
|
|
1712
|
-
//
|
|
1713
|
-
// would
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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 -----------------------------
|