@descryy/adapter-python 0.4.1 → 0.5.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,7 +30,7 @@
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, isCallableNodeType, isTypeLikeNodeType, nodeId, normaliseEndpointPath, symbolQsp, testCaseQsp, } from "@descryy/ir";
33
+ import { databaseTableNodesFromModel, edgeId, endpointQsp, externalQsp, isCallableNodeType, isTypeLikeNodeType, nodeId, normaliseEndpointPath, routeQsp, symbolQsp, testCaseQsp, } from "@descryy/ir";
34
34
  import { importRoots, modulePathOf, moduleNameOf, resolveImport, } from "./module.js";
35
35
  import { attributeExternal, externalImportTable, ownNamespaces, STDLIB_BASIS, } from "./external.js";
36
36
  import { DECLARATIVE_BASE_FACTORIES, ORM_BASE_MODULES, isFrameworkBaseName, ormShapeOf, } from "./orm.js";
@@ -39,7 +39,16 @@ import { drfShapeOf, dtoShapeOf, isDrfSerializerRoot, isPydanticRoot, isValidato
39
39
  import { DRF_ACTIONS, drfRegisterCallsIn, drfRouterLocalsIn, djangoUrlEntriesIn, HTTP_METHODS, isApplicationRouter, joinPath, mountsIn, odooRouteFromDecorator, routeFromDecorator, routersIn, } from "./routes.js";
40
40
  import { GRAPHQL_METHOD, fieldFromMethod, fieldsFromAttributes, operationPath, schemaDeclsIn, } from "./graphql.js";
41
41
  import { clientCallsIn, clientLocalsIn } from "./client.js";
42
+ import { modelParsing, validateCalls } from "./response-shape.js";
42
43
  export const LANGUAGE = "python";
44
+ /** Which packaging world an external module name belongs to. The name is the
45
+ * ecosystem's, not an installed path's: `pyyaml` installs `yaml`, and `yaml`
46
+ * is what the source writes and what this node is identified by. */
47
+ const EXTERNAL_ECOSYSTEM = "pypi";
48
+ /** An external node is attributed from the file's own `import`/`from`
49
+ * statements, which is module resolution and nothing more. R1 exactly: never
50
+ * R0 (the import *is* the resolution), never R4 (nothing observed it run). */
51
+ const EXTERNAL_RESOLUTION = 1;
43
52
  /**
44
53
  * Confidence by the level an edge's evidence earned — never by the level the
45
54
  * run reached. DEC-058: those were two different notions in the first adapter,
@@ -79,10 +88,6 @@ const REASONS = {
79
88
  callBaseUnresolved: "an HTTP call written as f\"{BASE}/path\" whose base this file does not bind to a string " +
80
89
  "literal. The path half is readable and the base half is not, so the served template cannot " +
81
90
  "be assembled — the named base is the piece of work that would close it.",
82
- callBaseIsExternalHost: "an HTTP call written as f\"{BASE}/path\" whose base resolves to an absolute URL on another " +
83
- "host. Read correctly and out of scope for the join: an endpoint is identified by method and " +
84
- "path with no host (DEC-014), so claiming one here would let this caller join a local route " +
85
- "of the same path that it never reaches.",
86
91
  callPathFromLocal: "an HTTP call whose path is a local assembled earlier in the function. Distinguished from an " +
87
92
  "opaque path because it is reachable by further work, and filing the two together would " +
88
93
  "report one number for two populations.",
@@ -354,6 +359,24 @@ export function extract(input) {
354
359
  const edges = [];
355
360
  const unresolved = [];
356
361
  const seenEdge = new Set();
362
+ /**
363
+ * DEC-164's `attrs.clientBase`, per caller node id. First writer wins, so a
364
+ * function making two calls is described by the first one read rather than
365
+ * by whichever happened to be walked last.
366
+ *
367
+ * **Declared HERE, beside `nodes`, and the reason is a bug this already
368
+ * caused.** It sat next to the R2 guard, which reads as the right place —
369
+ * the client half is R2 work. But the client half runs inside `emitRoutes`,
370
+ * and `emitRoutes` is *called* above that line, so every run threw
371
+ * `ReferenceError: Cannot access 'clientBases' before initialization`,
372
+ * `extractOrDegrade` turned the throw into a degraded one-node-per-file
373
+ * graph, and the corpus reported all nineteen patterns as role-unbound
374
+ * failures rather than as a crash. A temporal-dead-zone error inside a
375
+ * catch-all degrade path looks exactly like an adapter that read nothing.
376
+ * The map stays empty below R2 because nothing writes to it there, which is
377
+ * the honest version of what the old placement was trying to say.
378
+ */
379
+ const clientBases = new Map();
357
380
  /** file -> declaration name (first segment) -> Declared */
358
381
  const declaredIn = new Map();
359
382
  /** node id -> the entry owning it, where it sits in `nodes`, and how many
@@ -430,19 +453,26 @@ export function extract(input) {
430
453
  }));
431
454
  assignedOf.set(file, new Set(parsed.assignedNames ?? []));
432
455
  }
456
+ /** Node ids already minted for a dependency symbol — one node per symbol, not
457
+ * per call site. Measured node growth is +3.1% (plane) to +4.5% (dispatch). */
458
+ const externalIds = new Map();
433
459
  /**
434
- * The module and symbol behind a call that left the repository, as ledger
435
- * `attrs`, or `undefined` when no binding the developer wrote settles it.
460
+ * The id of the `FUNCTION` node standing for the symbol behind a call that
461
+ * left the repository, or `undefined` when no binding the developer wrote
462
+ * settles it — or when this run did not reach the resolution such a node
463
+ * requires.
436
464
  *
437
- * **This does not mint a node or an edge.** `@descryy/ir` gained the
438
- * discriminator that lets a `FUNCTION` stand for a symbol in a dependency
439
- * only at 0.7.0; this package is pinned to the release before it, and a node
440
- * emitted against that release is rejected with every edge to it — losing the
441
- * ledger row as well, which is strictly worse than the boundary being
442
- * unnamed. The attribution is computed at the site that files the row so that
443
- * minting is the only step left.
465
+ * Three settled rules govern the node
466
+ * (`DEC-NEXT-external-symbol-nodes-at-the-dependency-boundary`): the qsp
467
+ * carries an `ext:` marker (`externalQsp`), the node may claim R1–R3 but
468
+ * never R0 or R4, and it owns no outgoing edge, because nothing read its
469
+ * body. `thirdParty` rides in `attrs`, never in the hash: a module that moved
470
+ * between the standard library and an installed package would otherwise
471
+ * change identity with no line of source changing.
444
472
  */
445
- const externalAttrs = (file, receiver, name) => {
473
+ const mintExternal = (file, receiver, name) => {
474
+ if (input.reached < EXTERNAL_RESOLUTION)
475
+ return undefined;
446
476
  const attribution = attributeExternal({
447
477
  receiver,
448
478
  name,
@@ -451,18 +481,49 @@ export function extract(input) {
451
481
  });
452
482
  if (attribution === null)
453
483
  return undefined;
454
- return {
455
- external: {
456
- // Identity, and it is the module path the source imported — never a
457
- // site-packages directory, never a distribution name, never a version.
458
- moduleOrNamespace: attribution.moduleOrNamespace,
459
- symbol: attribution.symbolPath.join("."),
484
+ // Identity, and it is the module path the source imported — never a
485
+ // site-packages directory, never a distribution name, never a version.
486
+ const qsp = externalQsp(attribution.moduleOrNamespace, attribution.symbolPath);
487
+ const existing = externalIds.get(qsp);
488
+ if (existing !== undefined)
489
+ return existing;
490
+ const id = nodeId(scope, "FUNCTION", qsp, LANGUAGE);
491
+ externalIds.set(qsp, id);
492
+ nodes.push({
493
+ id,
494
+ type: "FUNCTION",
495
+ name: attribution.symbolPath[attribution.symbolPath.length - 1] ?? "",
496
+ // No file and no range: the symbol lives in a dependency, and a node that
497
+ // named a file here would be invalidated by a source file it has nothing
498
+ // to do with.
499
+ file: null,
500
+ range: null,
501
+ language: LANGUAGE,
502
+ producedBy: input.producedBy,
503
+ resolution: EXTERNAL_RESOLUTION,
504
+ attrs: {
460
505
  thirdParty: attribution.thirdParty,
461
506
  // Honest degradation: `thirdParty` is decided against a static list,
462
- // not against anything installed, and the row says which list.
507
+ // not against anything installed, and the node says which list.
463
508
  thirdPartyBasis: STDLIB_BASIS,
464
509
  },
465
- };
510
+ external: {
511
+ ecosystem: EXTERNAL_ECOSYSTEM,
512
+ moduleOrNamespace: attribution.moduleOrNamespace,
513
+ },
514
+ });
515
+ return id;
516
+ };
517
+ /**
518
+ * The edge into a dependency is **always `CALLS`, never `TESTS`**, even when
519
+ * the container is a `TEST_CASE` and every in-repo edge it owns is a `TESTS`.
520
+ * `TESTS` asserts coverage — that this case exercises that node — and this
521
+ * run read nothing of the dependency's body, so it cannot assert it (rule 4,
522
+ * rule 7). A test that reaches `yaml.safe_load` calls it; it does not test
523
+ * it. Filed as `DEC-NEXT-external-edge-is-calls-not-tests`.
524
+ */
525
+ const callExternal = (from, to) => {
526
+ push({ from, to, type: "CALLS" }, EXTERNAL_RESOLUTION);
466
527
  };
467
528
  // --- which classes are test suites, decided from evidence not from names ---
468
529
  /** file -> top-level class name -> the bases it was written with. */
@@ -934,6 +995,7 @@ export function extract(input) {
934
995
  file,
935
996
  range: { startLine: declaration.range.startLine, endLine: declaration.range.endLine },
936
997
  declaration,
998
+ qualifiedSymbolPath: qsp,
937
999
  };
938
1000
  // **One name in one scope is one symbol, however many `def`s write it.**
939
1001
  //
@@ -1109,6 +1171,7 @@ export function extract(input) {
1109
1171
  fields: [],
1110
1172
  decorators: [],
1111
1173
  },
1174
+ qualifiedSymbolPath: qsp,
1112
1175
  };
1113
1176
  byName.set(alias.name, entry);
1114
1177
  byFull.set(alias.name, entry);
@@ -1477,7 +1540,12 @@ export function extract(input) {
1477
1540
  // left into is readable. This is the half `adapter-go` has no
1478
1541
  // analogue for (Go has no from-import), and on measurement it is the
1479
1542
  // larger half: 2,790 of 4,836 attributions on dispatch.
1480
- disclose(source, edgeType, reference.name, file, reference.line, "outside", externalAttrs(file, undefined, reference.name));
1543
+ const external = mintExternal(file, undefined, reference.name);
1544
+ if (external !== undefined) {
1545
+ callExternal(source.id, external);
1546
+ continue;
1547
+ }
1548
+ disclose(source, edgeType, reference.name, file, reference.line, "outside");
1481
1549
  continue;
1482
1550
  }
1483
1551
  if ((target.type === "FUNCTION" || target.type === "TEST_CASE") && target.id !== source.id) {
@@ -1560,8 +1628,16 @@ export function extract(input) {
1560
1628
  // about this row — there is one, and it leaves the repository. So the
1561
1629
  // reason becomes `outside`, which is what the row actually is. An
1562
1630
  // unattributed row is untouched and stays `unknown`.
1563
- const external = externalAttrs(file, reference.receiver, reference.name);
1564
- disclose(source, edgeType, `${reference.receiver}.${reference.name}`, file, reference.line, external === undefined ? "unknown" : "outside", external);
1631
+ const external = mintExternal(file, reference.receiver, reference.name);
1632
+ if (external !== undefined) {
1633
+ // `import yaml` ... `yaml.safe_load(raw)`. When an import statement
1634
+ // binds the receiver, "no resolvable binding" is a FALSE sentence
1635
+ // about this row — there is one, and it leaves the repository. It
1636
+ // is now a node and an edge rather than a better-worded refusal.
1637
+ callExternal(source.id, external);
1638
+ continue;
1639
+ }
1640
+ disclose(source, edgeType, `${reference.receiver}.${reference.name}`, file, reference.line, "unknown");
1565
1641
  continue;
1566
1642
  }
1567
1643
  const method = byFullName
@@ -1781,6 +1857,11 @@ export function extract(input) {
1781
1857
  const mounts = [];
1782
1858
  const routeDecls = [];
1783
1859
  const clientCalls = [];
1860
+ /**
1861
+ * Every file's client half, read but not yet judged — see the push site
1862
+ * for why deciding is deferred to after the loop.
1863
+ */
1864
+ const pendingClients = [];
1784
1865
  const djangoRouteEntries = [];
1785
1866
  const djangoIncludeEntries = [];
1786
1867
  const drfRegisterEntries = [];
@@ -1849,27 +1930,22 @@ export function extract(input) {
1849
1930
  const moduleId = moduleNodeId.get(file);
1850
1931
  return moduleId === undefined ? undefined : { id: moduleId };
1851
1932
  };
1852
- for (const refusal of read.refusals) {
1853
- const container = containerFor([]);
1854
- if (container === undefined)
1855
- continue;
1856
- disclose(container, "USES_API", refusal.rawTarget, refusal.file, refusal.line, refusal.reason,
1857
- // Unset `refusalClass` stays legal and means unclassified — DEC-242 — so
1858
- // `attrs` is omitted entirely rather than sent with an `undefined` field.
1859
- refusal.refusalClass === undefined
1860
- ? undefined
1861
- : {
1862
- blockedBy: refusal.blockedBy,
1863
- refusalClass: refusal.refusalClass,
1864
- ...(refusal.argumentKind === undefined ? {} : { argumentKind: refusal.argumentKind }),
1865
- });
1866
- }
1867
- for (const call of read.calls) {
1868
- const container = containerFor(call.scope);
1869
- if (container === undefined)
1870
- continue;
1871
- clientCalls.push({ call, containerId: container.id });
1872
- }
1933
+ // EMITTED AFTER THE LOOP, NOT HERE — DEC-164's origin rule needs every
1934
+ // file's bases before any one call site can be judged, and a base three
1935
+ // files away is still evidence about this call. Reading is per file;
1936
+ // deciding is per program. File order is preserved, so the ledger reads
1937
+ // the same as it did.
1938
+ pendingClients.push({
1939
+ file,
1940
+ calls,
1941
+ moduleOfImport: moduleOfImportIn(file),
1942
+ clientLocals,
1943
+ constants,
1944
+ localStrings,
1945
+ functionParams,
1946
+ containerFor,
1947
+ read,
1948
+ });
1873
1949
  for (const declaration of parsed.declarations) {
1874
1950
  for (const call of declaration.decoratorCalls ?? []) {
1875
1951
  const route = routeFromDecorator(file, call, declaration.path);
@@ -1893,6 +1969,81 @@ export function extract(input) {
1893
1969
  drfRegisterEntries.push(reg);
1894
1970
  }
1895
1971
  }
1972
+ // --- the client half, decided now that every file has been read --------
1973
+ //
1974
+ // DEC-164's own-origin rule admits an absolute call path only at an origin
1975
+ // some base in this same program already resolves to. The first read
1976
+ // produced those origins; the second re-reads only the files that could
1977
+ // change, and with no absolute base anywhere `origins` is empty and no
1978
+ // file is re-read at all.
1979
+ const origins = new Set();
1980
+ for (const pending of pendingClients) {
1981
+ for (const call of pending.read.calls) {
1982
+ if (call.baseOrigin !== undefined)
1983
+ origins.add(call.baseOrigin);
1984
+ }
1985
+ }
1986
+ for (const pending of pendingClients) {
1987
+ const couldChange = origins.size > 0 && pending.read.refusals.some((refusal) => refusal.reason === "callPathNotRelative");
1988
+ const read = couldChange
1989
+ ? clientCallsIn(pending.file, pending.calls, pending.moduleOfImport, pending.clientLocals, pending.constants, pending.localStrings, pending.functionParams, origins)
1990
+ : pending.read;
1991
+ for (const refusal of read.refusals) {
1992
+ // DEC-164 is attributed to the caller that wrote the call; the ledger
1993
+ // row's own container is unchanged and still the module.
1994
+ const owner = pending.containerFor(refusal.scope);
1995
+ if (owner !== undefined && refusal.clientBase !== undefined && !clientBases.has(owner.id)) {
1996
+ clientBases.set(owner.id, refusal.clientBase);
1997
+ }
1998
+ const container = pending.containerFor([]);
1999
+ if (container === undefined)
2000
+ continue;
2001
+ disclose(container, "USES_API", refusal.rawTarget, refusal.file, refusal.line, refusal.reason,
2002
+ // Unset `refusalClass` stays legal and means unclassified — DEC-242 — so
2003
+ // `attrs` is omitted entirely rather than sent with an `undefined` field.
2004
+ refusal.refusalClass === undefined
2005
+ ? undefined
2006
+ : {
2007
+ blockedBy: refusal.blockedBy,
2008
+ refusalClass: refusal.refusalClass,
2009
+ ...(refusal.argumentKind === undefined ? {} : { argumentKind: refusal.argumentKind }),
2010
+ });
2011
+ }
2012
+ // Pydantic parses written in this file, read once rather than per call
2013
+ // — golden 18's consumer half. Read HERE and not at the first pass
2014
+ // because `read` may be the SECOND read of this file (DEC-164's origin
2015
+ // rule re-reads a file whose refusals could change), and a model
2016
+ // resolved against the first read's calls would describe calls that no
2017
+ // longer exist.
2018
+ const parses = validateCalls(pending.calls);
2019
+ for (const call of read.calls) {
2020
+ const container = pending.containerFor(call.scope);
2021
+ if (container === undefined)
2022
+ continue;
2023
+ if (!clientBases.has(container.id))
2024
+ clientBases.set(container.id, call.clientBase);
2025
+ clientCalls.push({
2026
+ call,
2027
+ containerId: container.id,
2028
+ responseModel: modelParsing(parses, call.scope, call.client, call.rawPath),
2029
+ responseModelFile: pending.file,
2030
+ });
2031
+ }
2032
+ }
2033
+ // DEC-164, patched onto the caller now rather than at the end of the
2034
+ // function. `adapter-csharp` patches its own at the end and can, because
2035
+ // it has one exit; this function returns early at `reached < 3`, and a
2036
+ // patch after that line was **absent at R2 while the golden requires it
2037
+ // there** — the conformance sweep caught it as three `node-attr-mismatch`
2038
+ // findings at R2 only, which a single-level run would never have shown.
2039
+ // Here is the earliest point where every caller node exists and every
2040
+ // base has been decided.
2041
+ for (let i = 0; i < nodes.length; i += 1) {
2042
+ const base = clientBases.get(nodes[i].id);
2043
+ if (base === undefined)
2044
+ continue;
2045
+ nodes[i] = { ...nodes[i], attrs: { ...nodes[i].attrs, clientBase: base } };
2046
+ }
1896
2047
  /** child -> its parent and the prefix mounting it there. */
1897
2048
  const parentOf = new Map();
1898
2049
  for (const mount of mounts) {
@@ -2042,11 +2193,22 @@ export function extract(input) {
2042
2193
  // honest rather than implied.
2043
2194
  const served = joinPath(prefix, route.path);
2044
2195
  for (const method of route.methods) {
2196
+ // Handler resolution now runs BEFORE the route id is computed. The
2197
+ // resolved handler's own qualified symbol path is folded in as a
2198
+ // third hash input alongside method+template — product ruling,
2199
+ // `DEC-NEXT-duplicate-route-handlers-tracked-separately.md`: two
2200
+ // different handlers serving one method+template must become two
2201
+ // distinct `API_ROUTE` nodes (both still joined to the one shared
2202
+ // `API_ENDPOINT` below), not the second one silently losing its node.
2203
+ // Falls back to the declaring module's own qsp — never a raw file
2204
+ // path (DEC-004) — when the handler can't be resolved.
2205
+ const handler = route.handler === null ? undefined : resolveSameFileHandler(route.file, route.handler.join("."));
2206
+ const handlerQsp = handler?.qualifiedSymbolPath ?? symbolQsp(pkg, modulePathOf(route.file));
2045
2207
  // The route keeps the template AS WRITTEN in its identity and the
2046
2208
  // normalised form in `attrs.pathTemplate` — `adapter-openapi` does
2047
2209
  // exactly this, and two producers of one route must mint the same
2048
2210
  // endpoint id or the join this whole lane exists for does not happen.
2049
- const routeId = nodeId(scope, "API_ROUTE", `${method} ${served}`, LANGUAGE);
2211
+ const routeId = nodeId(scope, "API_ROUTE", routeQsp(method, served, handlerQsp), LANGUAGE);
2050
2212
  let isNewRoute = false;
2051
2213
  if (!routeSeen.has(routeId)) {
2052
2214
  routeSeen.add(routeId);
@@ -2076,21 +2238,20 @@ export function extract(input) {
2076
2238
  });
2077
2239
  }
2078
2240
  else {
2079
- // This declaration's own SERVES_API edge and source location did not
2080
- // enter the graph — a second router already claimed this exact
2081
- // method+path. Real (two apps mounted at overlapping prefixes, a
2082
- // duplicate registration) rather than a bug in this reader, but
2083
- // silent until now: nothing distinguished it from "checked, only
2084
- // one declaration exists".
2241
+ // The narrow residual this identity change still allows: two
2242
+ // declarations at the same method+template that ALSO resolved to
2243
+ // the exact same handler (or, unresolved, the exact same fallback
2244
+ // module qsp) — a genuine duplicate, correctly merged into one
2245
+ // node. A route with a *different* handler no longer reaches this
2246
+ // branch at all; it gets its own node above instead.
2085
2247
  disclose(from, "SERVES_API", `${method} ${served}`, route.file, route.line, "routeIdCollision");
2086
2248
  }
2087
2249
  // Route -> handler, golden pattern 19. Only for the declaration that
2088
- // actually won the node above — the second of two declarations
2089
- // colliding on one method+path already lost its SERVES_API edge and
2090
- // location just above, and letting its handler through here would
2091
- // attach an unrelated function to a route it does not own.
2250
+ // actually won the node above — the residual duplicate case just
2251
+ // above already lost its SERVES_API edge and location, and letting
2252
+ // its handler through here would attach it to a route it does not
2253
+ // own.
2092
2254
  if (isNewRoute) {
2093
- const handler = route.handler === null ? undefined : resolveSameFileHandler(route.file, route.handler.join("."));
2094
2255
  emitHandlerCalls(routeId, handler, route.handler === null ? `${method} ${served}` : route.handler.join("."), route.file, route.line, "routeHandlerUnresolved", 0);
2095
2256
  }
2096
2257
  else {
@@ -2378,7 +2539,19 @@ export function extract(input) {
2378
2539
  };
2379
2540
  const emitDjangoRoute = (file, line, served, method, methodDeclared, level, framework, handler) => {
2380
2541
  const from = { id: moduleNodeId.get(file) };
2381
- const routeId = nodeId(scope, "API_ROUTE", `${method} ${served}`, LANGUAGE);
2542
+ // Every caller (`djangoRouteHandler`, DRF's own action lookup, the
2543
+ // strawberry field lookup) has already resolved `handler` before this
2544
+ // function runs, so its declaration's own qualified symbol path can be
2545
+ // folded straight into the route id as a third hash input alongside
2546
+ // method+template — product ruling,
2547
+ // `DEC-NEXT-duplicate-route-handlers-tracked-separately.md`: two
2548
+ // different handlers serving one method+template must become two
2549
+ // distinct `API_ROUTE` nodes, not the second one silently losing its
2550
+ // node. Falls back to the declaring module's own qsp — never a raw
2551
+ // file path (DEC-004) — when there is no handler or it could not be
2552
+ // resolved.
2553
+ const handlerQsp = handler?.declared?.qualifiedSymbolPath ?? symbolQsp(pkg, modulePathOf(file));
2554
+ const routeId = nodeId(scope, "API_ROUTE", routeQsp(method, served, handlerQsp), LANGUAGE);
2382
2555
  let isNewRoute = false;
2383
2556
  if (!routeSeen.has(routeId)) {
2384
2557
  routeSeen.add(routeId);
@@ -2405,6 +2578,10 @@ export function extract(input) {
2405
2578
  }
2406
2579
  }
2407
2580
  else {
2581
+ // The narrow residual this identity change still allows: two
2582
+ // declarations at the same method+template that ALSO resolved to the
2583
+ // same handler (or the same fallback module qsp) — a genuine
2584
+ // duplicate, correctly merged into one node.
2408
2585
  disclose(from, "SERVES_API", `${method} ${served}`, file, line, "routeIdCollision");
2409
2586
  }
2410
2587
  if (handler !== undefined) {
@@ -2412,9 +2589,8 @@ export function extract(input) {
2412
2589
  emitHandlerCalls(routeId, handler.declared, handler.rawTarget, file, line, handler.reason, 0);
2413
2590
  }
2414
2591
  else {
2415
- // Consistent with the SERVES_API drop just above: the second of two
2416
- // declarations colliding on one method+path does not get to claim a
2417
- // handler either.
2592
+ // Consistent with the SERVES_API drop just above: the residual
2593
+ // duplicate case does not get to claim a handler either.
2418
2594
  disclose({ id: routeId }, "CALLS", `${method} ${served}`, file, line, "routeIdCollision");
2419
2595
  }
2420
2596
  }
@@ -2507,7 +2683,15 @@ export function extract(input) {
2507
2683
  }
2508
2684
  const served = normaliseEndpointPath(route.path).startsWith("/") ? route.path : `/${route.path}`;
2509
2685
  for (const method of route.methods) {
2510
- const routeId = nodeId(scope, "API_ROUTE", `${method} ${served}`, LANGUAGE);
2686
+ // Same restructuring as the FastAPI/Flask site above: resolve the
2687
+ // handler before minting the route id and fold its qualified symbol
2688
+ // path into the hash as a third input (product ruling,
2689
+ // `DEC-NEXT-duplicate-route-handlers-tracked-separately.md`).
2690
+ // Fallback: the declaring module's own qsp, never a raw file path
2691
+ // (DEC-004).
2692
+ const handler = route.handler === null ? undefined : resolveSameFileHandler(route.file, route.handler.join("."));
2693
+ const handlerQsp = handler?.qualifiedSymbolPath ?? symbolQsp(pkg, modulePathOf(route.file));
2694
+ const routeId = nodeId(scope, "API_ROUTE", routeQsp(method, served, handlerQsp), LANGUAGE);
2511
2695
  let isNewRoute = false;
2512
2696
  if (!routeSeen.has(routeId)) {
2513
2697
  routeSeen.add(routeId);
@@ -2525,10 +2709,12 @@ export function extract(input) {
2525
2709
  });
2526
2710
  }
2527
2711
  else {
2712
+ // Narrow residual: two declarations at the same method+template
2713
+ // that also resolved to the same handler (or fallback qsp) — a
2714
+ // genuine duplicate, correctly merged into one node.
2528
2715
  disclose(from, "SERVES_API", `${method} ${served}`, route.file, route.line, "routeIdCollision");
2529
2716
  }
2530
2717
  if (isNewRoute) {
2531
- const handler = route.handler === null ? undefined : resolveSameFileHandler(route.file, route.handler.join("."));
2532
2718
  emitHandlerCalls(routeId, handler, route.handler === null ? `${method} ${served}` : route.handler.join("."), route.file, route.line, "routeHandlerUnresolved", 0);
2533
2719
  }
2534
2720
  else {
@@ -2608,6 +2794,10 @@ export function extract(input) {
2608
2794
  walk(local, schema.file, 0);
2609
2795
  for (const { declared } of collected) {
2610
2796
  const moduleOf = moduleOfImportIn(declared.file);
2797
+ // Populated below only for the decorated-method form, where a real
2798
+ // function declaration was found and resolved. The attribute/field
2799
+ // form's fields never get an entry — there is nothing to resolve to.
2800
+ const fieldHandlers = new Map();
2611
2801
  const fields = [
2612
2802
  ...fieldsFromAttributes(declared.declaration.attributes ?? [], declared.file, schema.naming, moduleOf),
2613
2803
  ];
@@ -2627,8 +2817,21 @@ export function extract(input) {
2627
2817
  // the last segment, and using the whole of it would mint
2628
2818
  // `/Query/Query.circuit_by_id`.
2629
2819
  candidate.path[candidate.path.length - 1], candidate.decorators ?? [], candidate.decoratorCalls ?? [], declared.file, candidate.range.startLine, schema.naming, moduleOf);
2630
- if (field !== null)
2820
+ if (field !== null) {
2631
2821
  fields.push(field);
2822
+ // The loop above already resolved this exact declaration to
2823
+ // decide it was a field at all — reusing the same same-file
2824
+ // lookup every other handler resolution in this file goes
2825
+ // through (`resolveSameFileHandler`, the Odoo case's own
2826
+ // precedent just above) rather than re-deriving a node
2827
+ // reference from `candidate` by hand. Attribute-form fields
2828
+ // never reach this branch, so they never gain an entry here —
2829
+ // `emitDjangoRoute` below sees `undefined` for those, same as
2830
+ // before this fix.
2831
+ const handlerDeclared = resolveSameFileHandler(declared.file, candidate.path.join("."));
2832
+ if (handlerDeclared !== undefined)
2833
+ fieldHandlers.set(field, handlerDeclared);
2834
+ }
2632
2835
  }
2633
2836
  for (const field of fields) {
2634
2837
  if (field.name === null) {
@@ -2645,7 +2848,10 @@ export function extract(input) {
2645
2848
  const level = (field.file === schema.file ? 0 : 1);
2646
2849
  if (level > input.reached)
2647
2850
  continue;
2648
- emitDjangoRoute(field.file, field.line, operationPath(slot, field.name), GRAPHQL_METHOD, true, level, "strawberry");
2851
+ const handlerDeclared = fieldHandlers.get(field);
2852
+ emitDjangoRoute(field.file, field.line, operationPath(slot, field.name), GRAPHQL_METHOD, true, level, "strawberry", handlerDeclared === undefined
2853
+ ? undefined
2854
+ : { declared: handlerDeclared, rawTarget: field.name, reason: "routeHandlerCrossFile" });
2649
2855
  }
2650
2856
  }
2651
2857
  }
@@ -2664,7 +2870,9 @@ export function extract(input) {
2664
2870
  // rejected at the boundary as RESOLUTION_EXCEEDS_BATCH — the batch
2665
2871
  // contract catching a claim stronger than the run that produced it, which
2666
2872
  // is exactly what it is for.
2667
- for (const { call, containerId } of input.reached >= 2 ? clientCalls : []) {
2873
+ for (const { call, containerId, responseModel, responseModelFile } of input.reached >= 2
2874
+ ? clientCalls
2875
+ : []) {
2668
2876
  const endpointId = nodeId(scope, "API_ENDPOINT", endpointQsp(call.method, call.path), null);
2669
2877
  if (!routeSeen.has(endpointId)) {
2670
2878
  routeSeen.add(endpointId);
@@ -2680,6 +2888,26 @@ export function extract(input) {
2680
2888
  attrs: { method: call.method, pathTemplate: normaliseEndpointPath(call.path) },
2681
2889
  });
2682
2890
  }
2891
+ /**
2892
+ * Golden 18's consumer half. Gated on `input.reached >= 3` INSIDE the
2893
+ * attrs rather than by an early return above: this loop runs from R2, so
2894
+ * a guard placed one level up would be green at R3 and silently wrong at
2895
+ * R2 — the monotonic sweep's own failure mode. At R2 the key is simply
2896
+ * absent, which is the honest answer.
2897
+ *
2898
+ * R3 because the shape is a Pydantic DECLARATION — types stated, not
2899
+ * inferred — which is DEC-202's rule and the same ground `adapter-java`'s
2900
+ * JPA shapes and `adapter-sql`'s schema stand on. The edge itself stays
2901
+ * at 2 (path resolution); `shapeResolution` carries the shape's own
2902
+ * evidence so the contract engine does not cap it at the weaker of the
2903
+ * two facts this edge holds (DEC-058).
2904
+ */
2905
+ const declaredShape = input.reached >= 3 && responseModel !== null
2906
+ ? followName(responseModelFile, responseModel)
2907
+ : undefined;
2908
+ const shapeAttrs = declaredShape !== undefined && declaredShape.type === "DTO"
2909
+ ? { responseType: declaredShape.id, shapeResolution: 3 }
2910
+ : {};
2683
2911
  push({
2684
2912
  from: containerId,
2685
2913
  to: endpointId,
@@ -2689,6 +2917,7 @@ export function extract(input) {
2689
2917
  file: call.file,
2690
2918
  line: call.line,
2691
2919
  written: call.rawPath,
2920
+ ...shapeAttrs,
2692
2921
  },
2693
2922
  }, 2);
2694
2923
  }