@descryy/adapter-python 0.1.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.
@@ -0,0 +1,2267 @@
1
+ /**
2
+ * Parsed Python to Canonical IR.
3
+ *
4
+ * The Python side reports what one file says; everything that needs two files
5
+ * happens here, where the resolution level is tracked and can be claimed
6
+ * honestly.
7
+ *
8
+ * ## Name binding, which is the whole of R2 for this adapter
9
+ *
10
+ * There is no type checker (DEC-062). A name is resolved by building, per file,
11
+ * the set of module-level bindings that Python itself would build:
12
+ *
13
+ * 1. imports — `from .money import format_money` binds `format_money`;
14
+ * 2. module-level declarations — a `def` or `class` in this file;
15
+ * 3. module-level instantiations — `service = OrderService()`, the one
16
+ * inference made anywhere in this adapter.
17
+ *
18
+ * Anything else resolves to nothing and is disclosed. `self.repo.save()`,
19
+ * `getattr(obj, name)()`, a name rebound in a loop — all absent, all recorded.
20
+ * That is the recall cost of having no checker, and it is stated rather than
21
+ * covered with a guess.
22
+ *
23
+ * ## The re-export hop
24
+ *
25
+ * `from . import validate_order` reaches `src/__init__.py`, which is not where
26
+ * `validate_order` is defined. Golden pattern 03 requires the edge to terminate
27
+ * at the definition and explicitly forbids it terminating at the package — the
28
+ * single most common symbol-resolution bug, and the one the TypeScript adapter
29
+ * hit three separate times. `followReexport` walks the hop, with a visited set
30
+ * because `a/__init__.py` importing from `b/__init__.py` importing back is legal
31
+ * Python and must not hang the run.
32
+ */
33
+ import { databaseTableNodesFromModel, edgeId, endpointQsp, isTypeLikeNodeType, nodeId, normaliseEndpointPath, symbolQsp, testCaseQsp, } from "@descryy/ir";
34
+ import { importRoots, modulePathOf, moduleNameOf, resolveImport, } from "./module.js";
35
+ import { DECLARATIVE_BASE_FACTORIES, ORM_BASE_MODULES, isFrameworkBaseName, ormShapeOf, } from "./orm.js";
36
+ import { globToRegExp, PYTEST_DEFAULTS } from "./pytest-config.js";
37
+ import { drfShapeOf, dtoShapeOf, isDrfSerializerRoot, isPydanticRoot, isValidatorDecorator, } from "./pydantic.js";
38
+ import { DRF_ACTIONS, drfRegisterCallsIn, drfRouterLocalsIn, djangoUrlEntriesIn, HTTP_METHODS, isApplicationRouter, joinPath, mountsIn, odooRouteFromDecorator, routeFromDecorator, routersIn, } from "./routes.js";
39
+ import { GRAPHQL_METHOD, fieldFromMethod, fieldsFromAttributes, operationPath, schemaDeclsIn, } from "./graphql.js";
40
+ import { clientCallsIn, clientLocalsIn } from "./client.js";
41
+ export const LANGUAGE = "python";
42
+ /**
43
+ * Confidence by the level an edge's evidence earned — never by the level the
44
+ * run reached. DEC-058: those were two different notions in the first adapter,
45
+ * and 83% of its edges claimed a level they had not earned.
46
+ */
47
+ const CONFIDENCE = { 0: 0.5, 1: 0.7, 2: 0.9, 3: 0.95 };
48
+ const REASONS = {
49
+ outside: "resolves to a name this run did not analyse — the standard library, an installed package, " +
50
+ "or a module outside the analysed set. A scope boundary, not an analysis gap.",
51
+ unknown: "no resolvable binding — the receiver's type is not inferable without a checker, or the " +
52
+ "target is chosen at runtime. This adapter has no type checker (DEC-062).",
53
+ unrecognisedTest: "this is in a file a test runner would collect and it looks like a test case, but no test " +
54
+ "style this adapter understands matched it. NOT the same as 'this code has no tests' — it " +
55
+ "means this adapter could not read the tests that are there.",
56
+ testDiscoveryFailed: "TEST DISCOVERY PRODUCED NOTHING. This repository contains files a test runner would " +
57
+ "collect, and this run emitted no test cases at all. Any statement that code here is " +
58
+ "untested is UNSUPPORTED — the correct statement is that this adapter could not read the " +
59
+ "tests. Check the collection rules named in this row against the project's own config.",
60
+ testDiscoveryPartial: "test discovery is incomplete: files a test runner would collect produced no test case. " +
61
+ "Coverage claims over this repository are capped by the ratio in this row.",
62
+ routeReceiverUnknown: "this decorator is spelled like an HTTP route, but its receiver does not resolve to a " +
63
+ "router or application constructed from a web framework's own module. Reading it as a " +
64
+ "route on the strength of the verb alone would invent 1,150 routes on saleor, where every " +
65
+ "one is `unittest.mock.patch`.",
66
+ routePathNotLiteral: "the route's path is written as an expression rather than a literal, so the path it serves " +
67
+ "is not knowable from source. Emitting a route here would attach a caller to an endpoint " +
68
+ "that does not exist, and nothing above the graph could tell that from a correct edge.",
69
+ routePrefixNotLiteral: "the route's path is a literal, but a prefix somewhere in its mount chain is not, so the " +
70
+ "served path cannot be assembled. The route exists; where it is served is unreadable.",
71
+ callPathNotLiteral: "an HTTP call whose request path is not a literal or an f-string. The call is real and its " +
72
+ "target is assembled somewhere this reader cannot follow, so no endpoint is claimed for it.",
73
+ callPathNotRelative: "an HTTP call to an absolute URL, which names a host this repository does not serve. Read " +
74
+ "correctly and out of scope for the join rather than unreadable — there is no route on this " +
75
+ "side for it to meet.",
76
+ callMethodNotLiteral: "a requests.request()/httpx.request() call whose verb is not a literal, so no single method " +
77
+ "describes it. Refused rather than filed under a guess.",
78
+ callBaseUnresolved: "an HTTP call written as f\"{BASE}/path\" whose base this file does not bind to a string " +
79
+ "literal. The path half is readable and the base half is not, so the served template cannot " +
80
+ "be assembled — the named base is the piece of work that would close it.",
81
+ callBaseIsExternalHost: "an HTTP call written as f\"{BASE}/path\" whose base resolves to an absolute URL on another " +
82
+ "host. Read correctly and out of scope for the join: an endpoint is identified by method and " +
83
+ "path with no host (DEC-014), so claiming one here would let this caller join a local route " +
84
+ "of the same path that it never reaches.",
85
+ callPathFromLocal: "an HTTP call whose path is a local assembled earlier in the function. Distinguished from an " +
86
+ "opaque path because it is reachable by further work, and filing the two together would " +
87
+ "report one number for two populations.",
88
+ routeNeverMounted: "this route is declared on a router that no source file mounts, so the path it serves is " +
89
+ "unknowable. Commonly a plugin router mounted by the framework at runtime.",
90
+ routeIdCollision: "this route's method and path are identical to one already emitted, so it collapsed onto " +
91
+ "the same API_ROUTE node and this declaration's own SERVES_API edge and location were " +
92
+ "dropped. The endpoint is real; this specific declaration is not the one the graph kept.",
93
+ djangoPathNotLiteral: "a Django path()/re_path()/url() call whose route is written as anything but a literal — " +
94
+ "or, for re_path()/url(), a regex this reader will not translate because it is not just a " +
95
+ "literal segment with named groups. Emitting a route here would attach a caller to a path " +
96
+ "that does not exist.",
97
+ djangoIncludeUnresolved: "a Django include(...) whose target module string does not resolve to a file this run " +
98
+ "analysed — an installed app, a module outside the analysed set, or a name this reader does " +
99
+ "not evaluate (a computed string, a (module, namespace) tuple). The routes behind it are real " +
100
+ "and unreadable from here.",
101
+ odooPathNotLiteral: "an @http.route(...) whose path is written as anything but a literal, so the path it serves " +
102
+ "is not knowable from source.",
103
+ odooMethodsNotLiteral: "an @http.route(...) whose methods= is written but not readable as a list of literals — " +
104
+ "defaulting to GET here would report the opposite of the source as fact.",
105
+ graphqlFieldNameNotReadable: "a declared strawberry root-operation field whose SCHEMA-FACING name cannot be read out of " +
106
+ "the source. strawberry's default is auto_camel_case=True, so `circuit_list` in Python is " +
107
+ "`circuitList` on the wire — and this run could not resolve the schema's StrawberryConfig to " +
108
+ "a written boolean, so which of the two the server actually serves is not stated anywhere " +
109
+ "this reader can see. The field is real; its name is unreadable. Computing it from the " +
110
+ "library's default would write down a string that appears nowhere in the repository, and a " +
111
+ "consumer reading the real wire name would mint a different id and join nothing — DEC-055's " +
112
+ "failure mode exactly. Fixed at the source by writing an explicit name= on the field, or by " +
113
+ "passing config=StrawberryConfig(auto_camel_case=...) as a literal at the schema call.",
114
+ graphqlRootBaseUnreadable: "a strawberry root operation class inherits from a base this reader cannot resolve to an " +
115
+ "analysed class — a starred unpacking of a plugin registry, or a name outside the analysed " +
116
+ "set. Every field that base contributes is a real operation and none of them are in this " +
117
+ "graph. A recall gap on this root, not a reason to drop the bases that did resolve.",
118
+ graphqlRootSlotUnreadable: "a strawberry.Schema(...) whose query=/mutation=/subscription= slot is written as something " +
119
+ "other than a bare class name, so the root type it puts in that slot cannot be followed. " +
120
+ "The operations under it are real and unreadable from here.",
121
+ };
122
+ // --- pytest -----------------------------------------------------------------
123
+ /**
124
+ * pytest's own default collection rule, not a guess about intent.
125
+ *
126
+ * `test_*.py` files, `test_*` functions, `Test*` classes — a documented,
127
+ * machine-checkable convention, which is the same standing `describe`/`it` have
128
+ * in the TypeScript adapter. It is a world away from deciding that a class is a
129
+ * `MODEL` because of its name (DEC-043): the runner will genuinely collect these
130
+ * and will genuinely not collect anything else.
131
+ */
132
+ function isPytestFile(file, config) {
133
+ const base = file.split("/").pop() ?? "";
134
+ return config.filePatterns.some((pattern) => globToRegExp(pattern).test(base));
135
+ }
136
+ /**
137
+ * Could this file hold tests, **independently of what the project's config says**?
138
+ *
139
+ * This is the file gate for the *safety net*, and it must not be the gate that
140
+ * emits nodes. The two were the same predicate, and that is precisely why Sentry
141
+ * went dark: one malformed `python_files` value made `isPytestFile` false for
142
+ * every file in the repository, so the net that exists to catch a failed
143
+ * understanding was switched off by the same failure it was built to report.
144
+ * **A safety net wired to the thing it is protecting against is not a safety net.**
145
+ *
146
+ * Three independent signals, unioned:
147
+ *
148
+ * 1. the project's own configured patterns — the normal case;
149
+ * 2. pytest's documented defaults, which hold whatever a project misconfigures;
150
+ * 3. a `test`/`tests` path segment, which catches a project that renames its
151
+ * files entirely but still keeps them where every runner looks.
152
+ *
153
+ * Signals 2 and 3 are what make this survive a configuration this adapter reads
154
+ * wrongly, reads partially, or cannot read at all.
155
+ */
156
+ function couldHoldTests(file, config) {
157
+ if (isPytestFile(file, config))
158
+ return true;
159
+ const base = file.split("/").pop() ?? "";
160
+ if (PYTEST_DEFAULTS.filePatterns.some((pattern) => globToRegExp(pattern).test(base)))
161
+ return true;
162
+ return file.split("/").slice(0, -1).some((segment) => segment === "test" || segment === "tests");
163
+ }
164
+ /**
165
+ * Modules whose classes are test bases, so a subclass is a suite whatever it is
166
+ * called. **Provenance beats naming**: `unittest.IsolatedAsyncioTestCase` does
167
+ * not end in `TestCase`, and a rule that matched on the name missed six of
168
+ * sherpa-backend's tests even after it was taught about `unittest`.
169
+ *
170
+ * The list is short on purpose. It is not the mechanism that has to be complete
171
+ * — rule 2 covers unlisted libraries by name and rule 3 covers in-repo bases —
172
+ * and anything all three miss is disclosed to the ledger rather than dropped.
173
+ */
174
+ const TEST_LIBRARY_MODULES = new Set([
175
+ "unittest",
176
+ "unittest.case",
177
+ "unittest.async_case",
178
+ "unittest2",
179
+ "asynctest",
180
+ "django.test",
181
+ "rest_framework.test",
182
+ "tornado.testing",
183
+ "twisted.trial.unittest",
184
+ "absl.testing.absltest",
185
+ "absl.testing.parameterized",
186
+ ]);
187
+ /**
188
+ * Does this declaration *look* like a test case, whether or not we can model it?
189
+ *
190
+ * **This is deliberately broader than every rule that emits a node**, and that
191
+ * gap is the whole point. Measured over seven Python codebases, test detection
192
+ * fails all-or-nothing per repository rather than evenly: `pandas` is at 99.6%
193
+ * and `sherpa-backend` and PySpark are at 0% and 0.1%. A repository whose style
194
+ * we do not handle goes completely dark, and with no disclosure the report says
195
+ * *"no tests cover this code"* when the truth is *"this adapter did not read
196
+ * your tests"* — §20.2's two different statements, collapsed into the wrong one.
197
+ *
198
+ * Anything matching here and *not* becoming a `TEST_CASE` is written to the
199
+ * ledger. That converts a silent wrong answer into a counted gap, and unlike
200
+ * every other fix on this list it holds for test styles nobody has thought of
201
+ * yet — which is the only kind of protection worth having against the next
202
+ * repository.
203
+ *
204
+ * The file gate is the runner's own rule, so a production function that happens
205
+ * to be called `test_odoo_connection` is not swept in. That was a real false
206
+ * positive in the survey that produced these numbers.
207
+ */
208
+ function looksLikeTestCase(declaration, file, config) {
209
+ // **The project's own rule, deliberately — not the wider `couldHoldTests`.**
210
+ // This net answers "a test style we cannot model", and a project that declares
211
+ // `python_files = ["check_*.py"]` has genuinely excluded `test_ignored.py`;
212
+ // flagging it would be a gap that is not a gap. The wholesale case — a
213
+ // configuration this adapter reads wrongly, so *nothing* matches — is caught
214
+ // by the repository-level audit at the end of `extract`, which is
215
+ // config-independent precisely because this predicate is not.
216
+ if (declaration.kind !== "function" || !isPytestFile(file, config))
217
+ return false;
218
+ const own = declaration.path[declaration.path.length - 1] ?? "";
219
+ if (!config.functionPrefixes.some((prefix) => own.startsWith(prefix)))
220
+ return false;
221
+ // **A runner collects a module-level function or one method of a class, and
222
+ // nothing deeper.** Without this the net reported PySpark's
223
+ // `test_scalar_iter_udf_close.test_close` — a helper defined *inside* a test
224
+ // method — as a test it could not read. Over-reporting is the safer direction
225
+ // for a safety net, but a gap that is not a gap still costs the reader trust.
226
+ if (declaration.path.length > 2)
227
+ return false;
228
+ // Nor does a runner collect a property. `HasThrowableProperty.test_property`
229
+ // is a `@property` on an ordinary helper class, and it is not a test.
230
+ return !(declaration.decorators ?? []).some((decorator) => {
231
+ const leaf = (decorator ?? "").split(".").pop() ?? "";
232
+ return leaf === "property" || leaf === "cached_property";
233
+ });
234
+ }
235
+ /**
236
+ * Which of two declarations of one name should own the node.
237
+ *
238
+ * A `@typing.overload` stub is never the answer — it has no body worth pointing
239
+ * at. A `@property` getter is preferred to its setter, because the getter is
240
+ * what the name evaluates to. Otherwise the widest span, which is deterministic
241
+ * and picks the more complete of two conditional definitions.
242
+ */
243
+ function preferredDeclaration(a, b) {
244
+ const decorated = (entry, leaf) => (entry.declaration.decorators ?? []).some((d) => (d ?? "").split(".").pop() === leaf);
245
+ const stub = (entry) => decorated(entry, "overload");
246
+ if (stub(a) !== stub(b))
247
+ return stub(a) ? b : a;
248
+ const getter = (entry) => decorated(entry, "property");
249
+ if (getter(a) !== getter(b))
250
+ return getter(a) ? a : b;
251
+ const width = (entry) => entry.range.endLine - entry.range.startLine;
252
+ return width(b) > width(a) ? b : a;
253
+ }
254
+ /** Names an annotation mentions, as written. `Optional[Order]` -> ["Optional", "Order"]. */
255
+ function typeNamesIn(annotation) {
256
+ if (annotation === null || annotation === undefined)
257
+ return [];
258
+ return [...new Set(annotation.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? [])];
259
+ }
260
+ /** `Optional[str]`, `str | None` and `None` are the three ways Python writes nullable. */
261
+ function isNullable(annotation, hasDefault) {
262
+ if (annotation === null || annotation === undefined)
263
+ return hasDefault;
264
+ return /\bOptional\s*\[/.test(annotation) || /\bNone\b/.test(annotation) || hasDefault;
265
+ }
266
+ /**
267
+ * The graph to return when the parse produced nothing but the files are there.
268
+ *
269
+ * **A failed analysis must not look like an empty repository.** The two were
270
+ * indistinguishable, and home-assistant-core and posthog each came back with 0
271
+ * nodes over ~18,000 files and a successful R0 status.
272
+ *
273
+ * A `MODULE` per file is honest at R0 and needs no parse: the file exists and
274
+ * its module path is a fact about the filesystem. It also gives the disclosure
275
+ * a real node to hang from — the ledger requires a valid `fromNodeId`, and the
276
+ * first attempt at this invented one and was rightly rejected by the Normaliser
277
+ * as `MALFORMED_UNRESOLVED_REF`, which would have thrown away the very warning
278
+ * it was carrying.
279
+ *
280
+ * The result reads as what it is: every module present, every module empty, and
281
+ * one row saying loudly why.
282
+ */
283
+ export function degradedGraph(input) {
284
+ const scope = { repo: input.repo, workspace: input.workspace };
285
+ const nodes = input.files.map((file) => {
286
+ const modulePath = modulePathOf(file);
287
+ return {
288
+ id: nodeId(scope, "MODULE", symbolQsp(input.packageName, modulePath), LANGUAGE),
289
+ type: "MODULE",
290
+ name: moduleNameOf(file),
291
+ file,
292
+ range: { startLine: 1, endLine: 1 },
293
+ language: LANGUAGE,
294
+ producedBy: input.producedBy,
295
+ resolution: 0,
296
+ attrs: { parsed: false },
297
+ };
298
+ });
299
+ const anchor = nodes[0];
300
+ const unresolved = anchor === undefined
301
+ ? []
302
+ : [
303
+ {
304
+ fromNodeId: anchor.id,
305
+ edgeType: "IMPORTS",
306
+ rawTarget: "(entire repository)",
307
+ file: anchor.file,
308
+ line: 1,
309
+ producedBy: input.producedBy,
310
+ reason: `NO GRAPH WAS PRODUCED for ${input.files.length} Python file(s). ${input.cause}. ` +
311
+ "Modules are listed because their paths are a fact about the filesystem; their " +
312
+ "contents are absent. Every finding, coverage figure and 'not affected' statement " +
313
+ "over this repository is UNSUPPORTED — this is a failed analysis, not an empty " +
314
+ "repository.",
315
+ },
316
+ ];
317
+ return { nodes, edges: [], unresolved };
318
+ }
319
+ export function extract(input) {
320
+ const scope = { repo: input.repo, workspace: input.workspace };
321
+ const pytest = input.pytest ?? PYTEST_DEFAULTS;
322
+ const files = Object.keys(input.parsed.files).sort();
323
+ const known = new Set(files);
324
+ const roots = importRoots(files);
325
+ const pkg = input.packageName;
326
+ const nodes = [];
327
+ const edges = [];
328
+ const unresolved = [];
329
+ const seenEdge = new Set();
330
+ /** file -> declaration name (first segment) -> Declared */
331
+ const declaredIn = new Map();
332
+ /** node id -> the entry owning it, where it sits in `nodes`, and how many
333
+ * source declarations merged into it. */
334
+ const emittedById = new Map();
335
+ /** Node ids that are type aliases rather than real classes — see the RETURNS pass. */
336
+ const aliasIds = new Set();
337
+ const bindingsOf = new Map();
338
+ const importsResolved = new Map();
339
+ /**
340
+ * Import resolution, memoised.
341
+ *
342
+ * An absolute specifier is tried against **every import root**, and posthog
343
+ * has 2,288 of them against home-assistant-core's 24 — a repository's layout,
344
+ * not its size, decides how expensive one import is. The same specifier also
345
+ * repeats across thousands of files: `from django.db import models` resolves
346
+ * identically wherever it appears.
347
+ *
348
+ * The key holds everything the result depends on. For an absolute import that
349
+ * is the specifier alone. For a relative one it is also the importing file's
350
+ * *package path*, because `from . import x` means different things in
351
+ * different packages — keying relative imports on the specifier alone would
352
+ * be a correctness bug, not an optimisation.
353
+ */
354
+ const resolutionCache = new Map();
355
+ const resolveImportCached = (record, fromFile) => {
356
+ const anchor = record.level > 0
357
+ ? `${fromFile.endsWith("__init__.py") ? "p" : "m"}:${modulePathOf(fromFile).join(".")}`
358
+ : "";
359
+ const key = `${record.level}|${record.module ?? ""}|${record.name ?? ""}|${anchor}`;
360
+ let target = resolutionCache.get(key);
361
+ if (target === undefined) {
362
+ target = resolveImport(record, fromFile, { known, roots });
363
+ resolutionCache.set(key, target);
364
+ }
365
+ return target;
366
+ };
367
+ for (const file of files) {
368
+ const parsed = input.parsed.files[file];
369
+ const bindings = new Map();
370
+ bindingsOf.set(file, bindings);
371
+ const resolutions = [];
372
+ importsResolved.set(file, resolutions);
373
+ for (const record of parsed.imports) {
374
+ const target = resolveImportCached(record, file);
375
+ resolutions.push({ record, target });
376
+ if (target.file === undefined)
377
+ continue;
378
+ bindings.set(record.local, {
379
+ file: target.file,
380
+ symbol: target.symbol,
381
+ viaPackageInit: target.viaPackageInit,
382
+ record,
383
+ });
384
+ }
385
+ }
386
+ // --- which classes are test suites, decided from evidence not from names ---
387
+ /** file -> top-level class name -> the bases it was written with. */
388
+ const basesOf = new Map();
389
+ for (const file of files) {
390
+ const byName = new Map();
391
+ basesOf.set(file, byName);
392
+ for (const declaration of input.parsed.files[file].declarations) {
393
+ if (declaration.kind !== "class" || declaration.path.length !== 1)
394
+ continue;
395
+ byName.set(declaration.path[0], (declaration.bases ?? []).map((base) => base ?? ""));
396
+ }
397
+ }
398
+ /** The file a bare class name resolves to, following package re-exports. */
399
+ function classFileOf(startFile, name, depth = 0) {
400
+ if (depth > 8)
401
+ return undefined;
402
+ if (basesOf.get(startFile)?.has(name) === true)
403
+ return startFile;
404
+ const binding = bindingsOf.get(startFile)?.get(name);
405
+ if (binding === undefined)
406
+ return undefined;
407
+ return classFileOf(binding.file, binding.symbol ?? name, depth + 1);
408
+ }
409
+ /**
410
+ * Is this class a test suite?
411
+ *
412
+ * Three rules, in strength order, and the third is the one that matters at
413
+ * scale. Measured over seven Python codebases: **2,158 of PySpark's 2,282 test
414
+ * cases inherit from a shared in-repo base rather than from the test library
415
+ * directly**, so rules 1 and 2 alone would leave that repository at 5%. Teams
416
+ * write one base class and hang hundreds of tests off it; following the chain
417
+ * is not a refinement of this fix, for such a repository it *is* the fix.
418
+ *
419
+ * 1. **The base came from a test library.** Where a name came from is written
420
+ * in the imports and is not a guess. This catches `IsolatedAsyncioTestCase`
421
+ * and anything the library adds next, which name matching never will.
422
+ * 2. **The base's own name ends in `TestCase`.** The one name rule kept, and
423
+ * kept deliberately: it covers Django's `SimpleTestCase`, DRF's
424
+ * `APITestCase` and every library nobody has listed, at the cost of a
425
+ * class that ends in `TestCase` and is not one — which would be a strange
426
+ * thing to write.
427
+ * 3. **The base is a class in the analysed set that is itself a suite.**
428
+ * Recursive and cross-file. `seen` guards the cycles that legal Python
429
+ * permits through circular package imports.
430
+ */
431
+ function isTestSuiteClass(file, name, seen = new Set()) {
432
+ const key = `${file}:${name}`;
433
+ if (seen.has(key) || seen.size > 32)
434
+ return false;
435
+ seen.add(key);
436
+ const bases = basesOf.get(file)?.get(name);
437
+ if (bases === undefined)
438
+ return false;
439
+ const parsed = input.parsed.files[file];
440
+ for (const base of bases) {
441
+ if (base === "")
442
+ continue;
443
+ const root = base.split(".")[0];
444
+ const leaf = base.split(".").pop();
445
+ // 1 — provenance.
446
+ const fromTestLibrary = parsed.imports.some((record) => {
447
+ const module = record.module ?? "";
448
+ if (!TEST_LIBRARY_MODULES.has(module))
449
+ return false;
450
+ // `import unittest` binds the module; `from unittest import TestCase`
451
+ // binds the symbol. Either way the local name is what the base uses.
452
+ return record.local === root || record.local === leaf;
453
+ });
454
+ if (fromTestLibrary)
455
+ return true;
456
+ // 2 — the one name rule kept.
457
+ if (leaf.endsWith("TestCase"))
458
+ return true;
459
+ // 3 — the chain, across files.
460
+ const owner = classFileOf(file, leaf);
461
+ if (owner !== undefined && isTestSuiteClass(owner, leaf, seen))
462
+ return true;
463
+ }
464
+ return false;
465
+ }
466
+ // --- which classes are ORM models, decided from evidence not from names ----
467
+ //
468
+ // The same three-rule shape as `isTestSuiteClass`, with rule 2 deliberately
469
+ // removed. That rule is a name rule — a base whose name ends in `TestCase` —
470
+ // and it is affordable there because being wrong misfiles a test. Being wrong
471
+ // here mints a `MODEL` that no table backs, and every data-propagation
472
+ // traversal above the graph then reasons about a database column that does not
473
+ // exist. Provenance, or a chain that reaches provenance, is the only entry.
474
+ /** Names bound at module level to the result of a declarative-base factory. */
475
+ const declarativeBases = new Map();
476
+ for (const file of files) {
477
+ const parsed = input.parsed.files[file];
478
+ const bound = new Set();
479
+ for (const instantiation of parsed.instantiations) {
480
+ if (instantiation.scope.length !== 0)
481
+ continue;
482
+ const leaf = instantiation.typeName.split(".").pop() ?? "";
483
+ if (!DECLARATIVE_BASE_FACTORIES.has(leaf))
484
+ continue;
485
+ // Provenance again: `declarative_base` has to have come from SQLAlchemy.
486
+ // A project is entitled to a function of its own by that name.
487
+ const fromSqlAlchemy = parsed.imports.some((record) => ORM_BASE_MODULES.sqlalchemy.has(record.module ?? "") &&
488
+ (record.local === leaf || record.local === instantiation.typeName.split(".")[0]));
489
+ if (fromSqlAlchemy)
490
+ bound.add(instantiation.local);
491
+ }
492
+ declarativeBases.set(file, bound);
493
+ }
494
+ /**
495
+ * Which ORM, if any, declared this class — following bases across files.
496
+ *
497
+ * `seen` guards the cycles circular package imports make legal, exactly as it
498
+ * does for test suites.
499
+ */
500
+ function ormFrameworkOf(file, name, seen = new Set()) {
501
+ const key = `${file}:${name}`;
502
+ if (seen.has(key) || seen.size > 32)
503
+ return null;
504
+ seen.add(key);
505
+ const bases = basesOf.get(file)?.get(name);
506
+ if (bases === undefined)
507
+ return null;
508
+ const parsed = input.parsed.files[file];
509
+ for (const base of bases) {
510
+ if (base === "")
511
+ continue;
512
+ const root = base.split(".")[0];
513
+ const leaf = base.split(".").pop();
514
+ // 1 — provenance. Written in the import statement, so a model is
515
+ // recognisable at R0; only rule 3 needs imports resolved.
516
+ for (const framework of ["sqlalchemy", "django"]) {
517
+ if (!isFrameworkBaseName(leaf, framework))
518
+ continue;
519
+ const fromFramework = parsed.imports.some((record) => {
520
+ if (!ORM_BASE_MODULES[framework].has(record.module ?? ""))
521
+ return false;
522
+ return record.local === root || record.local === leaf;
523
+ });
524
+ if (fromFramework)
525
+ return framework;
526
+ }
527
+ // 2 — a module-level declarative base, which is what SQLAlchemy 1.x makes
528
+ // the base of every model in the project.
529
+ if (declarativeBases.get(file)?.has(root) === true)
530
+ return "sqlalchemy";
531
+ // 3 — the chain, across files. A project's own `class BaseModel(Base)`.
532
+ const owner = classFileOf(file, leaf);
533
+ if (owner !== undefined) {
534
+ const inherited = ormFrameworkOf(owner, leaf, seen);
535
+ if (inherited !== null)
536
+ return inherited;
537
+ if (declarativeBases.get(owner)?.has(leaf) === true)
538
+ return "sqlalchemy";
539
+ }
540
+ }
541
+ return null;
542
+ }
543
+ /**
544
+ * Does this class's base chain reach a Pydantic root?
545
+ *
546
+ * Recursive and **across files**, for the reason DEC-073 recorded about test
547
+ * suites: real code declares one `DispatchBase(BaseModel)` and inherits from
548
+ * it everywhere else. Rules that only read a direct base would find 1 model
549
+ * on dispatch instead of 449.
550
+ */
551
+ const pydanticVerdict = new Map();
552
+ function isPydanticClass(file, name, seen = new Set()) {
553
+ const key = `${file}:${name}`;
554
+ const cached = pydanticVerdict.get(key);
555
+ if (cached !== undefined)
556
+ return cached;
557
+ if (seen.has(key) || seen.size > 32)
558
+ return false; // a cycle is legal to write
559
+ seen.add(key);
560
+ // `basesOf` and `classFileOf` are DEC-073's pass 0, built before any node
561
+ // type is decided precisely so a base can be followed across files while
562
+ // this question is being asked. Reaching for `declaredIn` here instead
563
+ // would read a table that pass 1 is still filling in, and the answer would
564
+ // depend on the order files happen to be walked.
565
+ const bases = basesOf.get(file)?.get(name);
566
+ if (bases === undefined) {
567
+ pydanticVerdict.set(key, false);
568
+ return false;
569
+ }
570
+ const parsed = input.parsed.files[file];
571
+ let answer = false;
572
+ for (const base of bases) {
573
+ if (base === "")
574
+ continue;
575
+ const root = base.split(".")[0].split("[")[0];
576
+ const leaf = base.split(".").pop().split("[")[0];
577
+ // 1 — provenance. `from pydantic import BaseModel` binds the symbol;
578
+ // `import pydantic` binds the module and the base reads `pydantic.BaseModel`.
579
+ const record = parsed.imports.find((r) => r.local === root || r.local === leaf);
580
+ const origin = record === undefined ? undefined : (record.module ?? record.name ?? undefined);
581
+ if (isPydanticRoot(base, origin)) {
582
+ answer = true;
583
+ break;
584
+ }
585
+ // 2 — no name rule. `orm.ts` removed the one `isTestSuiteClass` keeps,
586
+ // and for the same reason: a wrong `MODEL` claims a table that does not
587
+ // exist, and a wrong `DTO` claims a wire format that does not exist.
588
+ // A class called `BaseModel` proves nothing.
589
+ // 3 — the chain, across files. This is where 448 of dispatch's 449 come
590
+ // from: real code declares one `DispatchBase(BaseModel)` and inherits it.
591
+ const owner = classFileOf(file, leaf);
592
+ if (owner !== undefined && isPydanticClass(owner, leaf, seen)) {
593
+ answer = true;
594
+ break;
595
+ }
596
+ }
597
+ pydanticVerdict.set(key, answer);
598
+ return answer;
599
+ }
600
+ /**
601
+ * Does this class's base chain reach a DRF serializer root? Same
602
+ * across-file chain walk as `isPydanticClass`, against a different root
603
+ * set — a project's own `OrderSerializerBase(serializers.ModelSerializer)`
604
+ * needs the same following.
605
+ */
606
+ const drfVerdict = new Map();
607
+ function isDrfSerializerClass(file, name, seen = new Set()) {
608
+ const key = `${file}:${name}`;
609
+ const cached = drfVerdict.get(key);
610
+ if (cached !== undefined)
611
+ return cached;
612
+ if (seen.has(key) || seen.size > 32)
613
+ return false;
614
+ seen.add(key);
615
+ const bases = basesOf.get(file)?.get(name);
616
+ if (bases === undefined) {
617
+ drfVerdict.set(key, false);
618
+ return false;
619
+ }
620
+ const parsed = input.parsed.files[file];
621
+ let answer = false;
622
+ for (const base of bases) {
623
+ if (base === "")
624
+ continue;
625
+ const root = base.split(".")[0].split("[")[0];
626
+ const leaf = base.split(".").pop().split("[")[0];
627
+ const record = parsed.imports.find((r) => r.local === root || r.local === leaf);
628
+ const origin = record === undefined ? undefined : (record.module ?? record.name ?? undefined);
629
+ if (isDrfSerializerRoot(base, origin)) {
630
+ answer = true;
631
+ break;
632
+ }
633
+ const owner = classFileOf(file, leaf);
634
+ if (owner !== undefined && isDrfSerializerClass(owner, leaf, seen)) {
635
+ answer = true;
636
+ break;
637
+ }
638
+ }
639
+ drfVerdict.set(key, answer);
640
+ return answer;
641
+ }
642
+ /** The validated shape of a class, or `null` where nothing declares one. */
643
+ function dtoShapeOfDeclaration(file, declaration) {
644
+ if (declaration.kind !== "class" || declaration.path.length !== 1)
645
+ return null;
646
+ if (isPydanticClass(file, declaration.path[0])) {
647
+ const validators = input.parsed.files[file].declarations.filter((other) => other.kind === "function" &&
648
+ other.path.length === 2 &&
649
+ other.path[0] === declaration.name &&
650
+ (other.decorators ?? []).some((d) => isValidatorDecorator(d))).length;
651
+ return dtoShapeOf({
652
+ attributes: declaration.attributes ?? [],
653
+ annotatedFields: declaration.fields ?? [],
654
+ validatorCount: validators,
655
+ });
656
+ }
657
+ if (isDrfSerializerClass(file, declaration.path[0])) {
658
+ return drfShapeOf({ attributes: declaration.attributes ?? [] });
659
+ }
660
+ return null;
661
+ }
662
+ /** The mapped shape of a class, or `null` where nothing declares one. */
663
+ function ormModelOf(file, declaration) {
664
+ if (declaration.kind !== "class" || declaration.path.length !== 1)
665
+ return null;
666
+ const framework = ormFrameworkOf(file, declaration.path[0]);
667
+ if (framework === null)
668
+ return null;
669
+ const meta = input.parsed.files[file].declarations.find((other) => other.kind === "class" && other.name === `${declaration.name}.Meta`)?.attributes ?? [];
670
+ return ormShapeOf({ framework, attributes: declaration.attributes ?? [], meta });
671
+ }
672
+ /** Is this declaration a test case any supported runner would collect? */
673
+ function isTestCase(declaration, file) {
674
+ if (declaration.kind !== "function" || !isPytestFile(file, pytest))
675
+ return false;
676
+ const own = declaration.path[declaration.path.length - 1];
677
+ // The prefixes are the repository's, not this adapter's. `python_files`,
678
+ // `python_classes` and `python_functions` are pytest's to define and a
679
+ // project's to change, and a hard-coded `test` is the same class of mistake
680
+ // as deciding a suite by its name.
681
+ if (!pytest.functionPrefixes.some((prefix) => own.startsWith(prefix)))
682
+ return false;
683
+ if (declaration.path.length === 1)
684
+ return true;
685
+ if (declaration.path.length !== 2)
686
+ return false;
687
+ // pytest's plain-class convention, then the unittest family. pytest itself
688
+ // collects a `unittest` subclass whatever it is called, so the name rule
689
+ // alone was never the framework's own rule.
690
+ const suite = declaration.path[0];
691
+ return (pytest.classPrefixes.some((prefix) => suite.startsWith(prefix)) ||
692
+ isTestSuiteClass(file, suite));
693
+ }
694
+ /** node id -> the mapped shape that promoted it, for the passes that come later. */
695
+ const ormShapes = new Map();
696
+ /** node id -> the validated shape that promoted it to a `DTO`. */
697
+ const dtoShapes = new Map();
698
+ /** Route and endpoint ids already emitted — two routers may serve one path. */
699
+ const routeSeen = new Set();
700
+ /** file -> full dotted name -> Declared, so `OrderService.calculate_total` is reachable. */
701
+ const byFullName = new Map();
702
+ const moduleNodeId = new Map();
703
+ const push = (edge, level) => {
704
+ const key = edgeId(edge.from, edge.to, edge.type);
705
+ if (seenEdge.has(key))
706
+ return;
707
+ seenEdge.add(key);
708
+ edges.push({
709
+ ...edge,
710
+ resolution: level,
711
+ confidence: CONFIDENCE[level] ?? 0.5,
712
+ producedBy: input.producedBy,
713
+ });
714
+ };
715
+ const disclose = (from, edgeType, rawTarget, file, line, reason, attrs) => {
716
+ unresolved.push({
717
+ fromNodeId: from.id,
718
+ edgeType,
719
+ rawTarget,
720
+ file,
721
+ line,
722
+ producedBy: input.producedBy,
723
+ reason: REASONS[reason],
724
+ ...(attrs === undefined ? {} : { attrs }),
725
+ });
726
+ };
727
+ // --- pass 1: nodes --------------------------------------------------------
728
+ for (const file of files) {
729
+ const parsed = input.parsed.files[file];
730
+ const modulePath = modulePathOf(file);
731
+ const moduleId = nodeId(scope, "MODULE", symbolQsp(pkg, modulePath), LANGUAGE);
732
+ moduleNodeId.set(file, moduleId);
733
+ // DEC-124: envReads/envDynamicReads/migrationOps carry on MODULE, not
734
+ // FILE — no shipped adapter emits a FILE node, so that carrier was
735
+ // silently dead everywhere.
736
+ const fileScopedAttrs = {
737
+ ...(parsed.envReads !== undefined && parsed.envReads.length > 0 ? { envReads: parsed.envReads } : {}),
738
+ ...(parsed.envDynamicReads !== undefined && parsed.envDynamicReads > 0
739
+ ? { envDynamicReads: parsed.envDynamicReads }
740
+ : {}),
741
+ ...(parsed.migrationOps !== undefined && parsed.migrationOps.length > 0
742
+ ? { migrationOps: parsed.migrationOps }
743
+ : {}),
744
+ };
745
+ nodes.push({
746
+ id: moduleId,
747
+ type: "MODULE",
748
+ name: moduleNameOf(file),
749
+ file,
750
+ range: { startLine: 1, endLine: 1 },
751
+ language: LANGUAGE,
752
+ producedBy: input.producedBy,
753
+ resolution: 0,
754
+ attrs: { ...(file.endsWith("__init__.py") ? { declarationForm: "package" } : {}), ...fileScopedAttrs },
755
+ });
756
+ const byName = new Map();
757
+ const byFull = new Map();
758
+ declaredIn.set(file, byName);
759
+ byFullName.set(file, byFull);
760
+ for (const declaration of parsed.declarations) {
761
+ // A pytest case is a plain `def`, unlike `it("…", fn)` which is a call and
762
+ // declares nothing. Emitting both a FUNCTION and a TEST_CASE for it would
763
+ // put two nodes of the same name in one file, which the conformance
764
+ // binder correctly refuses to choose between (DEC-039) — a self-inflicted
765
+ // ambiguity rather than a real one.
766
+ const isCase = isTestCase(declaration, file);
767
+ // **A `MODEL` is a promotion, not a second node.** Emitting both a `CLASS`
768
+ // and a `MODEL` for one declaration would put two ids in the graph for one
769
+ // thing, and because the two ids differ the duplicate audit cannot see it —
770
+ // the same silence that let two RSpec examples share one node's edges.
771
+ const orm = ormModelOf(file, declaration);
772
+ // A `DTO` is the same kind of promotion as a `MODEL` and is decided the
773
+ // same way — by provenance. `orm` wins where both somehow apply: a class
774
+ // that maps a table *and* validates a request is primarily the table,
775
+ // because a wrong `MODEL` is a claim about a database and a wrong `DTO`
776
+ // is a claim about a wire format. Measured on both repositories: the
777
+ // overlap is zero, so this is a tie-break that has never fired.
778
+ const dto = orm === null ? dtoShapeOfDeclaration(file, declaration) : null;
779
+ const type = isCase
780
+ ? "TEST_CASE"
781
+ : declaration.kind === "class"
782
+ ? orm !== null
783
+ ? "MODEL"
784
+ : dto !== null
785
+ ? "DTO"
786
+ : "CLASS"
787
+ : "FUNCTION";
788
+ const suite = declaration.path.length === 2 ? [declaration.path[0]] : [];
789
+ // The package root belongs in a test's identity for the same reason it
790
+ // belongs in a symbol's (DEC-047): a module path is relative to its own
791
+ // package anchor, so two packages with the same internal layout collide.
792
+ // Python cannot produce the same-title duplicate TypeScript can — a test
793
+ // is a `def`, and two `def`s of one name in one scope are one function —
794
+ // so the occurrence ordinal is always 1 here.
795
+ const qsp = isCase
796
+ ? testCaseQsp(pkg, [...modulePath, ...suite], declaration.path[declaration.path.length - 1])
797
+ : symbolQsp(pkg, [...modulePath, ...declaration.path]);
798
+ const entry = {
799
+ id: nodeId(scope, type, qsp, LANGUAGE),
800
+ type,
801
+ name: declaration.name,
802
+ file,
803
+ range: { startLine: declaration.range.startLine, endLine: declaration.range.endLine },
804
+ declaration,
805
+ };
806
+ // **One name in one scope is one symbol, however many `def`s write it.**
807
+ //
808
+ // Three real shapes, all measured: a `@property` beside its
809
+ // `@x.setter` (django, `RelatedFieldWidgetWrapper.choices`), a
810
+ // `@typing.overload` set, and a class defined once per branch of an
811
+ // `if`/`elif` (dispatch, `Secret` under two secret providers). Python
812
+ // binds exactly one object to the name in every case.
813
+ //
814
+ // Emitting a node each produced duplicate ids — 133 lost declarations on
815
+ // django, 15 on dispatch, 0 on either repository this adapter was built
816
+ // against. The Normaliser rejected the extras, keeping the *first*, which
817
+ // for a property is the getter but for an overload set is a stub.
818
+ const previous = emittedById.get(entry.id);
819
+ if (previous !== undefined) {
820
+ const winner = preferredDeclaration(previous.entry, entry);
821
+ const merged = { ...winner };
822
+ nodes[previous.index] = {
823
+ ...nodes[previous.index],
824
+ range: merged.range,
825
+ attrs: { ...nodes[previous.index].attrs, declarations: previous.count + 1 },
826
+ };
827
+ emittedById.set(entry.id, { entry: merged, index: previous.index, count: previous.count + 1 });
828
+ byFull.set(declaration.name, merged);
829
+ // **`path.length === 1` guards this for the same reason it guards the
830
+ // non-merge path below, and a second unguarded write here was a wrong
831
+ // edge rather than a missing one.**
832
+ //
833
+ // It wrote `path[0]` whatever the depth, so a *nested* declaration
834
+ // claimed its enclosing function's importable name. `django`'s
835
+ // `convert_exception_to_response` contains two `inner` closures, one per
836
+ // branch; the second merged, and the merge then pointed every importer
837
+ // of `convert_exception_to_response` at `….inner`. Found by chasing
838
+ // four "missing" calls that turned out to be four wrong ones.
839
+ if (declaration.path.length === 1)
840
+ byName.set(declaration.path[0], merged);
841
+ continue;
842
+ }
843
+ byFull.set(declaration.name, entry);
844
+ // Only a top-level name is importable, so only a top-level name enters
845
+ // the binding table. `OrderService.calculate_total` is reachable by its
846
+ // full name, never by `calculate_total` alone.
847
+ if (declaration.path.length === 1)
848
+ byName.set(declaration.path[0], entry);
849
+ const attrs = {};
850
+ if (declaration.kind === "class")
851
+ attrs["declarationForm"] = "class";
852
+ if (isCase)
853
+ attrs["runner"] = "pytest";
854
+ if (orm !== null) {
855
+ ormShapes.set(entry.id, orm);
856
+ attrs["orm"] = orm.framework;
857
+ if (orm.table !== null)
858
+ attrs["table"] = orm.table;
859
+ // **Not gated on `input.reached`, and the exception is the point.** An
860
+ // annotation needs a checker before it can be trusted, which is why the
861
+ // branch below waits for R3. A mapped column does not: `nullable=True`
862
+ // is a declaration the framework itself enforces, so the shape is
863
+ // written down rather than inferred and is available as soon as it is
864
+ // read. Its resolution is the evidence's, not the run's (DEC-058).
865
+ if (orm.fields.length > 0)
866
+ attrs["fields"] = orm.fields;
867
+ if (orm.undecided.length > 0)
868
+ attrs["fieldsUndecided"] = orm.undecided;
869
+ // DEC-240: DATABASE_TABLE/DATABASE_COLUMN, minted through the one
870
+ // shared function every producer of these two types now goes
871
+ // through — never re-derived here, and never guessed when
872
+ // `orm.table` is `null` (an implicit Django/SQLAlchemy table name,
873
+ // `tableOf()`'s own disclosed refusal, carried through unchanged).
874
+ nodes.push(...databaseTableNodesFromModel(scope, { table: orm.table, fields: orm.fields.map((f) => ({ name: f.name, nullable: f.nullable })) }, {
875
+ file,
876
+ line: declaration.range.startLine,
877
+ producedBy: input.producedBy,
878
+ resolution: 0,
879
+ language: LANGUAGE,
880
+ }));
881
+ }
882
+ else if (dto !== null) {
883
+ dtoShapes.set(entry.id, dto);
884
+ attrs["schema"] = "pydantic";
885
+ // **Gated at R3, exactly as the annotation branch below is.** A field's
886
+ // shape here is read from a written annotation, and DEC-058's rule for
887
+ // an annotation is that it is a hint until a checker confirms it —
888
+ // which is precisely why golden 05 carries `requiredAtResolution: 3`.
889
+ // The ORM branch above is the deliberate exception: a mapped column's
890
+ // `nullable=True` is enforced by the framework rather than annotated.
891
+ // **`fields` is names and `fieldDetail` is shapes — `adapter-openapi`'s
892
+ // convention, matched rather than re-invented.** Golden 05 compares a
893
+ // list of names, and two producers of one node type describing it two
894
+ // ways is the same defect as two producers minting different endpoint
895
+ // ids: every consumer above the IR then has to know which one ran.
896
+ // (`MODEL.fields` carries objects instead, which is a corpus-level
897
+ // inconsistency between DTO and MODEL, not one to fix from this side.)
898
+ if (input.reached >= 3 && dto.fields.length > 0) {
899
+ attrs["fields"] = dto.fields.map((field) => field.name);
900
+ attrs["fieldDetail"] = dto.fields.map((field) => ({
901
+ name: field.name,
902
+ nullable: field.nullable,
903
+ required: field.required,
904
+ ...(field.type === null ? {} : { type: field.type }),
905
+ ...(field.constraints === undefined ? {} : { constraints: field.constraints }),
906
+ }));
907
+ }
908
+ if (dto.undecided.length > 0)
909
+ attrs["fieldsUndecided"] = dto.undecided;
910
+ // Real rules, none of them statically readable. Counted so the report
911
+ // can say "this shape is incompletely known" rather than implying the
912
+ // model constrains nothing beyond what is listed.
913
+ if (dto.unreadableValidators > 0)
914
+ attrs["unreadableValidators"] = dto.unreadableValidators;
915
+ }
916
+ else if (
917
+ // Shapes are the R3 claim: annotations are read from the source at every
918
+ // level, but only reported once the run claims to have understood types.
919
+ input.reached >= 3 &&
920
+ declaration.fields !== undefined &&
921
+ declaration.fields.length > 0) {
922
+ attrs["fields"] = [...declaration.fields]
923
+ .map((f) => ({ name: f.name, nullable: isNullable(f.annotation, f.hasDefault) }))
924
+ .sort((a, b) => a.name.localeCompare(b.name));
925
+ }
926
+ emittedById.set(entry.id, { entry, index: nodes.length, count: 1 });
927
+ nodes.push({
928
+ id: entry.id,
929
+ type,
930
+ name: declaration.name,
931
+ file,
932
+ range: entry.range,
933
+ language: LANGUAGE,
934
+ producedBy: input.producedBy,
935
+ resolution: 0,
936
+ attrs,
937
+ });
938
+ }
939
+ // --- tests we can see but cannot model, disclosed rather than dropped ----
940
+ //
941
+ // The ledger is the right home for this and needs no new field: the thing
942
+ // that failed to exist *is* a `TESTS` edge, and `unresolved_refs` is exactly
943
+ // "relationships we could not justify emitting". The module is the source
944
+ // because it is the only node guaranteed to exist for a file whose test
945
+ // declarations produced nothing.
946
+ for (const declaration of parsed.declarations) {
947
+ if (!looksLikeTestCase(declaration, file, pytest))
948
+ continue;
949
+ if (isTestCase(declaration, file))
950
+ continue;
951
+ disclose({ id: moduleId }, "TESTS", declaration.name, file, declaration.range.startLine, "unrecognisedTest");
952
+ }
953
+ // --- type aliases as CLASS nodes (DEC-069) ------------------------------
954
+ //
955
+ // DEC-061 settled the representation and the TypeScript adapter has emitted
956
+ // alias nodes since; this is the Python half of the same rule, not a new
957
+ // one. Registering the alias in `byName` is what makes the *consumer* edges
958
+ // appear: `followName` already resolves annotations, and every one of the
959
+ // 102 measured misses failed only because the name it resolved to had no
960
+ // node to point at.
961
+ for (const alias of parsed.aliases ?? []) {
962
+ if (byName.has(alias.name))
963
+ continue; // a declaration of the same name wins
964
+ const qsp = symbolQsp(pkg, [...modulePath, alias.name]);
965
+ const entry = {
966
+ id: nodeId(scope, "CLASS", qsp, LANGUAGE),
967
+ type: "CLASS",
968
+ name: alias.name,
969
+ file,
970
+ range: { startLine: alias.range.startLine, endLine: alias.range.endLine },
971
+ declaration: {
972
+ kind: "class",
973
+ path: [alias.name],
974
+ name: alias.name,
975
+ range: alias.range,
976
+ bases: [],
977
+ fields: [],
978
+ decorators: [],
979
+ },
980
+ };
981
+ byName.set(alias.name, entry);
982
+ byFull.set(alias.name, entry);
983
+ aliasIds.add(entry.id);
984
+ nodes.push({
985
+ id: entry.id,
986
+ type: "CLASS",
987
+ name: alias.name,
988
+ file,
989
+ range: entry.range,
990
+ language: LANGUAGE,
991
+ producedBy: input.producedBy,
992
+ resolution: 0,
993
+ // `declarationForm` is DEC-061's exact vocabulary, shared with the
994
+ // TypeScript adapter. `aliasRule` is Python-specific provenance and
995
+ // lives in attrs, which nothing above the IR reads.
996
+ attrs: { declarationForm: "alias", aliasRule: alias.rule },
997
+ });
998
+ }
999
+ }
1000
+ // --- HTTP routes declared in source (DEC-099) ------------------------------
1001
+ //
1002
+ // **Before the R1 gate, because a route is not automatically an R1 fact.**
1003
+ // Each route carries the level its own evidence earned, which DEC-058 requires
1004
+ // and which the first draft of this pass got wrong by claiming a flat R2 for
1005
+ // everything. A router declared, mounted and decorated in one file is a fact
1006
+ // about one file's text — R0. A chain that crosses a file boundary needs the
1007
+ // import graph and is R1. Nothing here resolves a reference through an index
1008
+ // or compares a shape, so R2 was over-claiming and R3 would be the inflation
1009
+ // DEC-058 removed once already.
1010
+ emitRoutes();
1011
+ if (input.reached < 1)
1012
+ return { nodes, edges, unresolved };
1013
+ // --- pass 2: import edges, off the resolution pass 0 already did ----------
1014
+ for (const file of files) {
1015
+ const fromId = moduleNodeId.get(file);
1016
+ for (const { record, target } of importsResolved.get(file)) {
1017
+ if (target.file === undefined) {
1018
+ disclose({ id: fromId }, "IMPORTS", record.module ?? record.name ?? record.local, file, record.line, "outside");
1019
+ continue;
1020
+ }
1021
+ const toId = moduleNodeId.get(target.file);
1022
+ if (toId !== undefined && toId !== fromId) {
1023
+ push({ from: fromId, to: toId, type: "IMPORTS" }, 1);
1024
+ }
1025
+ }
1026
+ }
1027
+ if (input.reached < 2)
1028
+ return { nodes, edges, unresolved };
1029
+ /**
1030
+ * Follow a name to the declaration that defines it, through however many
1031
+ * package re-exports stand in the way.
1032
+ *
1033
+ * Golden pattern 03 in one function, and its `mustNotResolveTo` clause is why
1034
+ * the loop exists rather than a single lookup: stopping at the first hop would
1035
+ * terminate the edge at the barrel, which the corpus fails an adapter for.
1036
+ */
1037
+ function followName(startFile, name, depth = 0) {
1038
+ if (depth > 8)
1039
+ return undefined; // circular package imports are legal Python
1040
+ const local = declaredIn.get(startFile)?.get(name);
1041
+ if (local !== undefined)
1042
+ return local;
1043
+ const binding = bindingsOf.get(startFile)?.get(name);
1044
+ if (binding === undefined)
1045
+ return undefined;
1046
+ const wanted = binding.symbol ?? name;
1047
+ const direct = declaredIn.get(binding.file)?.get(wanted);
1048
+ if (direct !== undefined)
1049
+ return direct;
1050
+ // Defined elsewhere and merely re-exported here — keep walking.
1051
+ return followName(binding.file, wanted, depth + 1);
1052
+ }
1053
+ /** The class a local name holds, where a bare constructor call said so. */
1054
+ /**
1055
+ * `local name -> its instantiations`, built once per file.
1056
+ *
1057
+ * This was a `.filter()` over the file's whole instantiation list, run once
1058
+ * for **every reference in that file** and twice per reference at R3. It is
1059
+ * quadratic in one file's size, which is invisible on a codebase of small
1060
+ * modules and brutal on one large generated module: `posthog/schema.py` has
1061
+ * 949 instantiations and 14,535 references — **13.8 million scans in a single
1062
+ * file**, each allocating an array.
1063
+ *
1064
+ * Measured: posthog took 1,655 s against 112 s for home-assistant-core at the
1065
+ * same file count. Neither the parser nor the repository size was the cause;
1066
+ * one file was.
1067
+ */
1068
+ const instantiationIndex = new Map();
1069
+ const instantiationsOf = (file, local) => {
1070
+ let index = instantiationIndex.get(file);
1071
+ if (index === undefined) {
1072
+ index = new Map();
1073
+ for (const entry of input.parsed.files[file]?.instantiations ?? []) {
1074
+ const bucket = index.get(entry.local);
1075
+ if (bucket === undefined)
1076
+ index.set(entry.local, [entry]);
1077
+ else
1078
+ bucket.push(entry);
1079
+ }
1080
+ instantiationIndex.set(file, index);
1081
+ }
1082
+ return index.get(local) ?? [];
1083
+ };
1084
+ function instantiatedType(file, scopePath, local) {
1085
+ const scopeKey = scopePath.join(".");
1086
+ const inScope = instantiationsOf(file, local).filter((i) => i.scope.length === 0 || i.scope.join(".") === scopeKey);
1087
+ // Innermost scope wins, matching Python: a local shadows a module global.
1088
+ const chosen = inScope.find((i) => i.scope.length > 0 && i.scope.join(".") === scopeKey) ??
1089
+ inScope.find((i) => i.scope.length === 0);
1090
+ if (chosen === undefined)
1091
+ return undefined;
1092
+ const target = followName(file, chosen.typeName);
1093
+ return target !== undefined && isTypeLikeNodeType(target.type) ? target : undefined;
1094
+ }
1095
+ for (const file of files) {
1096
+ const parsed = input.parsed.files[file];
1097
+ const byFull = byFullName.get(file);
1098
+ const containerOf = (scopePath) => {
1099
+ for (let depth = scopePath.length; depth > 0; depth -= 1) {
1100
+ const found = byFull.get(scopePath.slice(0, depth).join("."));
1101
+ if (found !== undefined)
1102
+ return found;
1103
+ }
1104
+ return undefined;
1105
+ };
1106
+ // --- heritage ----------------------------------------------------------
1107
+ for (const declaration of parsed.declarations) {
1108
+ if (declaration.kind !== "class")
1109
+ continue;
1110
+ const self = byFull.get(declaration.name);
1111
+ if (self === undefined)
1112
+ continue;
1113
+ for (const base of declaration.bases ?? []) {
1114
+ if (base === null)
1115
+ continue;
1116
+ const root = base.split("[")[0].split(".").pop();
1117
+ const target = followName(file, root);
1118
+ if (target === undefined) {
1119
+ disclose(self, "INHERITS", root, file, declaration.range.startLine, "outside");
1120
+ continue;
1121
+ }
1122
+ if (target.id !== self.id)
1123
+ push({ from: self.id, to: target.id, type: "INHERITS" }, 2);
1124
+ }
1125
+ }
1126
+ // --- calls, method calls, and the tests that make them ------------------
1127
+ for (const reference of parsed.references) {
1128
+ if (reference.name === null)
1129
+ continue;
1130
+ const container = containerOf(reference.scope);
1131
+ if (container === undefined)
1132
+ continue;
1133
+ // A pytest case and the function that carries it are the same source, so
1134
+ // a call inside it is attributed to the TEST_CASE rather than counted
1135
+ // twice — TESTS is the edge the corpus asks for.
1136
+ // A call out of a test case is `TESTS`, which is the edge the corpus asks
1137
+ // for; out of anything else it is `CALLS`.
1138
+ const source = container;
1139
+ const edgeType = container.type === "TEST_CASE" ? "TESTS" : "CALLS";
1140
+ if (reference.kind === "call") {
1141
+ const target = followName(file, reference.name);
1142
+ if (target === undefined) {
1143
+ disclose(source, edgeType, reference.name, file, reference.line, "outside");
1144
+ continue;
1145
+ }
1146
+ if ((target.type === "FUNCTION" || target.type === "TEST_CASE") && target.id !== source.id) {
1147
+ push({ from: source.id, to: target.id, type: edgeType }, 2);
1148
+ }
1149
+ else if (isTypeLikeNodeType(target.type) && target.id !== source.id) {
1150
+ // `service = OrderService()` — a constructor call. The name resolves
1151
+ // to a class, so `CALLS` cannot carry it (DEC-068 keeps CALLS to
1152
+ // FUNCTION and TEST_CASE targets), and the dependency was previously
1153
+ // dropped entirely. 1,237 of the 1,890 measured missing edges are
1154
+ // this one form.
1155
+ push({ from: source.id, to: target.id, type: "USES_TYPE" }, 2);
1156
+ }
1157
+ continue;
1158
+ }
1159
+ if (reference.kind === "method" && reference.receiver !== null) {
1160
+ // `OrderService.create(...)`, `DocumentType.VENDOR_BILL` — the receiver
1161
+ // is the class's own name rather than an instance, so no type inference
1162
+ // is needed: the name resolves to a declaration directly. DEC-068.
1163
+ //
1164
+ // A receiver that is a local, a parameter or `self` resolves to nothing
1165
+ // here and falls through to the paths below — that is the precision
1166
+ // guard, and it is why `resp.total` cannot reach this branch.
1167
+ if (reference.receiver !== "self") {
1168
+ const owner = followName(file, reference.receiver);
1169
+ if (owner !== undefined && isTypeLikeNodeType(owner.type) && owner.id !== source.id) {
1170
+ push({ from: source.id, to: owner.id, type: "USES_TYPE" }, 2);
1171
+ continue;
1172
+ }
1173
+ }
1174
+ // `self.x()` needs the enclosing class's own members; anything else
1175
+ // needs the receiver's type, which only a bare constructor call gives.
1176
+ if (reference.receiver === "self") {
1177
+ const owner = reference.scope[0];
1178
+ const method = owner === undefined ? undefined : byFull.get(`${owner}.${reference.name}`);
1179
+ if (method === undefined) {
1180
+ disclose(source, edgeType, `self.${reference.name}`, file, reference.line, "unknown");
1181
+ continue;
1182
+ }
1183
+ if (method.id !== source.id)
1184
+ push({ from: source.id, to: method.id, type: edgeType }, 2);
1185
+ continue;
1186
+ }
1187
+ // `event_service.log_case_event(…)` — the receiver is a **module**,
1188
+ // bound by `from dispatch.event import service as event_service`.
1189
+ //
1190
+ // `symbol === undefined` is the import resolver's own statement that the
1191
+ // specifier named a module rather than a name inside one, so this is not
1192
+ // a guess between two readings of the same text. Without it every call
1193
+ // through an aliased module fell to `instantiatedType`, found nothing,
1194
+ // and was disclosed as unknown: **18 of the 19 measured recall misses on
1195
+ // `dispatch` were this one form**, and it is the commonest way a Python
1196
+ // codebase of any size refers across packages.
1197
+ const asModule = bindingsOf.get(file)?.get(reference.receiver);
1198
+ if (asModule !== undefined && asModule.symbol === undefined) {
1199
+ const inModule = declaredIn.get(asModule.file)?.get(reference.name);
1200
+ if (inModule !== undefined && inModule.id !== source.id) {
1201
+ if (inModule.type === "FUNCTION" || inModule.type === "TEST_CASE") {
1202
+ push({ from: source.id, to: inModule.id, type: edgeType }, 2);
1203
+ }
1204
+ else if (isTypeLikeNodeType(inModule.type)) {
1205
+ // A class reached through its module is a value dependency, and
1206
+ // CALLS keeps to FUNCTION and TEST_CASE targets (DEC-068).
1207
+ push({ from: source.id, to: inModule.id, type: "USES_TYPE" }, 2);
1208
+ }
1209
+ continue;
1210
+ }
1211
+ disclose(source, edgeType, `${reference.receiver}.${reference.name}`, file, reference.line, "unknown");
1212
+ continue;
1213
+ }
1214
+ const receiverType = instantiatedType(file, reference.scope, reference.receiver);
1215
+ if (receiverType === undefined) {
1216
+ disclose(source, edgeType, `${reference.receiver}.${reference.name}`, file, reference.line, "unknown");
1217
+ continue;
1218
+ }
1219
+ const method = byFullName
1220
+ .get(receiverType.file)
1221
+ ?.get(`${receiverType.name}.${reference.name}`);
1222
+ if (method === undefined) {
1223
+ disclose(source, edgeType, `${receiverType.name}.${reference.name}`, file, reference.line, "unknown");
1224
+ continue;
1225
+ }
1226
+ if (method.id !== source.id)
1227
+ push({ from: source.id, to: method.id, type: edgeType }, 2);
1228
+ }
1229
+ }
1230
+ // --- a class or enum reached through its own name (DEC-068) -------------
1231
+ //
1232
+ // `DocumentType.VENDOR_BILL` arrives as an attribute access whose receiver
1233
+ // is the enum's own name. Resolving the *receiver* is the whole of it: the
1234
+ // dependency is on the class, and naming the member would need a node type
1235
+ // the vocabulary does not have. A receiver that is a local or a parameter
1236
+ // resolves to nothing and is left to the ledger, unchanged.
1237
+ for (const reference of parsed.references) {
1238
+ if (reference.kind !== "attribute" && reference.kind !== "attribute_write")
1239
+ continue;
1240
+ if (reference.receiver === null || reference.receiver === "self")
1241
+ continue;
1242
+ const container = containerOf(reference.scope);
1243
+ if (container === undefined)
1244
+ continue;
1245
+ const owner = followName(file, reference.receiver);
1246
+ if (owner === undefined || !isTypeLikeNodeType(owner.type) || owner.id === container.id)
1247
+ continue;
1248
+ push({ from: container.id, to: owner.id, type: "USES_TYPE" }, 2);
1249
+ }
1250
+ // --- a class referenced as a value (DEC-072) ----------------------------
1251
+ //
1252
+ // DEC-068 covered `X()` and `X.MEMBER`. This is the rest: passed as an
1253
+ // argument, caught, matched, returned, defaulted, iterated, put in a list.
1254
+ // Measured at 126 sites and 123 distinct edges before a line was written.
1255
+ //
1256
+ // The gate is that the target has a node at all, which is what keeps this
1257
+ // disjoint from DEC-071's constants by construction — every target here
1258
+ // already has a node, and every target there has none.
1259
+ for (const reference of parsed.valueRefs ?? []) {
1260
+ const container = containerOf(reference.scope);
1261
+ if (container === undefined)
1262
+ continue;
1263
+ const target = followName(file, reference.name);
1264
+ // **Nothing is disclosed here, and that is deliberate.** The ledger is for
1265
+ // references that *would have been edges* — DEC-016's "a name-matched
1266
+ // guess is a false claim, but dropping it silently leaves no denominator".
1267
+ // A bare name in a value position is usually a local or a parameter and is
1268
+ // not a reference to any declaration at all: filing every one of them
1269
+ // grew the ledger from 5,782 to 11,155 on one repository, and the reason
1270
+ // string would have claimed they resolve to the standard library, which is
1271
+ // simply untrue. A scan for one pattern is not a reference list, and
1272
+ // disclosing what it did not find would degrade the one signal the ledger
1273
+ // carries.
1274
+ if (target === undefined)
1275
+ continue;
1276
+ // **A function handed on as a value, DEC-074.** An earlier comment here
1277
+ // said `USES_TYPE` would be the wrong claim about a callback and left the
1278
+ // case out. Sizing it settled the question the other way: `READS` is
1279
+ // defined by the architecture as *code → data* and feeds the propagation
1280
+ // layer, so a function reference there would corrupt data analysis; and
1281
+ // `CALLS` would put a non-call into root-cause traversal, which is the
1282
+ // damage the precision rule names by example. `USES_TYPE` is what
1283
+ // DEC-067 already chose for a value reference when it rejected a separate
1284
+ // `USES_VALUE`, and its operative meaning is *A's declaration names B*.
1285
+ // Measured before the change: 98 edges on directus, 82 on dispatch.
1286
+ if (!isTypeLikeNodeType(target.type) && target.type !== "FUNCTION")
1287
+ continue;
1288
+ if (target.id === container.id)
1289
+ continue;
1290
+ push({ from: container.id, to: target.id, type: "USES_TYPE" }, 2);
1291
+ }
1292
+ // --- USES_TYPE, from an alias to its own constituents (DEC-069) ---------
1293
+ //
1294
+ // The second direction, and the one the annotation sizing could not see.
1295
+ // DEC-061's definition — *A's shape or signature names B* — reads on an
1296
+ // alias exactly as it reads on a parameter, and the TypeScript adapter has
1297
+ // emitted `Pair -> Alpha` since that entry. Omitting it here would make the
1298
+ // two adapters disagree about one construct, which is the failure the
1299
+ // conformance corpus exists to prevent.
1300
+ for (const alias of parsed.aliases ?? []) {
1301
+ const self = byFull.get(alias.name);
1302
+ if (self === undefined)
1303
+ continue;
1304
+ for (const name of alias.constituents) {
1305
+ const target = followName(file, name);
1306
+ if (target === undefined) {
1307
+ disclose(self, "USES_TYPE", name, file, alias.range.startLine, "outside");
1308
+ continue;
1309
+ }
1310
+ if (target.id === self.id)
1311
+ continue; // a recursive alias is not a dependency
1312
+ push({ from: self.id, to: target.id, type: "USES_TYPE" }, 2);
1313
+ }
1314
+ }
1315
+ // --- USES_TYPE, from an annotated local ---------------------------------
1316
+ //
1317
+ // **The "54": parsed, serialised, and never read.** `visit_AnnAssign`
1318
+ // recorded every `client: HttpClient = make()` as an annotation reference
1319
+ // from the day it was written, and no consumer on this side ever looked at
1320
+ // `kind === "annotation"`. Sized at 54 edges repo-wide and left on the
1321
+ // roadmap as "not started", which read as unbuilt work rather than as a
1322
+ // dropped field.
1323
+ //
1324
+ // A local's declared type is a dependency of the declaration that holds it,
1325
+ // by exactly DEC-061's definition, and it is the same `USES_TYPE` claim at
1326
+ // the same R2 as a parameter's. The module-level case is deliberately not
1327
+ // here: a top-level annotated assignment is an alias candidate and is
1328
+ // handled above, and claiming it twice would double-count it.
1329
+ for (const reference of parsed.references) {
1330
+ if (reference.kind !== "annotation")
1331
+ continue;
1332
+ const container = containerOf(reference.scope);
1333
+ if (container === undefined)
1334
+ continue;
1335
+ for (const name of typeNamesIn(reference.name)) {
1336
+ const target = followName(file, name);
1337
+ if (target === undefined)
1338
+ continue; // builtins and typing live here
1339
+ if (target.id === container.id)
1340
+ continue;
1341
+ push({ from: container.id, to: target.id, type: "USES_TYPE" }, 2);
1342
+ }
1343
+ }
1344
+ // --- USES_TYPE, from every written annotation (DEC-061) -----------------
1345
+ for (const declaration of parsed.declarations) {
1346
+ const self = byFull.get(declaration.name);
1347
+ if (self === undefined)
1348
+ continue;
1349
+ const written = [
1350
+ declaration.returns,
1351
+ ...(declaration.params ?? []).map((p) => p.annotation),
1352
+ ...(declaration.fields ?? []).map((f) => f.annotation),
1353
+ ];
1354
+ for (const annotation of written) {
1355
+ for (const name of typeNamesIn(annotation)) {
1356
+ const target = followName(file, name);
1357
+ if (target === undefined)
1358
+ continue; // builtins and typing live here
1359
+ if (target.id === self.id)
1360
+ continue;
1361
+ push({ from: self.id, to: target.id, type: "USES_TYPE" }, 2);
1362
+ }
1363
+ }
1364
+ }
1365
+ }
1366
+ function emitRoutes() {
1367
+ /** local name -> the module that supplied it, for the constructor check. */
1368
+ const moduleOfImportIn = (file) => (name) => {
1369
+ const record = input.parsed.files[file]?.imports.find((i) => i.local === name);
1370
+ if (record === undefined)
1371
+ return undefined;
1372
+ if (record.level > 0) {
1373
+ const binding = bindingsOf.get(file)?.get(name);
1374
+ return binding === undefined ? undefined : modulePathOf(binding.file).join(".");
1375
+ }
1376
+ // `from fastapi import APIRouter` names the module outright; `import
1377
+ // fastapi` binds the module itself.
1378
+ return record.module ?? record.name ?? undefined;
1379
+ };
1380
+ /**
1381
+ * A router variable, canonicalised across files.
1382
+ *
1383
+ * **The alias trap, one layer up.** `from .auth.views import router as
1384
+ * auth_router` binds a different local name to the same object, and a table
1385
+ * keyed on the local name misses every mount written against the alias. This
1386
+ * is golden pattern 03's lesson, and it has now cost a bug in this project
1387
+ * four separate times — DEC-052 hit it on the caller side of this very join.
1388
+ */
1389
+ /**
1390
+ * A router's key, qualified by the scope it was declared in.
1391
+ *
1392
+ * **Not optional.** flask's test suite declares `app = Flask(__name__)`
1393
+ * inside individual test functions, and a key of `file::app` makes one such
1394
+ * local visible to every other test in the file — including the ~50 whose
1395
+ * `app` is a pytest fixture parameter, a name this adapter cannot resolve at
1396
+ * all. Keyed flat it emitted 142 routes against 42 real ones. The same bug
1397
+ * existed in the independent truth walker and was found there first.
1398
+ */
1399
+ const scopedKey = (file, scope, local) => `${file}::${scope.join(".")}::${local}`;
1400
+ /** Innermost scope outwards, then the import table. */
1401
+ const lookupRouter = (file, scope, local) => {
1402
+ for (let depth = scope.length; depth >= 0; depth -= 1) {
1403
+ const key = scopedKey(file, scope.slice(0, depth), local);
1404
+ if (routers.has(key))
1405
+ return key;
1406
+ }
1407
+ const imported = scopedKey(...canonicalParts(file, local));
1408
+ return routers.has(imported) ? imported : undefined;
1409
+ };
1410
+ /** An imported router resolves to module scope in the file that declares it. */
1411
+ const canonicalParts = (file, local) => {
1412
+ const dot = local.lastIndexOf(".");
1413
+ if (dot !== -1) {
1414
+ const moduleBinding = bindingsOf.get(file)?.get(local.slice(0, dot));
1415
+ if (moduleBinding !== undefined)
1416
+ return [moduleBinding.file, [], local.slice(dot + 1)];
1417
+ return [file, [], local];
1418
+ }
1419
+ const binding = bindingsOf.get(file)?.get(local);
1420
+ if (binding === undefined)
1421
+ return [file, [], local];
1422
+ return [binding.file, [], binding.symbol ?? local];
1423
+ };
1424
+ /**
1425
+ * Did resolving this name need the import graph?
1426
+ *
1427
+ * This is the whole of the resolution question. A name bound in the file
1428
+ * that uses it costs nothing to resolve; a name that came from an import
1429
+ * costs module resolution, which is what R1 *is*.
1430
+ */
1431
+ const crossesFile = (file, local) => bindingsOf.get(file)?.get(local) !== undefined;
1432
+ const routers = new Map();
1433
+ const mounts = [];
1434
+ const routeDecls = [];
1435
+ const clientCalls = [];
1436
+ const djangoRouteEntries = [];
1437
+ const djangoIncludeEntries = [];
1438
+ const drfRegisterEntries = [];
1439
+ const odooRouteEntries = [];
1440
+ const graphqlSchemas = [];
1441
+ for (const file of files) {
1442
+ const parsed = input.parsed.files[file];
1443
+ const calls = parsed.calls ?? [];
1444
+ // `router = APIRouter(...)`: the instantiation table already pairs a
1445
+ // module-level assignment with its constructor, so the local name comes
1446
+ // from there rather than from a second walk.
1447
+ // **`constructions`, not `instantiations`.** The latter records only a
1448
+ // bare `Name` constructor, so `app = flask.Flask(__name__)` — the form
1449
+ // flask's own documentation uses — was invisible and the routes hanging
1450
+ // off it were disclosed as unreadable. Not restricted to module scope
1451
+ // either: an application factory assigns its app inside a function, and
1452
+ // the mounts are written there too.
1453
+ const localOfCall = (call) => (parsed.constructions ?? []).find((c) => c.line === call.line && c.callee === call.callee)
1454
+ ?.local;
1455
+ for (const declared of routersIn(file, calls, localOfCall, moduleOfImportIn(file))) {
1456
+ const scope = (parsed.constructions ?? []).find((c) => c.line === declared.line && c.local === declared.local)?.scope;
1457
+ routers.set(scopedKey(file, scope ?? [], declared.local), declared);
1458
+ }
1459
+ mounts.push(...mountsIn(file, calls));
1460
+ // --- the caller half: HTTP calls this file makes ---------------------
1461
+ //
1462
+ // Written here rather than in the reference walk because import
1463
+ // provenance is already resolved in this loop, and provenance is the
1464
+ // whole precision control: `client.get("/orders/1")` is a Django test
1465
+ // client in 2,280 places across the reference set and an HTTP call in
1466
+ // none of them.
1467
+ const fileClients = byFullName.get(file);
1468
+ const clientLocals = clientLocalsIn(calls, localOfCall, moduleOfImportIn(file));
1469
+ const constants = new Map();
1470
+ for (const constant of parsed.constants ?? [])
1471
+ constants.set(constant.name, constant.value);
1472
+ // Keyed on scope as well as name, because two functions in one file may
1473
+ // each bind `url` to a different path and merging them would attach a
1474
+ // caller to an endpoint it never reaches.
1475
+ const localStrings = new Map();
1476
+ for (const local of parsed.localStrings ?? []) {
1477
+ localStrings.set(`${local.scope.join(".")}::${local.name}`, local.value);
1478
+ }
1479
+ // DEC-242's `varies-per-call`: a function's own parameter names, keyed
1480
+ // by its scope path exactly as `PyCall.scope` writes it — a call inside
1481
+ // that function shares the same scope path, so `call.scope.join(".")`
1482
+ // looks this up directly.
1483
+ const functionParams = new Map();
1484
+ for (const declaration of parsed.declarations) {
1485
+ if (declaration.kind !== "function")
1486
+ continue;
1487
+ functionParams.set(declaration.path.join("."), new Set((declaration.params ?? []).map((p) => p.name)));
1488
+ }
1489
+ const read = clientCallsIn(file, calls, moduleOfImportIn(file), clientLocals, constants, localStrings, functionParams);
1490
+ const containerFor = (scopePath) => {
1491
+ if (fileClients === undefined)
1492
+ return undefined;
1493
+ for (let depth = scopePath.length; depth > 0; depth -= 1) {
1494
+ const found = fileClients.get(scopePath.slice(0, depth).join("."));
1495
+ if (found !== undefined)
1496
+ return found;
1497
+ }
1498
+ // A call at module level belongs to the module, exactly as it does in
1499
+ // TypeScript (DEC-098). Without this fallback a client module with no
1500
+ // enclosing def produces no edge *and* no ledger row.
1501
+ const moduleId = moduleNodeId.get(file);
1502
+ return moduleId === undefined ? undefined : { id: moduleId };
1503
+ };
1504
+ for (const refusal of read.refusals) {
1505
+ const container = containerFor([]);
1506
+ if (container === undefined)
1507
+ continue;
1508
+ disclose(container, "USES_API", refusal.rawTarget, refusal.file, refusal.line, refusal.reason,
1509
+ // Unset `refusalClass` stays legal and means unclassified — DEC-242 — so
1510
+ // `attrs` is omitted entirely rather than sent with an `undefined` field.
1511
+ refusal.refusalClass === undefined
1512
+ ? undefined
1513
+ : {
1514
+ blockedBy: refusal.blockedBy,
1515
+ refusalClass: refusal.refusalClass,
1516
+ ...(refusal.argumentKind === undefined ? {} : { argumentKind: refusal.argumentKind }),
1517
+ });
1518
+ }
1519
+ for (const call of read.calls) {
1520
+ const container = containerFor(call.scope);
1521
+ if (container === undefined)
1522
+ continue;
1523
+ clientCalls.push({ call, containerId: container.id });
1524
+ }
1525
+ for (const declaration of parsed.declarations) {
1526
+ for (const call of declaration.decoratorCalls ?? []) {
1527
+ const route = routeFromDecorator(file, call, declaration.path);
1528
+ if (route !== null)
1529
+ routeDecls.push(route);
1530
+ const odooRoute = odooRouteFromDecorator(file, call, declaration.path, moduleOfImportIn(file));
1531
+ if (odooRoute !== null)
1532
+ odooRouteEntries.push(odooRoute);
1533
+ }
1534
+ }
1535
+ // --- Django's own routing: urlpatterns/path()/re_path()/url() --------
1536
+ // --- GraphQL: the schema call is the provenance, nothing else is ------
1537
+ graphqlSchemas.push(...schemaDeclsIn(file, calls, moduleOfImportIn(file)));
1538
+ const djangoUrls = djangoUrlEntriesIn(file, calls, moduleOfImportIn(file));
1539
+ djangoRouteEntries.push(...djangoUrls.routes);
1540
+ djangoIncludeEntries.push(...djangoUrls.includes);
1541
+ // --- DRF: router.register(prefix, ViewSetClass) ----------------------
1542
+ const drfLocals = drfRouterLocalsIn(calls, localOfCall, moduleOfImportIn(file));
1543
+ for (const reg of drfRegisterCallsIn(file, calls)) {
1544
+ if (drfLocals.has(reg.routerLocal))
1545
+ drfRegisterEntries.push(reg);
1546
+ }
1547
+ }
1548
+ /** child -> its parent and the prefix mounting it there. */
1549
+ const parentOf = new Map();
1550
+ for (const mount of mounts) {
1551
+ const child = lookupRouter(mount.file, mount.scope, mount.childLocal);
1552
+ const parent = lookupRouter(mount.file, mount.scope, mount.parentLocal);
1553
+ if (child === undefined || parent === undefined || child === parent)
1554
+ continue;
1555
+ parentOf.set(child, {
1556
+ parent,
1557
+ prefix: mount.prefix,
1558
+ crossesFile: crossesFile(mount.file, mount.childLocal) || crossesFile(mount.file, mount.parentLocal),
1559
+ });
1560
+ }
1561
+ /**
1562
+ * The full mount prefix of a router, and the resolution its chain earned.
1563
+ *
1564
+ * `prefix: null` means a hop is written and unreadable — disclosed, never
1565
+ * guessed. `level` is 1 as soon as any hop needed the import graph.
1566
+ */
1567
+ const prefixCache = new Map();
1568
+ const fullPrefix = (start) => {
1569
+ const cached = prefixCache.get(start);
1570
+ if (cached !== undefined)
1571
+ return cached;
1572
+ const parts = [];
1573
+ const seen = new Set();
1574
+ let current = start;
1575
+ let answer = "";
1576
+ let level = 0;
1577
+ while (current !== undefined && !seen.has(current)) {
1578
+ seen.add(current);
1579
+ const declared = routers.get(current);
1580
+ if (declared !== undefined) {
1581
+ if (declared.prefix === null) {
1582
+ answer = null;
1583
+ break;
1584
+ }
1585
+ if (declared.prefix !== "")
1586
+ parts.push(declared.prefix);
1587
+ }
1588
+ const step = parentOf.get(current);
1589
+ if (step === undefined)
1590
+ break;
1591
+ if (step.prefix === null) {
1592
+ answer = null;
1593
+ break;
1594
+ }
1595
+ if (step.prefix !== "")
1596
+ parts.push(step.prefix);
1597
+ if (step.crossesFile)
1598
+ level = 1;
1599
+ current = step.parent;
1600
+ }
1601
+ if (answer !== null)
1602
+ answer = parts.reverse().join("");
1603
+ const result = { prefix: answer, level };
1604
+ prefixCache.set(start, result);
1605
+ return result;
1606
+ };
1607
+ /**
1608
+ * Every type name a route's contract mentions — its handler's parameter
1609
+ * annotations, its `response_model=`, and its return annotation.
1610
+ *
1611
+ * Names, not resolutions: the caller decides which of them is a `DTO`. A
1612
+ * route naming a type that is not a validated shape produces no edge and
1613
+ * needs no disclosure, because there is no relationship being refused.
1614
+ */
1615
+ const routeContractNames = (route) => {
1616
+ const parsed = input.parsed.files[route.file];
1617
+ const handler = route.handler === null
1618
+ ? undefined
1619
+ : parsed?.declarations.find((d) => d.name === route.handler.join("."));
1620
+ const written = [
1621
+ route.responseModel,
1622
+ handler?.returns ?? null,
1623
+ ...(handler?.params ?? []).map((p) => p.annotation),
1624
+ ];
1625
+ const out = new Set();
1626
+ for (const annotation of written) {
1627
+ for (const name of typeNamesIn(annotation))
1628
+ out.add(name);
1629
+ }
1630
+ return [...out];
1631
+ };
1632
+ for (const route of routeDecls) {
1633
+ const key = lookupRouter(route.file, route.scope, route.routerLocal);
1634
+ const router = key === undefined ? undefined : routers.get(key);
1635
+ const from = (route.handler === null ? undefined : byFullName.get(route.file)?.get(route.handler.join("."))) ??
1636
+ { id: moduleNodeId.get(route.file) };
1637
+ // **The receiver must be a router this run resolved.** Not "an attribute
1638
+ // spelled like a verb" — `@mock.patch` is 1,150 sites on saleor and 171 on
1639
+ // django, and a Slack Bolt `app.options(...)` registers an action id
1640
+ // rather than a path. Every one would be a false claim about a URL.
1641
+ if (router === undefined) {
1642
+ disclose(from, "SERVES_API", `${route.methods.join("/")} ${route.rawPath}`, route.file, route.line, "routeReceiverUnknown");
1643
+ continue;
1644
+ }
1645
+ if (route.path === null) {
1646
+ disclose(from, "SERVES_API", `${route.methods.join("/")} ${route.rawPath}`, route.file, route.line, "routePathNotLiteral");
1647
+ continue;
1648
+ }
1649
+ const chain = fullPrefix(key);
1650
+ if (chain.prefix === null) {
1651
+ disclose(from, "SERVES_API", `${route.methods.join("/")} ${route.path}`, route.file, route.line, "routePrefixNotLiteral");
1652
+ continue;
1653
+ }
1654
+ const prefix = chain.prefix;
1655
+ // The level this route's own evidence earned, capped by what the run
1656
+ // reached. A route whose chain needs the import graph is not available at
1657
+ // R0, and emitting it there would claim a resolution the run did not have.
1658
+ const level = (chain.level === 1 || crossesFile(route.file, route.routerLocal) ? 1 : 0);
1659
+ if (level > input.reached)
1660
+ continue;
1661
+ // A router nothing mounts serves nothing, and the mount is where the
1662
+ // served path is decided. dispatch declares four such routes in a Slack
1663
+ // plugin whose router is mounted by plugin machinery at runtime.
1664
+ //
1665
+ // **The guard used to fire only when the router had no prefix of its own**
1666
+ // — `!parentOf.has(key) && routers.get(key)?.prefix === ""` — which meant
1667
+ // a `Blueprint(url_prefix="/auth")` that nothing registers was emitted
1668
+ // anyway. It happened to produce correct paths on flask because those
1669
+ // blueprints *are* registered, so the reasoning was unsound while the
1670
+ // answer was right, which is the combination that survives a draw.
1671
+ if (!parentOf.has(key) && !isApplicationRouter(router)) {
1672
+ disclose(from, "SERVES_API", `${route.methods.join("/")} ${route.path}`, route.file, route.line, "routeNeverMounted");
1673
+ continue;
1674
+ }
1675
+ const served = joinPath(prefix, route.path);
1676
+ for (const method of route.methods) {
1677
+ // The route keeps the template AS WRITTEN in its identity and the
1678
+ // normalised form in `attrs.pathTemplate` — `adapter-openapi` does
1679
+ // exactly this, and two producers of one route must mint the same
1680
+ // endpoint id or the join this whole lane exists for does not happen.
1681
+ const routeId = nodeId(scope, "API_ROUTE", `${method} ${served}`, LANGUAGE);
1682
+ if (!routeSeen.has(routeId)) {
1683
+ routeSeen.add(routeId);
1684
+ nodes.push({
1685
+ id: routeId,
1686
+ type: "API_ROUTE",
1687
+ name: `${method} ${served}`,
1688
+ file: route.file,
1689
+ range: { startLine: route.line, endLine: route.line },
1690
+ language: LANGUAGE,
1691
+ producedBy: input.producedBy,
1692
+ resolution: level,
1693
+ attrs: {
1694
+ method,
1695
+ pathTemplate: normaliseEndpointPath(served),
1696
+ rawTemplate: served,
1697
+ framework: router.framework,
1698
+ },
1699
+ });
1700
+ }
1701
+ else {
1702
+ // This declaration's own SERVES_API edge and source location did not
1703
+ // enter the graph — a second router already claimed this exact
1704
+ // method+path. Real (two apps mounted at overlapping prefixes, a
1705
+ // duplicate registration) rather than a bug in this reader, but
1706
+ // silent until now: nothing distinguished it from "checked, only
1707
+ // one declaration exists".
1708
+ disclose(from, "SERVES_API", `${method} ${served}`, route.file, route.line, "routeIdCollision");
1709
+ }
1710
+ // Fileless AND language-less (DEC-014). `nodeId` throws if a fileless
1711
+ // type is hashed with a language, which is the guard against an id that
1712
+ // disagrees with the node it labels — a failure whose only symptom
1713
+ // would be a join that silently does not happen.
1714
+ const endpointId = nodeId(scope, "API_ENDPOINT", endpointQsp(method, served), null);
1715
+ if (!routeSeen.has(endpointId)) {
1716
+ routeSeen.add(endpointId);
1717
+ nodes.push({
1718
+ id: endpointId,
1719
+ type: "API_ENDPOINT",
1720
+ name: `${method} ${normaliseEndpointPath(served)}`,
1721
+ file: null,
1722
+ range: null,
1723
+ language: null,
1724
+ producedBy: input.producedBy,
1725
+ resolution: level,
1726
+ attrs: { method, pathTemplate: normaliseEndpointPath(served) },
1727
+ });
1728
+ }
1729
+ // `SERVES_API` and nothing else *to the endpoint*. Golden pattern 04
1730
+ // asserts this edge alone, and a route-to-handler edge would be a
1731
+ // second claim with no golden behind it and no draw measuring it.
1732
+ push({ from: routeId, to: endpointId, type: "SERVES_API" }, level);
1733
+ // The route's contract: which validated shapes it names. `USES_TYPE`
1734
+ // is the edge `adapter-openapi` already emits from a route to a schema
1735
+ // it names, and matching it is not cosmetic — two producers describing
1736
+ // one construct with two edge types makes every threshold above the IR
1737
+ // depend on which producer ran.
1738
+ //
1739
+ // **R2, and gated.** Deciding which `OrderRead` a name means is
1740
+ // reference resolution through re-exports, which is what R2 buys and
1741
+ // what `CALLS` and `USES_TYPE` already claim in this adapter.
1742
+ if (input.reached >= 2) {
1743
+ for (const name of routeContractNames(route)) {
1744
+ const target = followName(route.file, name);
1745
+ if (target === undefined || !dtoShapes.has(target.id))
1746
+ continue;
1747
+ push({ from: routeId, to: target.id, type: "USES_TYPE" }, 2);
1748
+ }
1749
+ }
1750
+ }
1751
+ }
1752
+ // --- Django's own routing: chain-resolve include() by module string ----
1753
+ //
1754
+ // Keyed by FILE, not by a local variable — `include("app.urls")` mounts
1755
+ // by dotted module string, and Django's mount graph has no notion of a
1756
+ // local scope a name could be shadowed in. A file's `urlpatterns` is
1757
+ // root (prefix `""`) exactly when nothing in this run's include graph
1758
+ // names it: `include()` only ever points from a project's root urlconf
1759
+ // down into an app's, never the other way, so "nothing includes it" is
1760
+ // the structural definition of root, not a guess about which file is
1761
+ // special.
1762
+ const djangoParentOf = new Map();
1763
+ for (const inc of djangoIncludeEntries) {
1764
+ const includeFrom = { id: moduleNodeId.get(inc.file) };
1765
+ if (inc.targetModule === null) {
1766
+ // `include(router.urls)` hands a DRF router's own generated routes
1767
+ // back into this same file's urlpatterns — not a cross-file mount,
1768
+ // and already covered by the DRF loop below. Anything else
1769
+ // unreadable (a computed string, a `(module, namespace)` tuple) is
1770
+ // disclosed rather than silently dropped.
1771
+ if (!/^[A-Za-z_][A-Za-z0-9_]*\.urls$/.test(inc.rawTarget.trim())) {
1772
+ disclose(includeFrom, "SERVES_API", inc.rawTarget, inc.file, inc.line, "djangoIncludeUnresolved");
1773
+ }
1774
+ continue;
1775
+ }
1776
+ const target = resolveImportCached({ kind: "absolute", level: 0, module: inc.targetModule, name: null, local: "", line: inc.line }, inc.file);
1777
+ if (target.file === undefined) {
1778
+ disclose(includeFrom, "SERVES_API", inc.targetModule, inc.file, inc.line, "djangoIncludeUnresolved");
1779
+ continue;
1780
+ }
1781
+ // First include wins when a module is mounted from more than one
1782
+ // place — rare, and in the overwhelming majority of real projects
1783
+ // exactly one parent is the meaningful one; overwriting a resolved
1784
+ // chain on a second sighting would be the less safe default.
1785
+ if (!djangoParentOf.has(target.file)) {
1786
+ djangoParentOf.set(target.file, { parent: inc.file, prefix: inc.prefix });
1787
+ }
1788
+ }
1789
+ const djangoPrefixCache = new Map();
1790
+ const djangoFullPrefix = (startFile) => {
1791
+ const cached = djangoPrefixCache.get(startFile);
1792
+ if (cached !== undefined)
1793
+ return cached;
1794
+ const parts = [];
1795
+ const seen = new Set();
1796
+ let current = startFile;
1797
+ let answer = "";
1798
+ let level = 0;
1799
+ while (current !== undefined && !seen.has(current)) {
1800
+ seen.add(current);
1801
+ const step = djangoParentOf.get(current);
1802
+ if (step === undefined)
1803
+ break;
1804
+ if (step.prefix === null) {
1805
+ answer = null;
1806
+ break;
1807
+ }
1808
+ if (step.prefix !== "")
1809
+ parts.push(step.prefix);
1810
+ level = 1;
1811
+ current = step.parent;
1812
+ }
1813
+ if (answer !== null)
1814
+ answer = parts.reverse().join("");
1815
+ const result = { prefix: answer, level };
1816
+ djangoPrefixCache.set(startFile, result);
1817
+ return result;
1818
+ };
1819
+ /**
1820
+ * A urlconf entry names no verb — Django dispatches by looking at the
1821
+ * request, not the registration. Defaulting to GET matches this file's
1822
+ * own precedent for Flask's undecorated `.route()` (`GENERIC_ROUTE_ATTRS`,
1823
+ * above). Upgraded when the view is a same-file class-based view whose
1824
+ * body itself declares HTTP-verb-named methods — Django's actual
1825
+ * dispatch mechanism (`View.dispatch` calls `getattr(self,
1826
+ * request.method.lower())`), read from source rather than guessed.
1827
+ * Cross-file CBVs and function-based views stay at the GET default —
1828
+ * resolving an imported class's own methods is a second import hop this
1829
+ * pass does not add.
1830
+ */
1831
+ const djangoRouteMethods = (route) => {
1832
+ if (route.viewClassLocal === null)
1833
+ return ["GET"];
1834
+ const declarations = input.parsed.files[route.file]?.declarations ?? [];
1835
+ const found = declarations
1836
+ .filter((d) => d.kind === "function" &&
1837
+ d.path.length === 2 &&
1838
+ d.path[0] === route.viewClassLocal &&
1839
+ HTTP_METHODS.has(d.path[1]))
1840
+ .map((d) => d.path[1].toUpperCase());
1841
+ return found.length > 0 ? found : ["GET"];
1842
+ };
1843
+ const emitDjangoRoute = (file, line, served, method, level, framework) => {
1844
+ const from = { id: moduleNodeId.get(file) };
1845
+ const routeId = nodeId(scope, "API_ROUTE", `${method} ${served}`, LANGUAGE);
1846
+ if (!routeSeen.has(routeId)) {
1847
+ routeSeen.add(routeId);
1848
+ nodes.push({
1849
+ id: routeId,
1850
+ type: "API_ROUTE",
1851
+ name: `${method} ${served}`,
1852
+ file,
1853
+ range: { startLine: line, endLine: line },
1854
+ language: LANGUAGE,
1855
+ producedBy: input.producedBy,
1856
+ resolution: level,
1857
+ attrs: { method, pathTemplate: normaliseEndpointPath(served), rawTemplate: served, framework },
1858
+ });
1859
+ }
1860
+ else {
1861
+ disclose(from, "SERVES_API", `${method} ${served}`, file, line, "routeIdCollision");
1862
+ }
1863
+ const endpointId = nodeId(scope, "API_ENDPOINT", endpointQsp(method, served), null);
1864
+ if (!routeSeen.has(endpointId)) {
1865
+ routeSeen.add(endpointId);
1866
+ nodes.push({
1867
+ id: endpointId,
1868
+ type: "API_ENDPOINT",
1869
+ name: `${method} ${normaliseEndpointPath(served)}`,
1870
+ file: null,
1871
+ range: null,
1872
+ language: null,
1873
+ producedBy: input.producedBy,
1874
+ resolution: level,
1875
+ attrs: { method, pathTemplate: normaliseEndpointPath(served) },
1876
+ });
1877
+ }
1878
+ push({ from: routeId, to: endpointId, type: "SERVES_API" }, level);
1879
+ };
1880
+ for (const route of djangoRouteEntries) {
1881
+ const from = { id: moduleNodeId.get(route.file) };
1882
+ if (route.path === null) {
1883
+ disclose(from, "SERVES_API", route.rawPath, route.file, route.line, "djangoPathNotLiteral");
1884
+ continue;
1885
+ }
1886
+ const chain = djangoFullPrefix(route.file);
1887
+ if (chain.prefix === null) {
1888
+ disclose(from, "SERVES_API", `${route.path}`, route.file, route.line, "djangoIncludeUnresolved");
1889
+ continue;
1890
+ }
1891
+ const level = chain.level;
1892
+ if (level > input.reached)
1893
+ continue;
1894
+ const served = joinPath(chain.prefix, route.path);
1895
+ for (const method of djangoRouteMethods(route)) {
1896
+ emitDjangoRoute(route.file, route.line, served, method, level, "django");
1897
+ }
1898
+ }
1899
+ // --- DRF: router.register(prefix, ViewSetClass) — the fixed action table
1900
+ // `DefaultRouter`/`SimpleRouter` always generate, regardless of which of
1901
+ // those actions the viewset actually implements (an unimplemented one
1902
+ // still gets the URL and 405s at request time). Reading the router's own
1903
+ // contract rather than inspecting the viewset's mixins, which mostly live
1904
+ // outside the analysed set entirely (`ModelViewSet` composes them from
1905
+ // DRF's own installed package).
1906
+ for (const reg of drfRegisterEntries) {
1907
+ const from = { id: moduleNodeId.get(reg.file) };
1908
+ if (reg.prefix === null) {
1909
+ disclose(from, "SERVES_API", reg.rawPrefix, reg.file, reg.line, "djangoPathNotLiteral");
1910
+ continue;
1911
+ }
1912
+ const chain = djangoFullPrefix(reg.file);
1913
+ if (chain.prefix === null) {
1914
+ disclose(from, "SERVES_API", reg.prefix, reg.file, reg.line, "djangoIncludeUnresolved");
1915
+ continue;
1916
+ }
1917
+ const level = chain.level;
1918
+ if (level > input.reached)
1919
+ continue;
1920
+ const basePath = joinPath(chain.prefix, reg.prefix);
1921
+ for (const action of DRF_ACTIONS) {
1922
+ const served = joinPath(basePath, action.suffix);
1923
+ emitDjangoRoute(reg.file, reg.line, served, action.method, level, "django");
1924
+ }
1925
+ }
1926
+ // --- Odoo: @http.route(...) — path already absolute, nothing to resolve -
1927
+ for (const route of odooRouteEntries) {
1928
+ const from = (route.handler === null ? undefined : byFullName.get(route.file)?.get(route.handler.join("."))) ??
1929
+ { id: moduleNodeId.get(route.file) };
1930
+ if (route.path === null) {
1931
+ disclose(from, "SERVES_API", route.rawPath, route.file, route.line, "odooPathNotLiteral");
1932
+ continue;
1933
+ }
1934
+ if (route.methods === null) {
1935
+ disclose(from, "SERVES_API", route.path, route.file, route.line, "odooMethodsNotLiteral");
1936
+ continue;
1937
+ }
1938
+ const served = normaliseEndpointPath(route.path).startsWith("/") ? route.path : `/${route.path}`;
1939
+ for (const method of route.methods) {
1940
+ const routeId = nodeId(scope, "API_ROUTE", `${method} ${served}`, LANGUAGE);
1941
+ if (!routeSeen.has(routeId)) {
1942
+ routeSeen.add(routeId);
1943
+ nodes.push({
1944
+ id: routeId,
1945
+ type: "API_ROUTE",
1946
+ name: `${method} ${served}`,
1947
+ file: route.file,
1948
+ range: { startLine: route.line, endLine: route.line },
1949
+ language: LANGUAGE,
1950
+ producedBy: input.producedBy,
1951
+ resolution: 0,
1952
+ attrs: { method, pathTemplate: normaliseEndpointPath(served), rawTemplate: served, framework: "odoo" },
1953
+ });
1954
+ }
1955
+ else {
1956
+ disclose(from, "SERVES_API", `${method} ${served}`, route.file, route.line, "routeIdCollision");
1957
+ }
1958
+ const endpointId = nodeId(scope, "API_ENDPOINT", endpointQsp(method, served), null);
1959
+ if (!routeSeen.has(endpointId)) {
1960
+ routeSeen.add(endpointId);
1961
+ nodes.push({
1962
+ id: endpointId,
1963
+ type: "API_ENDPOINT",
1964
+ name: `${method} ${normaliseEndpointPath(served)}`,
1965
+ file: null,
1966
+ range: null,
1967
+ language: null,
1968
+ producedBy: input.producedBy,
1969
+ resolution: 0,
1970
+ attrs: { method, pathTemplate: normaliseEndpointPath(served) },
1971
+ });
1972
+ }
1973
+ push({ from: routeId, to: endpointId, type: "SERVES_API" }, 0);
1974
+ }
1975
+ }
1976
+ // --- GraphQL: strawberry root operations -------------------------------
1977
+ //
1978
+ // `method: "GRAPHQL"`, path `/<OperationType>/<field>` — the gRPC identity
1979
+ // convention transposed (`DEC-NEXT-graphql-operation-identity`). See
1980
+ // `graphql.ts`'s header for why the field NAME is read rather than computed
1981
+ // and what is refused when it cannot be.
1982
+ //
1983
+ // The operation type comes from the slot the schema call puts the class in,
1984
+ // never from the class's own name or its `@strawberry.type(name=...)`:
1985
+ // netbox decorates all ten of its root mixins `name="Query"` and strawberry
1986
+ // discards every one of them, because a base class is never registered as a
1987
+ // type. Reading the decorator would have been right there by accident.
1988
+ for (const schema of graphqlSchemas) {
1989
+ const from = { id: moduleNodeId.get(schema.file) };
1990
+ for (const { slot, written } of schema.opaqueSlots) {
1991
+ disclose(from, "SERVES_API", `${slot}=${written}`, schema.file, schema.line, "graphqlRootSlotUnreadable");
1992
+ }
1993
+ for (const [slot, local] of schema.roots) {
1994
+ /**
1995
+ * Every class contributing fields to this root, the root itself first.
1996
+ *
1997
+ * Python's MRO is the reason the bases are walked at all: netbox's root
1998
+ * `Query` declares no field of its own and inherits all 246 from ten
1999
+ * mixins in ten other files. Stopping at the root would report a
2000
+ * GraphQL API with zero operations, which is a false sentence about the
2001
+ * repository rather than a disclosed gap.
2002
+ */
2003
+ const collected = [];
2004
+ const seenClass = new Set();
2005
+ const walk = (name, fromFile, depth) => {
2006
+ if (depth > 8)
2007
+ return; // a diamond is legal; a cycle is not worth chasing
2008
+ const declared = followName(fromFile, name);
2009
+ if (declared === undefined) {
2010
+ disclose(from, "SERVES_API", name, schema.file, schema.line, "graphqlRootBaseUnreadable");
2011
+ return;
2012
+ }
2013
+ if (seenClass.has(declared.id))
2014
+ return;
2015
+ seenClass.add(declared.id);
2016
+ collected.push({ declared });
2017
+ for (const base of declared.declaration.bases ?? []) {
2018
+ // `*registry['plugins']['graphql_schemas']` — a starred unpacking of
2019
+ // a registry filled at runtime. netbox's real root has one, and the
2020
+ // fields behind it are real operations this run cannot see. A
2021
+ // ledger row, and the resolvable siblings still land.
2022
+ if (base === null || !/^[A-Za-z_]\w*$/.test(base)) {
2023
+ disclose(from, "SERVES_API", base ?? "<unreadable base>", declared.file, declared.range.startLine, "graphqlRootBaseUnreadable");
2024
+ continue;
2025
+ }
2026
+ walk(base, declared.file, depth + 1);
2027
+ }
2028
+ };
2029
+ walk(local, schema.file, 0);
2030
+ for (const { declared } of collected) {
2031
+ const moduleOf = moduleOfImportIn(declared.file);
2032
+ const fields = [
2033
+ ...fieldsFromAttributes(declared.declaration.attributes ?? [], declared.file, schema.naming, moduleOf),
2034
+ ];
2035
+ // The other documented spelling: `@strawberry.field def circuit(...)`.
2036
+ // A method is a separate declaration whose `path` is the class's plus
2037
+ // its own name, so it is found by prefix rather than nested.
2038
+ const owner = declared.declaration.path;
2039
+ for (const candidate of input.parsed.files[declared.file]?.declarations ?? []) {
2040
+ if (candidate.kind !== "function")
2041
+ continue;
2042
+ if (candidate.path.length !== owner.length + 1)
2043
+ continue;
2044
+ if (owner.some((part, i) => candidate.path[i] !== part))
2045
+ continue;
2046
+ const field = fieldFromMethod(
2047
+ // `name` is the dotted path — `Query.circuit_by_id`. The field is
2048
+ // the last segment, and using the whole of it would mint
2049
+ // `/Query/Query.circuit_by_id`.
2050
+ candidate.path[candidate.path.length - 1], candidate.decorators ?? [], candidate.decoratorCalls ?? [], declared.file, candidate.range.startLine, schema.naming, moduleOf);
2051
+ if (field !== null)
2052
+ fields.push(field);
2053
+ }
2054
+ for (const field of fields) {
2055
+ if (field.name === null) {
2056
+ // The one refusal this reader exists to make. Computing the
2057
+ // camel-cased name would write down a string that is nowhere in
2058
+ // the repository, and a consumer reading the real wire name would
2059
+ // mint a different id and join nothing.
2060
+ disclose(from, "SERVES_API", `${operationPath(slot, field.attributeName)} (python name; schema name unreadable)`, field.file, field.line, "graphqlFieldNameNotReadable");
2061
+ continue;
2062
+ }
2063
+ // R1 as soon as the field was reached through the import graph —
2064
+ // netbox's mixins always are. A root declaring its own fields in
2065
+ // the schema's own file needed no import and says so.
2066
+ const level = (field.file === schema.file ? 0 : 1);
2067
+ if (level > input.reached)
2068
+ continue;
2069
+ emitDjangoRoute(field.file, field.line, operationPath(slot, field.name), GRAPHQL_METHOD, level, "strawberry");
2070
+ }
2071
+ }
2072
+ }
2073
+ }
2074
+ // --- the caller half emitted ------------------------------------------
2075
+ //
2076
+ // After the routes, and deliberately sharing their endpoint identity: a
2077
+ // caller and a route in the same repository must mint the SAME
2078
+ // `API_ENDPOINT` id or the join this pattern exists for does not happen.
2079
+ // That is why `endpointQsp` is called here rather than the path being
2080
+ // formatted a second way.
2081
+ //
2082
+ // Guarded, because `USES_API` is an R2 claim: it rests on import
2083
+ // provenance, and provenance is what separates `requests.get` from a test
2084
+ // client's `client.get`. Emitting it from a run that only reached R0 was
2085
+ // rejected at the boundary as RESOLUTION_EXCEEDS_BATCH — the batch
2086
+ // contract catching a claim stronger than the run that produced it, which
2087
+ // is exactly what it is for.
2088
+ for (const { call, containerId } of input.reached >= 2 ? clientCalls : []) {
2089
+ const endpointId = nodeId(scope, "API_ENDPOINT", endpointQsp(call.method, call.path), null);
2090
+ if (!routeSeen.has(endpointId)) {
2091
+ routeSeen.add(endpointId);
2092
+ nodes.push({
2093
+ id: endpointId,
2094
+ type: "API_ENDPOINT",
2095
+ name: `${call.method} ${normaliseEndpointPath(call.path)}`,
2096
+ file: null,
2097
+ range: null,
2098
+ language: null,
2099
+ producedBy: input.producedBy,
2100
+ resolution: 2,
2101
+ attrs: { method: call.method, pathTemplate: normaliseEndpointPath(call.path) },
2102
+ });
2103
+ }
2104
+ push({
2105
+ from: containerId,
2106
+ to: endpointId,
2107
+ type: "USES_API",
2108
+ attrs: {
2109
+ client: call.client,
2110
+ file: call.file,
2111
+ line: call.line,
2112
+ written: call.rawPath,
2113
+ },
2114
+ }, 2);
2115
+ }
2116
+ }
2117
+ if (input.reached < 3)
2118
+ return { nodes, edges, unresolved };
2119
+ // --- pass 3: R3 — shapes ---------------------------------------------------
2120
+ for (const file of files) {
2121
+ const parsed = input.parsed.files[file];
2122
+ const byFull = byFullName.get(file);
2123
+ const containerOf = (scopePath) => {
2124
+ for (let depth = scopePath.length; depth > 0; depth -= 1) {
2125
+ const found = byFull.get(scopePath.slice(0, depth).join("."));
2126
+ if (found !== undefined)
2127
+ return found;
2128
+ }
2129
+ return undefined;
2130
+ };
2131
+ // RETURNS — a written return annotation, which is a stated shape.
2132
+ for (const declaration of parsed.declarations) {
2133
+ if (declaration.kind !== "function")
2134
+ continue;
2135
+ const self = byFull.get(declaration.name);
2136
+ if (self === undefined)
2137
+ continue;
2138
+ for (const name of typeNamesIn(declaration.returns)) {
2139
+ const target = followName(file, name);
2140
+ if (target !== undefined && isTypeLikeNodeType(target.type) && target.id !== self.id) {
2141
+ // **An alias is not a shape this adapter has resolved.** `RETURNS` is
2142
+ // the R3 claim — a *stated shape* — and the shape of `Batch =
2143
+ // list[Alpha]` is its right-hand side, which cannot be resolved
2144
+ // without a checker this adapter deliberately does not have
2145
+ // (DEC-062). Naming the alias is a name-level fact, and `USES_TYPE`
2146
+ // at R2 already carries it.
2147
+ //
2148
+ // This also keeps the two adapters honest against each other: given
2149
+ // the same `-> Batch`, TypeScript resolves *through* the alias with
2150
+ // its checker and emits nothing when the underlying type is outside
2151
+ // the analysed set. Python emitting an R3 edge where TypeScript
2152
+ // deliberately emits none would be an adapter-specific semantic
2153
+ // reaching the IR, which is exactly what the parity check exists to
2154
+ // catch — and it would breach the resolution cap by claiming
2155
+ // reliability A for a name.
2156
+ if (aliasIds.has(target.id))
2157
+ continue;
2158
+ push({ from: self.id, to: target.id, type: "RETURNS" }, 3);
2159
+ }
2160
+ }
2161
+ }
2162
+ // READS and WRITES — a member access on a local whose class is known.
2163
+ // Direction is taken from the AST's own Load/Store context, never assumed:
2164
+ // `resp.total = x` is a write, and filing it as a read would state the
2165
+ // opposite of what the code does in the field data propagation is built on.
2166
+ const fieldsByEdge = new Map();
2167
+ for (const reference of parsed.references) {
2168
+ const isRead = reference.kind === "attribute";
2169
+ const isWrite = reference.kind === "attribute_write";
2170
+ if ((!isRead && !isWrite) || reference.receiver === null || reference.name === null) {
2171
+ continue;
2172
+ }
2173
+ if (reference.receiver === "self")
2174
+ continue;
2175
+ const container = containerOf(reference.scope);
2176
+ if (container === undefined || container.type !== "FUNCTION")
2177
+ continue;
2178
+ const receiverType = instantiatedType(file, reference.scope, reference.receiver);
2179
+ if (receiverType === undefined)
2180
+ continue;
2181
+ // **A mapped column is a declared member, and `fields` cannot see one.**
2182
+ // `fields` collects annotated assignments only, which is right for a
2183
+ // dataclass and blind to `id = Column(String)` — SQLAlchemy 1.x and Django
2184
+ // both write a column as a plain assignment. Reading only `fields` would
2185
+ // have left every model in either style with no READS edge at all, and the
2186
+ // absence would have looked like a language limit rather than an oversight.
2187
+ const declaresField = (receiverType.declaration.fields ?? []).some((f) => f.name === reference.name) ||
2188
+ (ormShapes.get(receiverType.id)?.fields ?? []).some((f) => f.name === reference.name);
2189
+ if (!declaresField)
2190
+ continue;
2191
+ const edgeType = isWrite ? "WRITES" : "READS";
2192
+ const key = `${container.id}|${receiverType.id}|${edgeType}`;
2193
+ const entry = fieldsByEdge.get(key) ?? {
2194
+ from: container,
2195
+ to: receiverType,
2196
+ type: edgeType,
2197
+ fields: new Set(),
2198
+ };
2199
+ entry.fields.add(reference.name);
2200
+ fieldsByEdge.set(key, entry);
2201
+ }
2202
+ for (const { from, to, type, fields } of fieldsByEdge.values()) {
2203
+ push({ from: from.id, to: to.id, type, attrs: { fields: [...fields].sort() } }, 3);
2204
+ }
2205
+ }
2206
+ // --- the repository-level test-discovery audit -----------------------------
2207
+ //
2208
+ // **"We found no tests" must never be sayable without a warning attached.**
2209
+ // Every other guard here is per declaration, and per-declaration guards all
2210
+ // share one weakness: they can only fire for a file something already
2211
+ // recognised. Sentry proved that is not enough — 7,955 files, 2,593 of them
2212
+ // named `test_*.py`, and zero test cases, with nothing in the ledger to say
2213
+ // so. The audit closes that by asking a question no per-declaration rule can:
2214
+ // *did this repository look like it has tests, and did we produce any?*
2215
+ //
2216
+ // The candidate count comes from `couldHoldTests`, which does not depend on
2217
+ // the project's configuration being read correctly. That independence is the
2218
+ // point — an audit that trusts the same input as the thing it audits agrees
2219
+ // with it and calls that agreement a result.
2220
+ // Two candidate sets, because the two questions are different.
2221
+ //
2222
+ // **Total failure** is judged against `couldHoldTests`, which does not trust
2223
+ // the project's configuration — that independence is the only thing that
2224
+ // would have caught Sentry, where one malformed value made every
2225
+ // config-derived predicate false at once.
2226
+ //
2227
+ // **Partial coverage** is judged against the project's *own* declared rule. A
2228
+ // project that says `python_files = ["check_*.py"]` has excluded
2229
+ // `test_ignored.py` on purpose, and counting it as an uncovered test file
2230
+ // would manufacture a gap out of a correct exclusion.
2231
+ const independentCandidates = files.filter((file) => couldHoldTests(file, pytest));
2232
+ const declaredCandidates = files.filter((file) => isPytestFile(file, pytest));
2233
+ const candidateFiles = independentCandidates;
2234
+ if (candidateFiles.length > 0) {
2235
+ const filesWithCases = new Set(nodes.filter((node) => node.type === "TEST_CASE" && node.file !== null).map((n) => n.file));
2236
+ const cases = nodes.filter((node) => node.type === "TEST_CASE").length;
2237
+ const covered = declaredCandidates.filter((file) => filesWithCases.has(file)).length;
2238
+ // One row, at the repository level, carrying the numbers a reader needs to
2239
+ // judge the claim rather than a bare flag.
2240
+ const detail = `${independentCandidates.length} file(s) could hold tests, ` +
2241
+ `${declaredCandidates.length} match the project's declared patterns, ` +
2242
+ `${covered} produced test cases, ${cases} test case(s) total. ` +
2243
+ `Collection rules: ${pytest.source}; patterns [${pytest.filePatterns.join(", ")}].`;
2244
+ // The source is the module of the first candidate file — the one node
2245
+ // guaranteed to exist for a file whose test declarations produced nothing,
2246
+ // which is the same choice the per-declaration disclosure already makes.
2247
+ const anchor = candidateFiles[0];
2248
+ const reason = cases === 0
2249
+ ? `${REASONS.testDiscoveryFailed} ${detail}`
2250
+ : covered < declaredCandidates.length
2251
+ ? `${REASONS.testDiscoveryPartial} ${detail}`
2252
+ : undefined;
2253
+ if (reason !== undefined) {
2254
+ unresolved.push({
2255
+ fromNodeId: nodeId(scope, "MODULE", symbolQsp(pkg, modulePathOf(anchor)), LANGUAGE),
2256
+ edgeType: "TESTS",
2257
+ rawTarget: "(test discovery audit)",
2258
+ file: anchor,
2259
+ line: 1,
2260
+ producedBy: input.producedBy,
2261
+ reason,
2262
+ });
2263
+ }
2264
+ }
2265
+ return { nodes, edges, unresolved };
2266
+ }
2267
+ //# sourceMappingURL=extract.js.map