@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/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, "INHERITS", root, file, declaration.range.startLine, "outside");
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: "INHERITS" }, 2);
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*. Golden pattern 04
1888
- // asserts this edge alone, and a route-to-handler edge would be a
1889
- // second claim with no golden behind it and no draw measuring it.
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
- if (target === undefined || !dtoShapes.has(target.id))
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. Defaulting to GET matches this file's
2000
- * own precedent for Flask's undecorated `.route()` (`GENERIC_ROUTE_ATTRS`,
2001
- * above). Upgraded when the view is a same-file class-based view whose
2002
- * body itself declares HTTP-verb-named methods — Django's actual
2003
- * dispatch mechanism (`View.dispatch` calls `getattr(self,
2004
- * request.method.lower())`), read from source rather than guessed.
2005
- * Cross-file CBVs and function-based views stay at the GET default —
2006
- * resolving an imported class's own methods is a second import hop this
2007
- * pass does not add.
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
- return ["GET"];
2012
- const declarations = input.parsed.files[route.file]?.declarations ?? [];
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: { method, pathTemplate: normaliseEndpointPath(served), rawTemplate: served, framework },
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
- for (const method of djangoRouteMethods(route)) {
2074
- emitDjangoRoute(route.file, route.line, served, method, level, "django");
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
- emitDjangoRoute(reg.file, reg.line, served, action.method, level, "django");
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 — the ground
2358
- // truth this pass exists to catch is the second shape, which
2359
- // `instantiatedType` alone has never been able to see.
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) ?? {