@descryy/adapter-python 0.2.0 → 0.3.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/LICENSE +8 -0
- package/dist/adapter.d.ts +13 -20
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js +102 -116
- 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 +23 -0
- package/dist/extract.d.ts.map +1 -1
- package/dist/extract.js +372 -31
- package/dist/extract.js.map +1 -1
- package/dist/extract.py +59 -4
- 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 +54 -93
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +78 -135
- package/dist/routes.js.map +1 -1
- package/package.json +15 -6
- package/src/extract.py +59 -4
package/dist/extract.js
CHANGED
|
@@ -30,11 +30,11 @@
|
|
|
30
30
|
* because `a/__init__.py` importing from `b/__init__.py` importing back is legal
|
|
31
31
|
* Python and must not hang the run.
|
|
32
32
|
*/
|
|
33
|
-
import { databaseTableNodesFromModel, edgeId, endpointQsp, isTypeLikeNodeType, nodeId, normaliseEndpointPath, symbolQsp, testCaseQsp, } from "@descryy/ir";
|
|
33
|
+
import { databaseTableNodesFromModel, edgeId, endpointQsp, isCallableNodeType, isTypeLikeNodeType, nodeId, normaliseEndpointPath, symbolQsp, testCaseQsp, } from "@descryy/ir";
|
|
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";
|
|
@@ -94,6 +94,20 @@ const REASONS = {
|
|
|
94
94
|
routeIdCollision: "this route's method and path are identical to one already emitted, so it collapsed onto " +
|
|
95
95
|
"the same API_ROUTE node and this declaration's own SERVES_API edge and location were " +
|
|
96
96
|
"dropped. The endpoint is real; this specific declaration is not the one the graph kept.",
|
|
97
|
+
routeHandlerUnresolved: "this route's handler does not resolve to a callable declaration this run could name. A " +
|
|
98
|
+
"decorator's handler is read straight off the parse tree, not inferred, so a genuine miss " +
|
|
99
|
+
"here is a real gap rather than a guess.",
|
|
100
|
+
routeHandlerCrossFile: "this view does not resolve to a declaration in the same file as its route registration -- " +
|
|
101
|
+
"imported from elsewhere, a name this file does not declare, or (for a class-based view) a " +
|
|
102
|
+
"class whose verb methods live in another file or are inherited rather than written here. " +
|
|
103
|
+
"Naming the exact function that serves this route would need a second import hop, which this " +
|
|
104
|
+
"pass does not add -- the same decline already made for this file's own HTTP-verb dispatch " +
|
|
105
|
+
"read (a cross-file class-based view defaults to GET rather than inspecting an imported " +
|
|
106
|
+
"class's methods). The route is real; which function serves it is unreadable from here.",
|
|
107
|
+
drfActionNotOverridden: "this DRF action comes from the router's own fixed contract (DefaultRouter/SimpleRouter " +
|
|
108
|
+
"always generate it), not from the viewset's own body. Its method is inherited from DRF's " +
|
|
109
|
+
"installed ModelViewSet/mixins -- outside the analysed set entirely -- unless this viewset " +
|
|
110
|
+
"overrides it in source, where it isn't.",
|
|
97
111
|
djangoPathNotLiteral: "a Django path()/re_path()/url() call whose route is written as anything but a literal — " +
|
|
98
112
|
"or, for re_path()/url(), a regex this reader will not translate because it is not just a " +
|
|
99
113
|
"literal segment with named groups. Emitting a route here would attach a caller to a path " +
|
|
@@ -102,6 +116,14 @@ const REASONS = {
|
|
|
102
116
|
"analysed — an installed app, a module outside the analysed set, or a name this reader does " +
|
|
103
117
|
"not evaluate (a computed string, a (module, namespace) tuple). The routes behind it are real " +
|
|
104
118
|
"and unreadable from here.",
|
|
119
|
+
djangoMethodNotDeclared: "a urlconf entry names no HTTP verb, and this run could not read one from source either — " +
|
|
120
|
+
"no @require_http_methods/@require_GET/@require_POST/@api_view decorator on the view, and " +
|
|
121
|
+
"(for a class-based view) no verb-named method readable on the class, whether declared in " +
|
|
122
|
+
"this file or one import hop away. Django dispatches by matching the request's own method " +
|
|
123
|
+
"against the view at runtime; nothing in source states a single verb here. `attrs.method` " +
|
|
124
|
+
"is recorded as GET and `attrs.methodDeclared` is false so that default is never mistaken " +
|
|
125
|
+
"for a declaration: a real POST to this path will never match this route, and an unrelated " +
|
|
126
|
+
"GET caller silently could.",
|
|
105
127
|
odooPathNotLiteral: "an @http.route(...) whose path is written as anything but a literal, so the path it serves " +
|
|
106
128
|
"is not knowable from source.",
|
|
107
129
|
odooMethodsNotLiteral: "an @http.route(...) whose methods= is written but not readable as a list of literals — " +
|
|
@@ -728,6 +750,40 @@ export function extract(input) {
|
|
|
728
750
|
...(attrs === undefined ? {} : { attrs }),
|
|
729
751
|
});
|
|
730
752
|
};
|
|
753
|
+
/**
|
|
754
|
+
* A route's handler, resolved to the callable declaration it names —
|
|
755
|
+
* always in the SAME file as the lookup key, never across an import.
|
|
756
|
+
*
|
|
757
|
+
* Every route-serving pass in this file (FastAPI/Flask decorators, Odoo,
|
|
758
|
+
* Django's per-verb class-based-view dispatch) already resolves a
|
|
759
|
+
* decorator's or `urlpatterns` entry's handler this way, restricted to one
|
|
760
|
+
* file's own `byFullName` map — `djangoRouteMethods`, above, is the
|
|
761
|
+
* precedent: a class-based view whose verb methods live in another file
|
|
762
|
+
* stays at the GET default rather than following the import. The
|
|
763
|
+
* route -> handler `CALLS` edge below stays consistent with that: a name
|
|
764
|
+
* this run cannot bind to a same-file declaration is disclosed, never
|
|
765
|
+
* chased across a module boundary it does not already follow.
|
|
766
|
+
*/
|
|
767
|
+
function resolveSameFileHandler(file, fullName) {
|
|
768
|
+
const found = byFullName.get(file)?.get(fullName);
|
|
769
|
+
return found !== undefined && isCallableNodeType(found.type) ? found : undefined;
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Route -> handler, golden pattern 19. Unlike Express (`adapter-typescript`),
|
|
773
|
+
* which needs a recurrence guard because a handler argument can be shared,
|
|
774
|
+
* route-agnostic terminal middleware, Python's decorator form has no such
|
|
775
|
+
* ambiguity: the function directly beneath `@router.get(...)` unambiguously
|
|
776
|
+
* IS that route's handler, however many other routes decorate it too. No
|
|
777
|
+
* recurrence check is ported here.
|
|
778
|
+
*/
|
|
779
|
+
const emitHandlerCalls = (routeId, handler, rawTarget, file, line, reason, level) => {
|
|
780
|
+
if (handler !== undefined) {
|
|
781
|
+
push({ from: routeId, to: handler.id, type: "CALLS" }, level);
|
|
782
|
+
}
|
|
783
|
+
else {
|
|
784
|
+
disclose({ id: routeId }, "CALLS", rawTarget, file, line, reason);
|
|
785
|
+
}
|
|
786
|
+
};
|
|
731
787
|
// --- pass 1: nodes --------------------------------------------------------
|
|
732
788
|
for (const file of files) {
|
|
733
789
|
const parsed = input.parsed.files[file];
|
|
@@ -1137,6 +1193,49 @@ export function extract(input) {
|
|
|
1137
1193
|
.filter((target) => target !== undefined && isTypeLikeNodeType(target.type));
|
|
1138
1194
|
return candidates.length === 1 ? candidates[0] : undefined;
|
|
1139
1195
|
}
|
|
1196
|
+
const localAnnotationIndex = new Map();
|
|
1197
|
+
const localAnnotationsOf = (file, local) => {
|
|
1198
|
+
let index = localAnnotationIndex.get(file);
|
|
1199
|
+
if (index === undefined) {
|
|
1200
|
+
index = new Map();
|
|
1201
|
+
for (const entry of input.parsed.files[file]?.localAnnotations ?? []) {
|
|
1202
|
+
const bucket = index.get(entry.local);
|
|
1203
|
+
if (bucket === undefined)
|
|
1204
|
+
index.set(entry.local, [entry]);
|
|
1205
|
+
else
|
|
1206
|
+
bucket.push(entry);
|
|
1207
|
+
}
|
|
1208
|
+
localAnnotationIndex.set(file, index);
|
|
1209
|
+
}
|
|
1210
|
+
return index.get(local) ?? [];
|
|
1211
|
+
};
|
|
1212
|
+
/**
|
|
1213
|
+
* A local variable's *own* type annotation — `org: Organization | None =
|
|
1214
|
+
* await get_org_for_user(...)` inside a function body. Neither
|
|
1215
|
+
* `instantiatedType` (a bare constructor call only) nor
|
|
1216
|
+
* `parameterAnnotatedType` (a function parameter only) can see this shape;
|
|
1217
|
+
* it is the actual root cause of UAT round 4's missed defect
|
|
1218
|
+
* (`sherpa-backend`'s `src/apis/reviews.py`: `org` is a local, not a
|
|
1219
|
+
* parameter, and its annotation was never read at all).
|
|
1220
|
+
*
|
|
1221
|
+
* Same scope discipline as `instantiatedType` — innermost enclosing scope
|
|
1222
|
+
* wins over a module-level annotation of the same name — and the same
|
|
1223
|
+
* "exactly one type-like candidate" discipline `parameterAnnotatedType`
|
|
1224
|
+
* already applies: `Optional[X]`, `X | None` and `Union[X, Y]` all still
|
|
1225
|
+
* resolve to nothing where more than one type-like name survives.
|
|
1226
|
+
*/
|
|
1227
|
+
function localAnnotatedType(file, scopePath, local) {
|
|
1228
|
+
const scopeKey = scopePath.join(".");
|
|
1229
|
+
const inScope = localAnnotationsOf(file, local).filter((a) => a.scope.length === 0 || a.scope.join(".") === scopeKey);
|
|
1230
|
+
const chosen = inScope.find((a) => a.scope.length > 0 && a.scope.join(".") === scopeKey) ??
|
|
1231
|
+
inScope.find((a) => a.scope.length === 0);
|
|
1232
|
+
if (chosen === undefined)
|
|
1233
|
+
return undefined;
|
|
1234
|
+
const candidates = typeNamesIn(chosen.annotation)
|
|
1235
|
+
.map((name) => followName(file, name))
|
|
1236
|
+
.filter((target) => target !== undefined && isTypeLikeNodeType(target.type));
|
|
1237
|
+
return candidates.length === 1 ? candidates[0] : undefined;
|
|
1238
|
+
}
|
|
1140
1239
|
/**
|
|
1141
1240
|
* Is "this attribute is not declared" a claim this adapter can actually
|
|
1142
1241
|
* stand behind for this type?
|
|
@@ -1180,6 +1279,28 @@ export function extract(input) {
|
|
|
1180
1279
|
return baseChainIsFieldFree(receiverType.file, leaf);
|
|
1181
1280
|
});
|
|
1182
1281
|
}
|
|
1282
|
+
/**
|
|
1283
|
+
* Is this a read of `BaseModel`'s own surface, reached through a
|
|
1284
|
+
* Pydantic-classified receiver (the same classification
|
|
1285
|
+
* `attributeExistenceIsProvable` uses, not SQLAlchemy's)?
|
|
1286
|
+
*
|
|
1287
|
+
* `baseChainIsFieldFree` treats any unresolvable base as field-free —
|
|
1288
|
+
* correct for SQLAlchemy's `DeclarativeBase` marker, but not for
|
|
1289
|
+
* `BaseModel`, which is equally unresolvable-in-repo yet has a real,
|
|
1290
|
+
* stable member set of its own (`model_dump`, `model_fields_set`, ...).
|
|
1291
|
+
* Rather than changing `baseChainIsFieldFree` itself — which would also
|
|
1292
|
+
* change the SQLAlchemy path, and that path's assumption is correct —
|
|
1293
|
+
* this is checked narrowly at the one call site that turns "not a
|
|
1294
|
+
* declared field" into a reported `unresolvedFields` fact.
|
|
1295
|
+
*/
|
|
1296
|
+
function isPydanticModelMember(receiverType, name) {
|
|
1297
|
+
if (!PYDANTIC_MODEL_MEMBERS.has(name))
|
|
1298
|
+
return false;
|
|
1299
|
+
const orm = ormShapes.get(receiverType.id);
|
|
1300
|
+
if (orm !== undefined)
|
|
1301
|
+
return false; // SQLAlchemy (or another ORM) — untouched, not this path
|
|
1302
|
+
return dtoShapes.has(receiverType.id) && isPydanticClass(receiverType.file, receiverType.declaration.path[0] ?? "");
|
|
1303
|
+
}
|
|
1183
1304
|
/**
|
|
1184
1305
|
* Walks a class's own base chain, and is safe to "see through" only where
|
|
1185
1306
|
* every hop is either external (nothing this run declares — the
|
|
@@ -1214,6 +1335,30 @@ export function extract(input) {
|
|
|
1214
1335
|
return baseChainIsFieldFree(target.file, leaf, seen);
|
|
1215
1336
|
});
|
|
1216
1337
|
}
|
|
1338
|
+
/**
|
|
1339
|
+
* True when `declared`'s own base chain reaches `typing.Protocol` — PEP 544's
|
|
1340
|
+
* explicit, syntactic declaration of a structural interface. Walked rather than
|
|
1341
|
+
* checked only on the immediate base list so a Protocol that widens another
|
|
1342
|
+
* Protocol (`class WiderPort(Renderer, Protocol)`) is still recognised as one;
|
|
1343
|
+
* bounded the same way `baseChainIsFieldFree` bounds its own base-chain walk,
|
|
1344
|
+
* since this walks the same graph and needs the same cycle guard.
|
|
1345
|
+
*/
|
|
1346
|
+
function isProtocolDeclared(declared, seen = new Set()) {
|
|
1347
|
+
const key = `${declared.file}:${declared.id}`;
|
|
1348
|
+
if (seen.has(key) || seen.size > 32)
|
|
1349
|
+
return false;
|
|
1350
|
+
seen.add(key);
|
|
1351
|
+
const bases = declared.declaration.bases ?? [];
|
|
1352
|
+
return bases.some((base) => {
|
|
1353
|
+
if (base === null)
|
|
1354
|
+
return false;
|
|
1355
|
+
const leaf = base.split("[")[0].split(".").pop();
|
|
1356
|
+
if (leaf === "Protocol")
|
|
1357
|
+
return true;
|
|
1358
|
+
const next = followName(declared.file, leaf);
|
|
1359
|
+
return next !== undefined && isProtocolDeclared(next, seen);
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1217
1362
|
for (const file of files) {
|
|
1218
1363
|
const parsed = input.parsed.files[file];
|
|
1219
1364
|
const byFull = byFullName.get(file);
|
|
@@ -1237,12 +1382,22 @@ export function extract(input) {
|
|
|
1237
1382
|
continue;
|
|
1238
1383
|
const root = base.split("[")[0].split(".").pop();
|
|
1239
1384
|
const target = followName(file, root);
|
|
1385
|
+
// B5 (13.2.3's other half): Python has no `implements` keyword, so
|
|
1386
|
+
// `adapter-typescript`'s language-level implements/extends split
|
|
1387
|
+
// (extract.ts:1279) had no analogue here and every base fell to
|
|
1388
|
+
// INHERITS. `typing.Protocol` is Python's own explicit, syntactic
|
|
1389
|
+
// declaration of a structural interface (PEP 544) — a base that is a
|
|
1390
|
+
// Protocol, directly or through another Protocol, is declared as an
|
|
1391
|
+
// interface, not a concrete implementation to inherit. Matched to
|
|
1392
|
+
// what the source declares, never inferred from behaviour (rule 1's
|
|
1393
|
+
// "if the framework will tell you, never infer it").
|
|
1394
|
+
const edgeType = root === "Protocol" || (target !== undefined && isProtocolDeclared(target)) ? "IMPLEMENTS" : "INHERITS";
|
|
1240
1395
|
if (target === undefined) {
|
|
1241
|
-
disclose(self,
|
|
1396
|
+
disclose(self, edgeType, root, file, declaration.range.startLine, "outside");
|
|
1242
1397
|
continue;
|
|
1243
1398
|
}
|
|
1244
1399
|
if (target.id !== self.id)
|
|
1245
|
-
push({ from: self.id, to: target.id, type:
|
|
1400
|
+
push({ from: self.id, to: target.id, type: edgeType }, 2);
|
|
1246
1401
|
}
|
|
1247
1402
|
}
|
|
1248
1403
|
// --- calls, method calls, and the tests that make them ------------------
|
|
@@ -1821,8 +1976,10 @@ export function extract(input) {
|
|
|
1821
1976
|
// exactly this, and two producers of one route must mint the same
|
|
1822
1977
|
// endpoint id or the join this whole lane exists for does not happen.
|
|
1823
1978
|
const routeId = nodeId(scope, "API_ROUTE", `${method} ${served}`, LANGUAGE);
|
|
1979
|
+
let isNewRoute = false;
|
|
1824
1980
|
if (!routeSeen.has(routeId)) {
|
|
1825
1981
|
routeSeen.add(routeId);
|
|
1982
|
+
isNewRoute = true;
|
|
1826
1983
|
nodes.push({
|
|
1827
1984
|
id: routeId,
|
|
1828
1985
|
type: "API_ROUTE",
|
|
@@ -1856,6 +2013,18 @@ export function extract(input) {
|
|
|
1856
2013
|
// one declaration exists".
|
|
1857
2014
|
disclose(from, "SERVES_API", `${method} ${served}`, route.file, route.line, "routeIdCollision");
|
|
1858
2015
|
}
|
|
2016
|
+
// Route -> handler, golden pattern 19. Only for the declaration that
|
|
2017
|
+
// actually won the node above — the second of two declarations
|
|
2018
|
+
// colliding on one method+path already lost its SERVES_API edge and
|
|
2019
|
+
// location just above, and letting its handler through here would
|
|
2020
|
+
// attach an unrelated function to a route it does not own.
|
|
2021
|
+
if (isNewRoute) {
|
|
2022
|
+
const handler = route.handler === null ? undefined : resolveSameFileHandler(route.file, route.handler.join("."));
|
|
2023
|
+
emitHandlerCalls(routeId, handler, route.handler === null ? `${method} ${served}` : route.handler.join("."), route.file, route.line, "routeHandlerUnresolved", 0);
|
|
2024
|
+
}
|
|
2025
|
+
else {
|
|
2026
|
+
disclose({ id: routeId }, "CALLS", `${method} ${served}`, route.file, route.line, "routeIdCollision");
|
|
2027
|
+
}
|
|
1859
2028
|
// **Nothing served, nothing to join.** An unmounted router's route has
|
|
1860
2029
|
// no real served path — `served` above is only what it *would* be at
|
|
1861
2030
|
// the application root, and minting a workspace-scoped `API_ENDPOINT`
|
|
@@ -1884,9 +2053,12 @@ export function extract(input) {
|
|
|
1884
2053
|
attrs: { method, pathTemplate: normaliseEndpointPath(served) },
|
|
1885
2054
|
});
|
|
1886
2055
|
}
|
|
1887
|
-
// `SERVES_API` and nothing else *to the endpoint
|
|
1888
|
-
//
|
|
1889
|
-
//
|
|
2056
|
+
// `SERVES_API` and nothing else *to the endpoint*, per golden
|
|
2057
|
+
// pattern 04. The route -> handler half is a separate edge to a
|
|
2058
|
+
// separate target (the FUNCTION, not the fileless API_ENDPOINT) —
|
|
2059
|
+
// golden pattern 19's own CALLS assertion, emitted above regardless
|
|
2060
|
+
// of `mounted`: which function a decorator sits on is a fact about
|
|
2061
|
+
// the declaration, not about whether anything ends up serving it.
|
|
1890
2062
|
push({ from: routeId, to: endpointId, type: "SERVES_API" }, level);
|
|
1891
2063
|
}
|
|
1892
2064
|
// The route's contract: which validated shapes it names. `USES_TYPE`
|
|
@@ -1910,7 +2082,19 @@ export function extract(input) {
|
|
|
1910
2082
|
const { request, response } = routeContractNames(route);
|
|
1911
2083
|
for (const name of new Set([...request, ...response])) {
|
|
1912
2084
|
const target = followName(route.file, name);
|
|
1913
|
-
|
|
2085
|
+
// B8: distinguished from the silent case just below. A name this run
|
|
2086
|
+
// cannot resolve at all (an external library type, or a name outside
|
|
2087
|
+
// the analysed file set) is a genuine resolution gap and worth
|
|
2088
|
+
// measuring — this is the "unresolved name" half of the three-way
|
|
2089
|
+
// breakdown 13.3.18 asked for. A name that DOES resolve but is not a
|
|
2090
|
+
// validated shape stays silent: that route parameter was never
|
|
2091
|
+
// expected to be a DTO, and disclosing it would manufacture noise
|
|
2092
|
+
// about a relationship nothing claimed existed.
|
|
2093
|
+
if (target === undefined) {
|
|
2094
|
+
disclose({ id: routeId }, "USES_TYPE", name, route.file, route.line, "outside");
|
|
2095
|
+
continue;
|
|
2096
|
+
}
|
|
2097
|
+
if (!dtoShapes.has(target.id))
|
|
1914
2098
|
continue;
|
|
1915
2099
|
const roles = [];
|
|
1916
2100
|
if (request.has(name))
|
|
@@ -1994,35 +2178,140 @@ export function extract(input) {
|
|
|
1994
2178
|
djangoPrefixCache.set(startFile, result);
|
|
1995
2179
|
return result;
|
|
1996
2180
|
};
|
|
2181
|
+
/**
|
|
2182
|
+
* A method restriction Django's own decorators state outright —
|
|
2183
|
+
* `require_GET`/`require_POST` (bare, no call) and
|
|
2184
|
+
* `require_http_methods([...])`/DRF's `api_view([...])` (a call whose
|
|
2185
|
+
* one positional argument is a list of verbs). A real declaration, read
|
|
2186
|
+
* from the decorator's own arguments — never inferred from the view's
|
|
2187
|
+
* name or body. `null` means no such decorator sits on this declaration,
|
|
2188
|
+
* not "GET" — the two are different facts and the caller must not
|
|
2189
|
+
* collapse them.
|
|
2190
|
+
*/
|
|
2191
|
+
function declaredHttpMethods(declaration) {
|
|
2192
|
+
for (const decorator of declaration.decorators ?? []) {
|
|
2193
|
+
const leaf = (decorator ?? "").split(".").pop();
|
|
2194
|
+
if (leaf === "require_GET")
|
|
2195
|
+
return ["GET"];
|
|
2196
|
+
if (leaf === "require_POST")
|
|
2197
|
+
return ["POST"];
|
|
2198
|
+
}
|
|
2199
|
+
for (const call of declaration.decoratorCalls ?? []) {
|
|
2200
|
+
const leaf = (call.callee ?? "").split(".").pop();
|
|
2201
|
+
if (leaf !== "require_http_methods" && leaf !== "api_view")
|
|
2202
|
+
continue;
|
|
2203
|
+
const list = call.argLists?.[0];
|
|
2204
|
+
if (list === undefined || list === null)
|
|
2205
|
+
continue;
|
|
2206
|
+
const methods = list
|
|
2207
|
+
.filter((m) => typeof m === "string")
|
|
2208
|
+
.map((m) => m.toUpperCase());
|
|
2209
|
+
if (methods.length > 0)
|
|
2210
|
+
return methods;
|
|
2211
|
+
}
|
|
2212
|
+
return null;
|
|
2213
|
+
}
|
|
2214
|
+
/**
|
|
2215
|
+
* The declaration a Django view reference names, following the same
|
|
2216
|
+
* one-hop import chain every cross-file lookup in this pass already
|
|
2217
|
+
* walks (`followName`, used above for a route's own DTO names) — not a
|
|
2218
|
+
* new resolution mechanism. `text` is the view argument's source text:
|
|
2219
|
+
* a bare name (`my_view`) resolves directly; `module.attr` (`views.my_view`)
|
|
2220
|
+
* takes one further hop through the module's own binding, since
|
|
2221
|
+
* `followName`/`bindingsOf` are keyed by the imported local name and a
|
|
2222
|
+
* dotted attribute access never appears as one.
|
|
2223
|
+
*/
|
|
2224
|
+
function resolveViewDeclaration(file, text) {
|
|
2225
|
+
const direct = followName(file, text);
|
|
2226
|
+
if (direct !== undefined)
|
|
2227
|
+
return direct;
|
|
2228
|
+
const dot = text.lastIndexOf(".");
|
|
2229
|
+
if (dot === -1)
|
|
2230
|
+
return undefined;
|
|
2231
|
+
const modulePart = text.slice(0, dot);
|
|
2232
|
+
const leaf = text.slice(dot + 1);
|
|
2233
|
+
if (modulePart.includes("."))
|
|
2234
|
+
return undefined; // one hop, matching classFileOf's own precedent
|
|
2235
|
+
const binding = bindingsOf.get(file)?.get(modulePart);
|
|
2236
|
+
if (binding === undefined)
|
|
2237
|
+
return undefined;
|
|
2238
|
+
return declaredIn.get(binding.file)?.get(leaf);
|
|
2239
|
+
}
|
|
1997
2240
|
/**
|
|
1998
2241
|
* A urlconf entry names no verb — Django dispatches by looking at the
|
|
1999
|
-
* request, not the registration.
|
|
2000
|
-
*
|
|
2001
|
-
*
|
|
2002
|
-
*
|
|
2003
|
-
*
|
|
2004
|
-
*
|
|
2005
|
-
*
|
|
2006
|
-
*
|
|
2007
|
-
*
|
|
2242
|
+
* request, not the registration. `declared: false` is what carries that
|
|
2243
|
+
* fact forward; `["GET"]` alone would look identical to a genuinely
|
|
2244
|
+
* declared one.
|
|
2245
|
+
*
|
|
2246
|
+
* Three real declarations, in the order checked:
|
|
2247
|
+
*
|
|
2248
|
+
* 1. A method-restricting decorator on the view function itself —
|
|
2249
|
+
* `require_GET`/`require_POST`/`require_http_methods`/`api_view`.
|
|
2250
|
+
* 2. A class-based view's own verb-named methods — Django's actual
|
|
2251
|
+
* dispatch mechanism (`View.dispatch` calls `getattr(self,
|
|
2252
|
+
* request.method.lower())`), read from source rather than guessed.
|
|
2253
|
+
* Same file first; `classFileOf` — the chain every ORM/DTO/test-suite
|
|
2254
|
+
* base lookup in this file already walks — follows one import hop
|
|
2255
|
+
* (and, same as those callers, further re-export hops up to its own
|
|
2256
|
+
* depth cap) when the class is registered from a different module
|
|
2257
|
+
* than it is defined in.
|
|
2258
|
+
* 3. Neither: `declared: false`, GET recorded as the default it is.
|
|
2008
2259
|
*/
|
|
2009
2260
|
const djangoRouteMethods = (route) => {
|
|
2010
|
-
if (route.viewClassLocal === null)
|
|
2011
|
-
|
|
2012
|
-
|
|
2261
|
+
if (route.viewClassLocal === null) {
|
|
2262
|
+
const target = resolveViewDeclaration(route.file, route.viewText);
|
|
2263
|
+
const decorated = target === undefined ? null : declaredHttpMethods(target.declaration);
|
|
2264
|
+
return decorated !== null
|
|
2265
|
+
? { methods: decorated, declared: true }
|
|
2266
|
+
: { methods: ["GET"], declared: false };
|
|
2267
|
+
}
|
|
2268
|
+
const owner = classFileOf(route.file, route.viewClassLocal);
|
|
2269
|
+
const declarations = owner === undefined ? [] : (input.parsed.files[owner]?.declarations ?? []);
|
|
2013
2270
|
const found = declarations
|
|
2014
2271
|
.filter((d) => d.kind === "function" &&
|
|
2015
2272
|
d.path.length === 2 &&
|
|
2016
2273
|
d.path[0] === route.viewClassLocal &&
|
|
2017
2274
|
HTTP_METHODS.has(d.path[1]))
|
|
2018
2275
|
.map((d) => d.path[1].toUpperCase());
|
|
2019
|
-
return found.length > 0 ? found : ["GET"];
|
|
2276
|
+
return found.length > 0 ? { methods: found, declared: true } : { methods: ["GET"], declared: false };
|
|
2277
|
+
};
|
|
2278
|
+
/**
|
|
2279
|
+
* The specific function this verb's route calls — using exactly the same
|
|
2280
|
+
* resolution reach `djangoRouteMethods` uses to find the verb in the
|
|
2281
|
+
* first place, so the two stay consistent by construction rather than by
|
|
2282
|
+
* a second check: a class-based view resolves through `classFileOf`
|
|
2283
|
+
* (same one-hop-across-an-import allowance `djangoRouteMethods` applies
|
|
2284
|
+
* when reading its verb methods), and a function-based view resolves
|
|
2285
|
+
* through `resolveViewDeclaration` (the same one used to read a bare or
|
|
2286
|
+
* `module.attr` view's decorators). Either can still fail — a target
|
|
2287
|
+
* this run cannot bind to a real declaration, or one that resolves to
|
|
2288
|
+
* something not callable — and that failure is disclosed, never guessed.
|
|
2289
|
+
*/
|
|
2290
|
+
const djangoRouteHandler = (route, method) => {
|
|
2291
|
+
if (route.viewClassLocal !== null) {
|
|
2292
|
+
const owner = classFileOf(route.file, route.viewClassLocal);
|
|
2293
|
+
const fullName = `${route.viewClassLocal}.${method.toLowerCase()}`;
|
|
2294
|
+
return {
|
|
2295
|
+
declared: owner === undefined ? undefined : resolveSameFileHandler(owner, fullName),
|
|
2296
|
+
rawTarget: fullName,
|
|
2297
|
+
reason: "routeHandlerCrossFile",
|
|
2298
|
+
};
|
|
2299
|
+
}
|
|
2300
|
+
const rawTarget = route.viewText.trim();
|
|
2301
|
+
const target = resolveViewDeclaration(route.file, rawTarget);
|
|
2302
|
+
return {
|
|
2303
|
+
declared: target !== undefined && isCallableNodeType(target.type) ? target : undefined,
|
|
2304
|
+
rawTarget,
|
|
2305
|
+
reason: "routeHandlerCrossFile",
|
|
2306
|
+
};
|
|
2020
2307
|
};
|
|
2021
|
-
const emitDjangoRoute = (file, line, served, method, level, framework) => {
|
|
2308
|
+
const emitDjangoRoute = (file, line, served, method, methodDeclared, level, framework, handler) => {
|
|
2022
2309
|
const from = { id: moduleNodeId.get(file) };
|
|
2023
2310
|
const routeId = nodeId(scope, "API_ROUTE", `${method} ${served}`, LANGUAGE);
|
|
2311
|
+
let isNewRoute = false;
|
|
2024
2312
|
if (!routeSeen.has(routeId)) {
|
|
2025
2313
|
routeSeen.add(routeId);
|
|
2314
|
+
isNewRoute = true;
|
|
2026
2315
|
nodes.push({
|
|
2027
2316
|
id: routeId,
|
|
2028
2317
|
type: "API_ROUTE",
|
|
@@ -2032,12 +2321,32 @@ export function extract(input) {
|
|
|
2032
2321
|
language: LANGUAGE,
|
|
2033
2322
|
producedBy: input.producedBy,
|
|
2034
2323
|
resolution: level,
|
|
2035
|
-
attrs: {
|
|
2324
|
+
attrs: {
|
|
2325
|
+
method,
|
|
2326
|
+
pathTemplate: normaliseEndpointPath(served),
|
|
2327
|
+
rawTemplate: served,
|
|
2328
|
+
framework,
|
|
2329
|
+
methodDeclared,
|
|
2330
|
+
},
|
|
2036
2331
|
});
|
|
2332
|
+
if (!methodDeclared) {
|
|
2333
|
+
disclose(from, "SERVES_API", `${method} ${served}`, file, line, "djangoMethodNotDeclared");
|
|
2334
|
+
}
|
|
2037
2335
|
}
|
|
2038
2336
|
else {
|
|
2039
2337
|
disclose(from, "SERVES_API", `${method} ${served}`, file, line, "routeIdCollision");
|
|
2040
2338
|
}
|
|
2339
|
+
if (handler !== undefined) {
|
|
2340
|
+
if (isNewRoute) {
|
|
2341
|
+
emitHandlerCalls(routeId, handler.declared, handler.rawTarget, file, line, handler.reason, 0);
|
|
2342
|
+
}
|
|
2343
|
+
else {
|
|
2344
|
+
// Consistent with the SERVES_API drop just above: the second of two
|
|
2345
|
+
// declarations colliding on one method+path does not get to claim a
|
|
2346
|
+
// handler either.
|
|
2347
|
+
disclose({ id: routeId }, "CALLS", `${method} ${served}`, file, line, "routeIdCollision");
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2041
2350
|
const endpointId = nodeId(scope, "API_ENDPOINT", endpointQsp(method, served), null);
|
|
2042
2351
|
if (!routeSeen.has(endpointId)) {
|
|
2043
2352
|
routeSeen.add(endpointId);
|
|
@@ -2070,8 +2379,9 @@ export function extract(input) {
|
|
|
2070
2379
|
if (level > input.reached)
|
|
2071
2380
|
continue;
|
|
2072
2381
|
const served = joinPath(chain.prefix, route.path);
|
|
2073
|
-
|
|
2074
|
-
|
|
2382
|
+
const { methods, declared } = djangoRouteMethods(route);
|
|
2383
|
+
for (const method of methods) {
|
|
2384
|
+
emitDjangoRoute(route.file, route.line, served, method, declared, level, "django", djangoRouteHandler(route, method));
|
|
2075
2385
|
}
|
|
2076
2386
|
}
|
|
2077
2387
|
// --- DRF: router.register(prefix, ViewSetClass) — the fixed action table
|
|
@@ -2098,7 +2408,18 @@ export function extract(input) {
|
|
|
2098
2408
|
const basePath = joinPath(chain.prefix, reg.prefix);
|
|
2099
2409
|
for (const action of DRF_ACTIONS) {
|
|
2100
2410
|
const served = joinPath(basePath, action.suffix);
|
|
2101
|
-
|
|
2411
|
+
// The router's own fixed action table, not a guess — every one of
|
|
2412
|
+
// these methods is genuinely declared by registering the viewset.
|
|
2413
|
+
// Handler resolution is same-file only: `action.action` is DRF's own
|
|
2414
|
+
// dispatch method name, and resolving it finds only the (uncommon)
|
|
2415
|
+
// case where this viewset overrides it in source rather than
|
|
2416
|
+
// inheriting it from DRF's own installed mixins.
|
|
2417
|
+
const fullName = `${reg.viewSetText.trim()}.${action.action}`;
|
|
2418
|
+
emitDjangoRoute(reg.file, reg.line, served, action.method, true, level, "django", {
|
|
2419
|
+
declared: resolveSameFileHandler(reg.file, fullName),
|
|
2420
|
+
rawTarget: fullName,
|
|
2421
|
+
reason: "drfActionNotOverridden",
|
|
2422
|
+
});
|
|
2102
2423
|
}
|
|
2103
2424
|
}
|
|
2104
2425
|
// --- Odoo: @http.route(...) — path already absolute, nothing to resolve -
|
|
@@ -2116,8 +2437,10 @@ export function extract(input) {
|
|
|
2116
2437
|
const served = normaliseEndpointPath(route.path).startsWith("/") ? route.path : `/${route.path}`;
|
|
2117
2438
|
for (const method of route.methods) {
|
|
2118
2439
|
const routeId = nodeId(scope, "API_ROUTE", `${method} ${served}`, LANGUAGE);
|
|
2440
|
+
let isNewRoute = false;
|
|
2119
2441
|
if (!routeSeen.has(routeId)) {
|
|
2120
2442
|
routeSeen.add(routeId);
|
|
2443
|
+
isNewRoute = true;
|
|
2121
2444
|
nodes.push({
|
|
2122
2445
|
id: routeId,
|
|
2123
2446
|
type: "API_ROUTE",
|
|
@@ -2133,6 +2456,13 @@ export function extract(input) {
|
|
|
2133
2456
|
else {
|
|
2134
2457
|
disclose(from, "SERVES_API", `${method} ${served}`, route.file, route.line, "routeIdCollision");
|
|
2135
2458
|
}
|
|
2459
|
+
if (isNewRoute) {
|
|
2460
|
+
const handler = route.handler === null ? undefined : resolveSameFileHandler(route.file, route.handler.join("."));
|
|
2461
|
+
emitHandlerCalls(routeId, handler, route.handler === null ? `${method} ${served}` : route.handler.join("."), route.file, route.line, "routeHandlerUnresolved", 0);
|
|
2462
|
+
}
|
|
2463
|
+
else {
|
|
2464
|
+
disclose({ id: routeId }, "CALLS", `${method} ${served}`, route.file, route.line, "routeIdCollision");
|
|
2465
|
+
}
|
|
2136
2466
|
const endpointId = nodeId(scope, "API_ENDPOINT", endpointQsp(method, served), null);
|
|
2137
2467
|
if (!routeSeen.has(endpointId)) {
|
|
2138
2468
|
routeSeen.add(endpointId);
|
|
@@ -2244,7 +2574,7 @@ export function extract(input) {
|
|
|
2244
2574
|
const level = (field.file === schema.file ? 0 : 1);
|
|
2245
2575
|
if (level > input.reached)
|
|
2246
2576
|
continue;
|
|
2247
|
-
emitDjangoRoute(field.file, field.line, operationPath(slot, field.name), GRAPHQL_METHOD, level, "strawberry");
|
|
2577
|
+
emitDjangoRoute(field.file, field.line, operationPath(slot, field.name), GRAPHQL_METHOD, true, level, "strawberry");
|
|
2248
2578
|
}
|
|
2249
2579
|
}
|
|
2250
2580
|
}
|
|
@@ -2354,11 +2684,15 @@ export function extract(input) {
|
|
|
2354
2684
|
if (container === undefined || container.type !== "FUNCTION")
|
|
2355
2685
|
continue;
|
|
2356
2686
|
// A local instantiation (`org = Organization()`) first, a parameter's
|
|
2357
|
-
// own annotation (`def get(org: Organization)`) second
|
|
2358
|
-
//
|
|
2359
|
-
// `
|
|
2687
|
+
// own annotation (`def get(org: Organization)`) second, a local
|
|
2688
|
+
// variable's own annotation (`org: Organization | None = await
|
|
2689
|
+
// get_org_for_user(...)`) third — the ground truth this pass exists to
|
|
2690
|
+
// catch is the third shape (`sherpa-backend`'s `src/apis/reviews.py`),
|
|
2691
|
+
// which neither `instantiatedType` nor `parameterAnnotatedType` alone
|
|
2692
|
+
// has ever been able to see.
|
|
2360
2693
|
const receiverType = instantiatedType(file, reference.scope, reference.receiver) ??
|
|
2361
|
-
parameterAnnotatedType(container, reference.receiver)
|
|
2694
|
+
parameterAnnotatedType(container, reference.receiver) ??
|
|
2695
|
+
localAnnotatedType(file, reference.scope, reference.receiver);
|
|
2362
2696
|
if (receiverType === undefined)
|
|
2363
2697
|
continue;
|
|
2364
2698
|
// **A mapped column is a declared member, and `fields` cannot see one.**
|
|
@@ -2371,6 +2705,13 @@ export function extract(input) {
|
|
|
2371
2705
|
(ormShapes.get(receiverType.id)?.fields ?? []).some((f) => f.name === reference.name);
|
|
2372
2706
|
if (!declaresField && !attributeExistenceIsProvable(receiverType))
|
|
2373
2707
|
continue;
|
|
2708
|
+
// `BaseModel`'s own members (`model_dump`, `model_fields_set`, ...) are
|
|
2709
|
+
// not a declared field, but they are also not an undeclared one — see
|
|
2710
|
+
// `isPydanticModelMember`. Neither `fields` (a data-field claim) nor
|
|
2711
|
+
// `unresolvedFields` (an absence claim) fits; the read is simply not
|
|
2712
|
+
// evidence either way, so it contributes nothing to this edge.
|
|
2713
|
+
if (!declaresField && isPydanticModelMember(receiverType, reference.name))
|
|
2714
|
+
continue;
|
|
2374
2715
|
const edgeType = isWrite ? "WRITES" : "READS";
|
|
2375
2716
|
const key = `${container.id}|${receiverType.id}|${edgeType}`;
|
|
2376
2717
|
const entry = fieldsByEdge.get(key) ?? {
|