@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/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 ---------------------------------------------------