@descryy/adapter-python 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapter.d.ts +12 -1
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js +82 -5
- package/dist/adapter.js.map +1 -1
- package/dist/celery.d.ts +76 -0
- package/dist/celery.d.ts.map +1 -0
- package/dist/celery.js +73 -0
- package/dist/celery.js.map +1 -0
- package/dist/extract.d.ts +9 -0
- package/dist/extract.d.ts.map +1 -1
- package/dist/extract.js +534 -0
- package/dist/extract.js.map +1 -1
- package/package.json +8 -8
package/dist/extract.js
CHANGED
|
@@ -35,6 +35,7 @@ import { importRoots, modulePathOf, moduleNameOf, resolveImport, } from "./modul
|
|
|
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";
|
|
37
37
|
import { globToRegExp, PYTEST_DEFAULTS } from "./pytest-config.js";
|
|
38
|
+
import { celeryProducerCallsIn, celeryTaskDeclarationsIn } from "./celery.js";
|
|
38
39
|
import { drfShapeOf, dtoShapeOf, isDrfSerializerRoot, isPydanticRoot, isValidatorDecorator, PYDANTIC_MODEL_MEMBERS, } from "./pydantic.js";
|
|
39
40
|
import { DRF_ACTIONS, drfRegisterCallsIn, drfRouterLocalsIn, djangoUrlEntriesIn, HTTP_METHODS, isApplicationRouter, joinPath, mountsIn, odooRouteFromDecorator, routeFromDecorator, routersIn, } from "./routes.js";
|
|
40
41
|
import { GRAPHQL_METHOD, fieldFromMethod, fieldsFromAttributes, operationPath, schemaDeclsIn, } from "./graphql.js";
|
|
@@ -55,6 +56,70 @@ const EXTERNAL_RESOLUTION = 1;
|
|
|
55
56
|
* and 83% of its edges claimed a level they had not earned.
|
|
56
57
|
*/
|
|
57
58
|
const CONFIDENCE = { 0: 0.5, 1: 0.7, 2: 0.9, 3: 0.95 };
|
|
59
|
+
// --- Django page rendering (golden pattern 23) ------------------------------
|
|
60
|
+
/** `django.shortcuts.render`/`render_to_response` — provenance, not a name
|
|
61
|
+
* guess, the same standing `ORM_BASE_MODULES` has for a MODEL base. */
|
|
62
|
+
const DJANGO_RENDER_MODULES = new Set(["django.shortcuts"]);
|
|
63
|
+
const DJANGO_TEMPLATE_RESPONSE_MODULES = new Set(["django.template.response"]);
|
|
64
|
+
const DJANGO_VIEW_MODULES = new Set([
|
|
65
|
+
"django.views.generic",
|
|
66
|
+
"django.views.generic.base",
|
|
67
|
+
"django.views.generic.list",
|
|
68
|
+
"django.views.generic.detail",
|
|
69
|
+
"django.views.generic.edit",
|
|
70
|
+
]);
|
|
71
|
+
const DJANGO_VIEW_BASE_NAMES = new Set([
|
|
72
|
+
"View",
|
|
73
|
+
"TemplateView",
|
|
74
|
+
"ListView",
|
|
75
|
+
"DetailView",
|
|
76
|
+
"CreateView",
|
|
77
|
+
"UpdateView",
|
|
78
|
+
"DeleteView",
|
|
79
|
+
"FormView",
|
|
80
|
+
]);
|
|
81
|
+
/** A positional argument's own written constant, when `extract.py` read one. */
|
|
82
|
+
function positionalLiteral(call, index) {
|
|
83
|
+
if (call.hasLiterals[index] !== true)
|
|
84
|
+
return undefined;
|
|
85
|
+
const value = call.literals[index];
|
|
86
|
+
return typeof value === "string" ? value : undefined;
|
|
87
|
+
}
|
|
88
|
+
function keywordLiteral(call, key) {
|
|
89
|
+
const value = call.keywords[key];
|
|
90
|
+
return typeof value === "string" ? value : undefined;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The page template a function-based Django view's call names, or the three
|
|
94
|
+
* states rule 2/rule 7 require: `undefined` — not a render-family call at
|
|
95
|
+
* all, silently skipped; `null` — a recognised render call whose template
|
|
96
|
+
* argument is not a bare literal, disclosed; a `string` — the template name,
|
|
97
|
+
* read straight off the call.
|
|
98
|
+
*
|
|
99
|
+
* `render(request, template_name, …)` / `render_to_response(template_name,
|
|
100
|
+
* …)` / `TemplateResponse(request, template, …)` are Django's own three, each
|
|
101
|
+
* gated on the callee's own import — `from django.shortcuts import render`
|
|
102
|
+
* states the provenance in the file, needing no cross-file resolution at all.
|
|
103
|
+
*/
|
|
104
|
+
function djangoPageTemplateOfCall(call, imports) {
|
|
105
|
+
const callee = call.callee;
|
|
106
|
+
if (callee === null)
|
|
107
|
+
return undefined;
|
|
108
|
+
const root = callee.split(".")[0];
|
|
109
|
+
const leaf = callee.split(".").pop();
|
|
110
|
+
const importedFrom = (modules) => imports.some((record) => modules.has(record.module ?? "") && (record.local === root || record.local === leaf));
|
|
111
|
+
if ((leaf === "render" || leaf === "render_to_response") && importedFrom(DJANGO_RENDER_MODULES)) {
|
|
112
|
+
const template = leaf === "render"
|
|
113
|
+
? (positionalLiteral(call, 1) ?? keywordLiteral(call, "template_name"))
|
|
114
|
+
: (positionalLiteral(call, 0) ?? keywordLiteral(call, "template_name"));
|
|
115
|
+
return template ?? null;
|
|
116
|
+
}
|
|
117
|
+
if (leaf === "TemplateResponse" && importedFrom(DJANGO_TEMPLATE_RESPONSE_MODULES)) {
|
|
118
|
+
const template = positionalLiteral(call, 1) ?? keywordLiteral(call, "template");
|
|
119
|
+
return template ?? null;
|
|
120
|
+
}
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
58
123
|
const REASONS = {
|
|
59
124
|
outside: "resolves to a name this run did not analyse — the standard library, an installed package, " +
|
|
60
125
|
"or a module outside the analysed set. A scope boundary, not an analysis gap.",
|
|
@@ -133,6 +198,10 @@ const REASONS = {
|
|
|
133
198
|
"is recorded as GET and `attrs.methodDeclared` is false so that default is never mistaken " +
|
|
134
199
|
"for a declaration: a real POST to this path will never match this route, and an unrelated " +
|
|
135
200
|
"GET caller silently could.",
|
|
201
|
+
celeryTaskNameNotLiteral: "a Celery task decorator (@app.task/@shared_task) whose name= is written but is not a " +
|
|
202
|
+
"literal, so the queue name Celery actually registers this task under is not knowable from " +
|
|
203
|
+
"source — nor is the module-qualified default, since a written name= always wins it. " +
|
|
204
|
+
"Emitting a queue here would attach a producer to a topic that does not exist.",
|
|
136
205
|
odooPathNotLiteral: "an @http.route(...) whose path is written as anything but a literal, so the path it serves " +
|
|
137
206
|
"is not knowable from source.",
|
|
138
207
|
odooMethodsNotLiteral: "an @http.route(...) whose methods= is written but not readable as a list of literals — " +
|
|
@@ -153,6 +222,21 @@ const REASONS = {
|
|
|
153
222
|
graphqlRootSlotUnreadable: "a strawberry.Schema(...) whose query=/mutation=/subscription= slot is written as something " +
|
|
154
223
|
"other than a bare class name, so the root type it puts in that slot cannot be followed. " +
|
|
155
224
|
"The operations under it are real and unreadable from here.",
|
|
225
|
+
grpcServicerHandlerUnresolved: "this RPC's servicer implementation does not resolve to a class method declared in this same " +
|
|
226
|
+
"file — the impl argument is not a bare constructor call or a local this file binds to one, " +
|
|
227
|
+
"the resolved class does not declare a method of this exact name, or the servicer is " +
|
|
228
|
+
"constructed and registered from two different files, which this reader does not follow " +
|
|
229
|
+
"(the same same-file-only restriction every other route -> handler edge in this adapter " +
|
|
230
|
+
"already holds itself to). The route is real; the code implementing it is not established.",
|
|
231
|
+
djangoRenderTemplateNotLiteral: "a Django render()/render_to_response()/TemplateResponse() call, or a class-based view's own " +
|
|
232
|
+
"template_name, whose template argument is not a bare string literal — computed, an f-string, " +
|
|
233
|
+
"or a name bound earlier. A computed template name is not a written fact (rule 2): emitting a " +
|
|
234
|
+
"PAGE for it would name a template that may not exist under that path at all.",
|
|
235
|
+
djangoTemplateFileNotFound: "the template name is a written literal, but no file under an app's own templates/ directory " +
|
|
236
|
+
"or a project-level templates/ directory matches it. The IR boundary requires a PAGE's file " +
|
|
237
|
+
"to be a real, batch-scoped path, so rule 2 discloses the render instead of minting a node " +
|
|
238
|
+
"with none — the name may be assembled at a template-loader search path this reader does not " +
|
|
239
|
+
"evaluate, or the template may simply not exist in this repository.",
|
|
156
240
|
};
|
|
157
241
|
// --- pytest -----------------------------------------------------------------
|
|
158
242
|
/**
|
|
@@ -608,6 +692,43 @@ export function extract(input) {
|
|
|
608
692
|
}
|
|
609
693
|
return false;
|
|
610
694
|
}
|
|
695
|
+
/**
|
|
696
|
+
* Is this class a Django generic view — golden pattern 23's class-based
|
|
697
|
+
* form? Same shape as `isTestSuiteClass`/`ormFrameworkOf`'s rule 1 and rule
|
|
698
|
+
* 3: the base's own name has to match a known Django view AND come from a
|
|
699
|
+
* `django.views.generic*` import (provenance, not a name guess), followed
|
|
700
|
+
* across files the same way a shared in-repo base class already is for a
|
|
701
|
+
* MODEL or a test suite.
|
|
702
|
+
*/
|
|
703
|
+
function isDjangoViewClass(file, name, seen = new Set()) {
|
|
704
|
+
const key = `${file}:${name}`;
|
|
705
|
+
if (seen.has(key) || seen.size > 32)
|
|
706
|
+
return false;
|
|
707
|
+
seen.add(key);
|
|
708
|
+
const bases = basesOf.get(file)?.get(name);
|
|
709
|
+
if (bases === undefined)
|
|
710
|
+
return false;
|
|
711
|
+
const parsed = input.parsed.files[file];
|
|
712
|
+
for (const base of bases) {
|
|
713
|
+
if (base === "")
|
|
714
|
+
continue;
|
|
715
|
+
const root = base.split(".")[0];
|
|
716
|
+
const leaf = base.split(".").pop();
|
|
717
|
+
if (DJANGO_VIEW_BASE_NAMES.has(leaf)) {
|
|
718
|
+
const fromDjango = parsed.imports.some((record) => {
|
|
719
|
+
if (!DJANGO_VIEW_MODULES.has(record.module ?? ""))
|
|
720
|
+
return false;
|
|
721
|
+
return record.local === root || record.local === leaf;
|
|
722
|
+
});
|
|
723
|
+
if (fromDjango)
|
|
724
|
+
return true;
|
|
725
|
+
}
|
|
726
|
+
const owner = classFileOf(file, leaf);
|
|
727
|
+
if (owner !== undefined && isDjangoViewClass(owner, leaf, seen))
|
|
728
|
+
return true;
|
|
729
|
+
}
|
|
730
|
+
return false;
|
|
731
|
+
}
|
|
611
732
|
// --- which classes are ORM models, decided from evidence not from names ----
|
|
612
733
|
//
|
|
613
734
|
// The same three-rule shape as `isTestSuiteClass`, with rule 2 deliberately
|
|
@@ -1206,8 +1327,107 @@ export function extract(input) {
|
|
|
1206
1327
|
// or compares a shape, so R2 was over-claiming and R3 would be the inflation
|
|
1207
1328
|
// DEC-058 removed once already.
|
|
1208
1329
|
emitRoutes();
|
|
1330
|
+
// --- gRPC (DEC-NEXT-grpc-client-and-queue-endpoint-convention) -------------
|
|
1331
|
+
//
|
|
1332
|
+
// Its own pass, not folded into `emitRoutes()`: a gRPC service has no HTTP
|
|
1333
|
+
// verb, no mount chain and no decorator — the generated `_pb2_grpc.py` file
|
|
1334
|
+
// is the whole of its evidence, read the same way `adapter-go`'s reader
|
|
1335
|
+
// reads `_FullMethodName` consts, never assembled from an unrelated name.
|
|
1336
|
+
emitGrpc();
|
|
1209
1337
|
if (input.reached < 1)
|
|
1210
1338
|
return { nodes, edges, unresolved };
|
|
1339
|
+
// --- Django page rendering (golden pattern 23) ------------------------------
|
|
1340
|
+
//
|
|
1341
|
+
// A backend handler renders a page directly through Django's own render
|
|
1342
|
+
// call — `render(request, "orders/index.html")` / `render_to_response(...)`
|
|
1343
|
+
// / `TemplateResponse(request, "...")` — or, for a class-based view, through
|
|
1344
|
+
// its own `template_name = "..."` class attribute (`TemplateView`,
|
|
1345
|
+
// `ListView`, `DetailView`, …). Both R1: a written, unambiguous instruction
|
|
1346
|
+
// read directly off the call site or the class body, with no cross-file
|
|
1347
|
+
// resolution beyond confirming the base class's own import — the same
|
|
1348
|
+
// standing `ormFrameworkOf`'s rule 1 already has for a `MODEL` base.
|
|
1349
|
+
//
|
|
1350
|
+
// NOT built here: `SomeView.as_view(template_name="...")` written directly
|
|
1351
|
+
// in `urls.py`, overriding a class's own `template_name` at the URL level —
|
|
1352
|
+
// a disclosed scope reduction, not a silent gap. This reader mints the
|
|
1353
|
+
// CLASS's own `template_name` when it declares one; a URL-level override
|
|
1354
|
+
// needs following a nested call one hop further than this pass does, and is
|
|
1355
|
+
// left for a future lane (see `DEC-NEXT-server-rendered-page-extractor-capability.md`).
|
|
1356
|
+
{
|
|
1357
|
+
const pageSeen = new Set();
|
|
1358
|
+
const templateFiles = input.templateFiles ?? new Map();
|
|
1359
|
+
// The IR boundary rejects a PAGE with `file: null` (`MISSING_FILE`) — see
|
|
1360
|
+
// `documents/decisions-inbox/DEC-NEXT-server-rendered-page-file-is-its-template.md`
|
|
1361
|
+
// in descry-core. A template NAME is not enough; only a real file
|
|
1362
|
+
// confirmed present under a templates/ directory backs a PAGE at all —
|
|
1363
|
+
// rule 2: when no such file exists, disclose the render as unresolved
|
|
1364
|
+
// rather than mint a node the IR would drop anyway.
|
|
1365
|
+
const mintDjangoPage = (declared, templateName, level, file, line) => {
|
|
1366
|
+
const templateFile = templateFiles.get(templateName);
|
|
1367
|
+
if (templateFile === undefined) {
|
|
1368
|
+
disclose(declared, "RETURNS", templateName, file, line, "djangoTemplateFileNotFound");
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
1371
|
+
const pageId = nodeId(scope, "PAGE", symbolQsp(pkg, [templateName]), LANGUAGE);
|
|
1372
|
+
if (!pageSeen.has(pageId)) {
|
|
1373
|
+
pageSeen.add(pageId);
|
|
1374
|
+
nodes.push({
|
|
1375
|
+
id: pageId,
|
|
1376
|
+
type: "PAGE",
|
|
1377
|
+
name: templateName,
|
|
1378
|
+
file: templateFile,
|
|
1379
|
+
range: null,
|
|
1380
|
+
language: LANGUAGE,
|
|
1381
|
+
producedBy: input.producedBy,
|
|
1382
|
+
resolution: level,
|
|
1383
|
+
attrs: { framework: "django" },
|
|
1384
|
+
});
|
|
1385
|
+
}
|
|
1386
|
+
push({ from: declared.id, to: pageId, type: "RETURNS" }, level);
|
|
1387
|
+
};
|
|
1388
|
+
for (const file of files) {
|
|
1389
|
+
const parsed = input.parsed.files[file];
|
|
1390
|
+
const byName = declaredIn.get(file);
|
|
1391
|
+
// Function-based views: render()/render_to_response()/TemplateResponse(),
|
|
1392
|
+
// read off the module-and-one-level-deep call table `extract.py` already
|
|
1393
|
+
// builds for every other framework reader in this file (route
|
|
1394
|
+
// decorators, ORM Meta options, HTTP client calls).
|
|
1395
|
+
for (const call of parsed.calls ?? []) {
|
|
1396
|
+
if (call.scope.length !== 1)
|
|
1397
|
+
continue;
|
|
1398
|
+
const declared = byName.get(call.scope[0]);
|
|
1399
|
+
if (declared === undefined || declared.type !== "FUNCTION")
|
|
1400
|
+
continue;
|
|
1401
|
+
const template = djangoPageTemplateOfCall(call, parsed.imports);
|
|
1402
|
+
if (template === undefined)
|
|
1403
|
+
continue;
|
|
1404
|
+
if (template === null) {
|
|
1405
|
+
disclose(declared, "RETURNS", call.callee ?? "(render call)", file, call.line, "djangoRenderTemplateNotLiteral");
|
|
1406
|
+
continue;
|
|
1407
|
+
}
|
|
1408
|
+
mintDjangoPage(declared, template, 1, file, call.line);
|
|
1409
|
+
}
|
|
1410
|
+
// Class-based views: a class whose own base resolves to a Django
|
|
1411
|
+
// generic view, with its OWN `template_name = "..."` class attribute.
|
|
1412
|
+
for (const declaration of parsed.declarations) {
|
|
1413
|
+
if (declaration.kind !== "class" || declaration.path.length !== 1)
|
|
1414
|
+
continue;
|
|
1415
|
+
if (!isDjangoViewClass(file, declaration.name))
|
|
1416
|
+
continue;
|
|
1417
|
+
const declared = byName.get(declaration.path[0]);
|
|
1418
|
+
if (declared === undefined)
|
|
1419
|
+
continue;
|
|
1420
|
+
const templateAttr = (declaration.attributes ?? []).find((a) => a.name === "template_name");
|
|
1421
|
+
if (templateAttr === undefined)
|
|
1422
|
+
continue;
|
|
1423
|
+
if (typeof templateAttr.literal !== "string") {
|
|
1424
|
+
disclose(declared, "RETURNS", "template_name", file, templateAttr.line, "djangoRenderTemplateNotLiteral");
|
|
1425
|
+
continue;
|
|
1426
|
+
}
|
|
1427
|
+
mintDjangoPage(declared, templateAttr.literal, 1, file, templateAttr.line);
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1211
1431
|
// --- pass 2: import edges, off the resolution pass 0 already did ----------
|
|
1212
1432
|
for (const file of files) {
|
|
1213
1433
|
const fromId = moduleNodeId.get(file);
|
|
@@ -1224,6 +1444,11 @@ export function extract(input) {
|
|
|
1224
1444
|
}
|
|
1225
1445
|
if (input.reached < 2)
|
|
1226
1446
|
return { nodes, edges, unresolved };
|
|
1447
|
+
// --- Celery queues (DEC-NEXT-grpc-client-and-queue-endpoint-convention.md) -
|
|
1448
|
+
//
|
|
1449
|
+
// R2, same gate as the HTTP client/route passes below: producer resolution
|
|
1450
|
+
// crosses files (`followName`), which needs the import graph.
|
|
1451
|
+
emitQueues();
|
|
1227
1452
|
/**
|
|
1228
1453
|
* Follow a name to the declaration that defines it, through however many
|
|
1229
1454
|
* package re-exports stand in the way.
|
|
@@ -2954,6 +3179,315 @@ export function extract(input) {
|
|
|
2954
3179
|
}, 2);
|
|
2955
3180
|
}
|
|
2956
3181
|
}
|
|
3182
|
+
/**
|
|
3183
|
+
* gRPC — both boundary halves off `grpcio`'s own generated `_pb2_grpc.py`
|
|
3184
|
+
* evidence, mirroring `adapter-go`'s `grpc.ts` exactly: the wire path is
|
|
3185
|
+
* read, never assembled, and a client/server construct is admitted only by
|
|
3186
|
+
* the literal generated name, corroborated against that evidence — never a
|
|
3187
|
+
* name-alone guess.
|
|
3188
|
+
*
|
|
3189
|
+
* **Where the wire path is read.** `protoc`'s Python plugin writes the
|
|
3190
|
+
* full, wire-accurate path as a literal first argument to
|
|
3191
|
+
* `channel.unary_unary(...)`/`unary_stream`/`stream_unary`/`stream_stream`
|
|
3192
|
+
* inside `<Service>Stub.__init__` — the Python analogue of Go's
|
|
3193
|
+
* `<Service>_<Method>_FullMethodName` const. `defs` is keyed by the stub
|
|
3194
|
+
* class's own name with its `Stub` suffix stripped, which is also the
|
|
3195
|
+
* token `add_<Service>Servicer_to_server` embeds in its own name — the two
|
|
3196
|
+
* sides of one join, read by name, never inferred.
|
|
3197
|
+
*
|
|
3198
|
+
* **Scope, disclosed rather than attempted and missed.** `channel.x(...)`
|
|
3199
|
+
* sits inside `<Service>Stub.__init__`, one level below what `_call_record`
|
|
3200
|
+
* captures unconditionally (`len(scope) <= 1`) — reached only when the
|
|
3201
|
+
* generated file's own `import grpc` puts it in `deep_call_modules`
|
|
3202
|
+
* (`GRPC_MODULES`, unioned into `deepCallModules` in `adapter.ts`, the
|
|
3203
|
+
* same mechanism `CLIENT_MODULES` already uses for `requests`/`httpx`).
|
|
3204
|
+
* The servicer impl argument to `add_<Service>Servicer_to_server(...)` is
|
|
3205
|
+
* resolved only against a bare constructor call or a same-file local this
|
|
3206
|
+
* file itself instantiates — never across an import, the same restriction
|
|
3207
|
+
* `resolveSameFileHandler` already holds every other route -> handler edge
|
|
3208
|
+
* in this adapter to. A stub or servicer stored on `self.<x>` rather than
|
|
3209
|
+
* a bare local is a real, disclosed gap this pass does not reach: `visit_
|
|
3210
|
+
* Assign`'s `constructions`/`instantiations` tables only ever record a
|
|
3211
|
+
* bare-`Name` assignment target, matching every other caller half this
|
|
3212
|
+
* adapter already reads that way.
|
|
3213
|
+
*/
|
|
3214
|
+
function emitGrpc() {
|
|
3215
|
+
if (input.reached < 2)
|
|
3216
|
+
return;
|
|
3217
|
+
const CHANNEL_RPC_METHODS = new Set([
|
|
3218
|
+
"unary_unary",
|
|
3219
|
+
"unary_stream",
|
|
3220
|
+
"stream_unary",
|
|
3221
|
+
"stream_stream",
|
|
3222
|
+
]);
|
|
3223
|
+
const STUB_SUFFIX = /^(.+)Stub$/;
|
|
3224
|
+
const SERVICER_REGISTRATION = /^add_([A-Za-z0-9_]+)Servicer_to_server$/;
|
|
3225
|
+
// Service base name (the stub class's own name, `Stub` stripped) -> its
|
|
3226
|
+
// methods, method name -> full wire path. Repo-wide, not per-file: the
|
|
3227
|
+
// stub class and the file registering a server for it are almost always
|
|
3228
|
+
// different files, the same reason `adapter-go`'s own table spans every
|
|
3229
|
+
// file in a resolved package rather than just one.
|
|
3230
|
+
const defs = new Map();
|
|
3231
|
+
for (const file of files) {
|
|
3232
|
+
const parsed = input.parsed.files[file];
|
|
3233
|
+
for (const call of parsed.calls ?? []) {
|
|
3234
|
+
if (call.receiver !== "channel" || call.attribute === null)
|
|
3235
|
+
continue;
|
|
3236
|
+
if (!CHANNEL_RPC_METHODS.has(call.attribute))
|
|
3237
|
+
continue;
|
|
3238
|
+
if (call.scope.length === 0)
|
|
3239
|
+
continue;
|
|
3240
|
+
const match = STUB_SUFFIX.exec(call.scope[0]);
|
|
3241
|
+
if (match === null)
|
|
3242
|
+
continue;
|
|
3243
|
+
const path = call.hasLiterals[0] === true ? call.literals[0] : undefined;
|
|
3244
|
+
if (typeof path !== "string" || !path.startsWith("/"))
|
|
3245
|
+
continue;
|
|
3246
|
+
const method = path.slice(path.lastIndexOf("/") + 1);
|
|
3247
|
+
if (method === "")
|
|
3248
|
+
continue;
|
|
3249
|
+
const base = match[1];
|
|
3250
|
+
const def = defs.get(base) ?? { methods: new Map() };
|
|
3251
|
+
def.methods.set(method, path);
|
|
3252
|
+
defs.set(base, def);
|
|
3253
|
+
}
|
|
3254
|
+
}
|
|
3255
|
+
if (defs.size === 0)
|
|
3256
|
+
return;
|
|
3257
|
+
const mintEndpoint = (path) => {
|
|
3258
|
+
const template = normaliseEndpointPath(path);
|
|
3259
|
+
const id = nodeId(scope, "API_ENDPOINT", endpointQsp("RPC", path), null);
|
|
3260
|
+
if (!routeSeen.has(id)) {
|
|
3261
|
+
routeSeen.add(id);
|
|
3262
|
+
nodes.push({
|
|
3263
|
+
id,
|
|
3264
|
+
type: "API_ENDPOINT",
|
|
3265
|
+
name: `RPC ${template}`,
|
|
3266
|
+
file: null,
|
|
3267
|
+
range: null,
|
|
3268
|
+
language: null,
|
|
3269
|
+
producedBy: input.producedBy,
|
|
3270
|
+
resolution: 2,
|
|
3271
|
+
attrs: { method: "RPC", pathTemplate: template },
|
|
3272
|
+
});
|
|
3273
|
+
}
|
|
3274
|
+
return id;
|
|
3275
|
+
};
|
|
3276
|
+
// --- the provider's half: add_<Service>Servicer_to_server(impl, server) --
|
|
3277
|
+
for (const file of files) {
|
|
3278
|
+
const parsed = input.parsed.files[file];
|
|
3279
|
+
for (const call of parsed.calls ?? []) {
|
|
3280
|
+
if (call.attribute === null)
|
|
3281
|
+
continue;
|
|
3282
|
+
const match = SERVICER_REGISTRATION.exec(call.attribute);
|
|
3283
|
+
if (match === null)
|
|
3284
|
+
continue;
|
|
3285
|
+
const base = match[1];
|
|
3286
|
+
const def = defs.get(base);
|
|
3287
|
+
// No corroborating stub evidence anywhere in the analysed set is not
|
|
3288
|
+
// proof this is really a generated gRPC registration — the same
|
|
3289
|
+
// naming-coincidence standard `adapter-go`'s `readGrpcRegistrations`
|
|
3290
|
+
// holds itself to. Stay silent, don't disclose.
|
|
3291
|
+
if (def === undefined)
|
|
3292
|
+
continue;
|
|
3293
|
+
const implClass = call.argCalls[0]?.callee ??
|
|
3294
|
+
parsed.instantiations.find((i) => i.local === call.argNames[0])?.typeName;
|
|
3295
|
+
for (const [method, path] of def.methods) {
|
|
3296
|
+
const template = normaliseEndpointPath(path);
|
|
3297
|
+
const name = `RPC ${path}`;
|
|
3298
|
+
const routeId = nodeId(scope, "API_ROUTE", name, LANGUAGE);
|
|
3299
|
+
if (!routeSeen.has(routeId)) {
|
|
3300
|
+
routeSeen.add(routeId);
|
|
3301
|
+
nodes.push({
|
|
3302
|
+
id: routeId,
|
|
3303
|
+
type: "API_ROUTE",
|
|
3304
|
+
name,
|
|
3305
|
+
file,
|
|
3306
|
+
range: { startLine: call.line, endLine: call.line },
|
|
3307
|
+
language: LANGUAGE,
|
|
3308
|
+
producedBy: input.producedBy,
|
|
3309
|
+
resolution: 2,
|
|
3310
|
+
attrs: { method: "RPC", pathTemplate: template, rawTemplate: path, framework: "grpc" },
|
|
3311
|
+
});
|
|
3312
|
+
}
|
|
3313
|
+
const endpointId = mintEndpoint(path);
|
|
3314
|
+
push({ from: routeId, to: endpointId, type: "SERVES_API" }, 2);
|
|
3315
|
+
const handler = implClass === undefined ? undefined : resolveSameFileHandler(file, `${implClass}.${method}`);
|
|
3316
|
+
if (handler !== undefined) {
|
|
3317
|
+
push({ from: routeId, to: handler.id, type: "CALLS" }, 2);
|
|
3318
|
+
}
|
|
3319
|
+
else {
|
|
3320
|
+
disclose({ id: routeId }, "CALLS", implClass === undefined ? `${base}.${method}` : `${implClass}.${method}`, file, call.line, "grpcServicerHandlerUnresolved");
|
|
3321
|
+
}
|
|
3322
|
+
}
|
|
3323
|
+
}
|
|
3324
|
+
}
|
|
3325
|
+
// --- the caller's half: <pkg>_pb2_grpc.<Service>Stub(channel) ------------
|
|
3326
|
+
for (const file of files) {
|
|
3327
|
+
const parsed = input.parsed.files[file];
|
|
3328
|
+
const calls = parsed.calls ?? [];
|
|
3329
|
+
// local -> service base, from `stub = order_pb2_grpc.OrderServiceStub(channel)`.
|
|
3330
|
+
const stubLocals = new Map();
|
|
3331
|
+
for (const construction of parsed.constructions ?? []) {
|
|
3332
|
+
if (construction.callee === null)
|
|
3333
|
+
continue;
|
|
3334
|
+
const leaf = construction.callee.includes(".")
|
|
3335
|
+
? construction.callee.slice(construction.callee.lastIndexOf(".") + 1)
|
|
3336
|
+
: construction.callee;
|
|
3337
|
+
const match = STUB_SUFFIX.exec(leaf);
|
|
3338
|
+
if (match === null || !defs.has(match[1]))
|
|
3339
|
+
continue;
|
|
3340
|
+
stubLocals.set(construction.local, match[1]);
|
|
3341
|
+
}
|
|
3342
|
+
if (stubLocals.size === 0)
|
|
3343
|
+
continue;
|
|
3344
|
+
const fileDecls = byFullName.get(file);
|
|
3345
|
+
const containerFor = (scopePath) => {
|
|
3346
|
+
if (fileDecls !== undefined) {
|
|
3347
|
+
for (let depth = scopePath.length; depth > 0; depth -= 1) {
|
|
3348
|
+
const found = fileDecls.get(scopePath.slice(0, depth).join("."));
|
|
3349
|
+
if (found !== undefined)
|
|
3350
|
+
return found.id;
|
|
3351
|
+
}
|
|
3352
|
+
}
|
|
3353
|
+
return moduleNodeId.get(file);
|
|
3354
|
+
};
|
|
3355
|
+
for (const call of calls) {
|
|
3356
|
+
const receiver = call.receiver;
|
|
3357
|
+
const attribute = call.attribute;
|
|
3358
|
+
if (receiver === null || attribute === null)
|
|
3359
|
+
continue;
|
|
3360
|
+
const base = stubLocals.get(receiver);
|
|
3361
|
+
const path = base === undefined ? undefined : defs.get(base)?.methods.get(attribute);
|
|
3362
|
+
if (path === undefined)
|
|
3363
|
+
continue;
|
|
3364
|
+
const containerId = containerFor(call.scope);
|
|
3365
|
+
if (containerId === undefined)
|
|
3366
|
+
continue;
|
|
3367
|
+
const endpointId = mintEndpoint(path);
|
|
3368
|
+
push({ from: containerId, to: endpointId, type: "USES_API", attrs: { via: attribute } }, 2);
|
|
3369
|
+
}
|
|
3370
|
+
}
|
|
3371
|
+
}
|
|
3372
|
+
/**
|
|
3373
|
+
* Celery: a `@app.task`/`@shared_task`-decorated function is both the
|
|
3374
|
+
* queue's registration and its handler — no separate subscribe/run
|
|
3375
|
+
* correlation is needed, unlike `adapter-typescript`'s kafkajs/BullMQ
|
|
3376
|
+
* reader. See `celery.ts`'s module doc for the precision tradeoff
|
|
3377
|
+
* (a lighter gate than `routes.ts`'s router-provenance table) and the
|
|
3378
|
+
* name-derivation rule.
|
|
3379
|
+
*/
|
|
3380
|
+
function emitQueues() {
|
|
3381
|
+
const mintedEndpoints = new Set();
|
|
3382
|
+
const mintedRoutes = new Set();
|
|
3383
|
+
/** Declared FUNCTION node id -> the topic Celery registers it under —
|
|
3384
|
+
* only for tasks this run could actually name a topic for. */
|
|
3385
|
+
const taskTopicById = new Map();
|
|
3386
|
+
const importsCelery = (imports) => imports.some((record) => {
|
|
3387
|
+
const named = record.module ?? record.name;
|
|
3388
|
+
return named === "celery" || (named?.startsWith("celery.") ?? false);
|
|
3389
|
+
});
|
|
3390
|
+
// --- Pass 1: task declarations, gated per file on that file importing
|
|
3391
|
+
// `celery` at all — before any decorator text in it is even read.
|
|
3392
|
+
for (const file of files) {
|
|
3393
|
+
const parsed = input.parsed.files[file];
|
|
3394
|
+
if (!importsCelery(parsed.imports))
|
|
3395
|
+
continue;
|
|
3396
|
+
const byFull = byFullName.get(file);
|
|
3397
|
+
if (byFull === undefined)
|
|
3398
|
+
continue;
|
|
3399
|
+
for (const candidate of celeryTaskDeclarationsIn(parsed.declarations)) {
|
|
3400
|
+
const declared = byFull.get(candidate.path.join("."));
|
|
3401
|
+
if (declared === undefined || !isCallableNodeType(declared.type))
|
|
3402
|
+
continue;
|
|
3403
|
+
if (candidate.nameOpaque) {
|
|
3404
|
+
disclose(declared, "SERVES_API", declared.name, file, candidate.line, "celeryTaskNameNotLiteral");
|
|
3405
|
+
continue;
|
|
3406
|
+
}
|
|
3407
|
+
const topic = candidate.explicitName ?? `${modulePathOf(file).join(".")}.${candidate.path.join(".")}`;
|
|
3408
|
+
taskTopicById.set(declared.id, topic);
|
|
3409
|
+
// Fileless AND language-less (DEC-014), same as every other
|
|
3410
|
+
// `API_ENDPOINT` this adapter mints.
|
|
3411
|
+
const endpointId = nodeId(scope, "API_ENDPOINT", endpointQsp("PUBLISH", topic), null);
|
|
3412
|
+
if (!mintedEndpoints.has(endpointId)) {
|
|
3413
|
+
mintedEndpoints.add(endpointId);
|
|
3414
|
+
nodes.push({
|
|
3415
|
+
id: endpointId,
|
|
3416
|
+
type: "API_ENDPOINT",
|
|
3417
|
+
name: `PUBLISH ${topic}`,
|
|
3418
|
+
file: null,
|
|
3419
|
+
range: null,
|
|
3420
|
+
language: null,
|
|
3421
|
+
producedBy: input.producedBy,
|
|
3422
|
+
resolution: 2,
|
|
3423
|
+
attrs: { method: "PUBLISH", pathTemplate: topic },
|
|
3424
|
+
});
|
|
3425
|
+
}
|
|
3426
|
+
const routeId = nodeId(scope, "API_ROUTE", `queue:celery:${topic}`, LANGUAGE);
|
|
3427
|
+
if (!mintedRoutes.has(routeId)) {
|
|
3428
|
+
mintedRoutes.add(routeId);
|
|
3429
|
+
nodes.push({
|
|
3430
|
+
id: routeId,
|
|
3431
|
+
type: "API_ROUTE",
|
|
3432
|
+
name: `PUBLISH ${topic}`,
|
|
3433
|
+
file,
|
|
3434
|
+
range: { startLine: candidate.line, endLine: declared.range.endLine },
|
|
3435
|
+
language: LANGUAGE,
|
|
3436
|
+
producedBy: input.producedBy,
|
|
3437
|
+
resolution: 2,
|
|
3438
|
+
attrs: { method: "PUBLISH", pathTemplate: topic, framework: "celery" },
|
|
3439
|
+
});
|
|
3440
|
+
}
|
|
3441
|
+
push({ from: routeId, to: endpointId, type: "SERVES_API" }, 2);
|
|
3442
|
+
// The decorated function IS the handler — nothing to resolve, unlike
|
|
3443
|
+
// an inline callback argument.
|
|
3444
|
+
push({ from: routeId, to: declared.id, type: "CALLS" }, 2);
|
|
3445
|
+
}
|
|
3446
|
+
}
|
|
3447
|
+
// --- Pass 2: producer call sites, now every file's tasks are known -----
|
|
3448
|
+
for (const file of files) {
|
|
3449
|
+
const parsed = input.parsed.files[file];
|
|
3450
|
+
const calls = parsed.calls ?? [];
|
|
3451
|
+
const byFull = byFullName.get(file);
|
|
3452
|
+
const containerFor = (scopePath) => {
|
|
3453
|
+
if (byFull !== undefined) {
|
|
3454
|
+
for (let depth = scopePath.length; depth > 0; depth -= 1) {
|
|
3455
|
+
const found = byFull.get(scopePath.slice(0, depth).join("."));
|
|
3456
|
+
if (found !== undefined)
|
|
3457
|
+
return found;
|
|
3458
|
+
}
|
|
3459
|
+
}
|
|
3460
|
+
const moduleId = moduleNodeId.get(file);
|
|
3461
|
+
return moduleId === undefined ? undefined : { id: moduleId };
|
|
3462
|
+
};
|
|
3463
|
+
for (const call of celeryProducerCallsIn(calls)) {
|
|
3464
|
+
// `receiver` is non-null by `celeryProducerCallsIn`'s own filter.
|
|
3465
|
+
const target = followName(file, call.receiver);
|
|
3466
|
+
// Not a name this run can trace at all — silently out of scope, the
|
|
3467
|
+
// same as a `.add()` on a BullMQ receiver this reader never tied to
|
|
3468
|
+
// a `new Queue(...)` (`adapter-typescript`'s `queues.ts`): nothing
|
|
3469
|
+
// here says this is a computed *topic* name, only that this call's
|
|
3470
|
+
// receiver isn't one this reader resolved.
|
|
3471
|
+
if (target === undefined)
|
|
3472
|
+
continue;
|
|
3473
|
+
const topic = taskTopicById.get(target.id);
|
|
3474
|
+
// Resolved, but not a confirmed Celery task (or a task whose own
|
|
3475
|
+
// name= this run could not read) — same silent-out-of-scope reasoning.
|
|
3476
|
+
if (topic === undefined)
|
|
3477
|
+
continue;
|
|
3478
|
+
const container = containerFor(call.scope);
|
|
3479
|
+
if (container === undefined)
|
|
3480
|
+
continue;
|
|
3481
|
+
const endpointId = nodeId(scope, "API_ENDPOINT", endpointQsp("PUBLISH", topic), null);
|
|
3482
|
+
push({
|
|
3483
|
+
from: container.id,
|
|
3484
|
+
to: endpointId,
|
|
3485
|
+
type: "USES_API",
|
|
3486
|
+
attrs: { framework: "celery", via: call.attribute },
|
|
3487
|
+
}, 2);
|
|
3488
|
+
}
|
|
3489
|
+
}
|
|
3490
|
+
}
|
|
2957
3491
|
if (input.reached < 3)
|
|
2958
3492
|
return { nodes, edges, unresolved };
|
|
2959
3493
|
// --- pass 3: R3 — shapes ---------------------------------------------------
|