@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.
- package/dist/adapter.d.ts +75 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +481 -0
- package/dist/adapter.js.map +1 -0
- package/dist/client.d.ts +117 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +347 -0
- package/dist/client.js.map +1 -0
- package/dist/extract.d.ts +231 -0
- package/dist/extract.d.ts.map +1 -0
- package/dist/extract.js +2267 -0
- package/dist/extract.js.map +1 -0
- package/dist/extract.py +1319 -0
- package/dist/graphql.d.ts +197 -0
- package/dist/graphql.d.ts.map +1 -0
- package/dist/graphql.js +302 -0
- package/dist/graphql.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/module.d.ts +99 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +211 -0
- package/dist/module.js.map +1 -0
- package/dist/orm.d.ts +102 -0
- package/dist/orm.d.ts.map +1 -0
- package/dist/orm.js +221 -0
- package/dist/orm.js.map +1 -0
- package/dist/pydantic.d.ts +157 -0
- package/dist/pydantic.d.ts.map +1 -0
- package/dist/pydantic.js +318 -0
- package/dist/pydantic.js.map +1 -0
- package/dist/pytest-config.d.ts +60 -0
- package/dist/pytest-config.d.ts.map +1 -0
- package/dist/pytest-config.js +147 -0
- package/dist/pytest-config.js.map +1 -0
- package/dist/routes.d.ts +249 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +511 -0
- package/dist/routes.js.map +1 -0
- package/package.json +34 -0
- package/src/extract.py +1319 -0
package/dist/extract.js
ADDED
|
@@ -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
|