@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/adapter.d.ts +41 -1
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js +94 -10
- package/dist/adapter.js.map +1 -1
- package/dist/client-base.d.ts +63 -0
- package/dist/client-base.d.ts.map +1 -0
- package/dist/client-base.js +55 -0
- package/dist/client-base.js.map +1 -0
- package/dist/client.d.ts +29 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +70 -29
- package/dist/client.js.map +1 -1
- package/dist/extract.d.ts.map +1 -1
- package/dist/extract.js +297 -68
- package/dist/extract.js.map +1 -1
- package/dist/response-shape.d.ts +62 -0
- package/dist/response-shape.d.ts.map +1 -0
- package/dist/response-shape.js +95 -0
- package/dist/response-shape.js.map +1 -0
- package/package.json +5 -5
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
|
|
435
|
-
*
|
|
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
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
443
|
-
*
|
|
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
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
1564
|
-
|
|
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
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
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",
|
|
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
|
-
//
|
|
2080
|
-
//
|
|
2081
|
-
//
|
|
2082
|
-
//
|
|
2083
|
-
//
|
|
2084
|
-
//
|
|
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
|
|
2089
|
-
//
|
|
2090
|
-
//
|
|
2091
|
-
//
|
|
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
|
-
|
|
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
|
|
2416
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|