@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/src/extract.py
ADDED
|
@@ -0,0 +1,1319 @@
|
|
|
1
|
+
"""Descry's Python-side extractor — DEC-062.
|
|
2
|
+
|
|
3
|
+
Shipped as a file and invoked by path, never assembled by string concatenation
|
|
4
|
+
in Node. It reads a JSON request on stdin and writes one JSON document to
|
|
5
|
+
stdout; nothing is printed anywhere else, because stdout is the protocol.
|
|
6
|
+
|
|
7
|
+
## What it does and, more importantly, what it does not
|
|
8
|
+
|
|
9
|
+
It walks `ast` and reports **what the source says**. It resolves nothing across
|
|
10
|
+
files, decides no edges and makes no claim about which declaration a name refers
|
|
11
|
+
to — all of that is the Node side's job, where the resolution level is tracked
|
|
12
|
+
and the IR boundary lives. This file's output is deliberately closer to a parse
|
|
13
|
+
tree than to IR.
|
|
14
|
+
|
|
15
|
+
The split matters for one reason: everything here is R0. A fact this file states
|
|
16
|
+
is a fact about one file's text, and a fact that needs two files is not its to
|
|
17
|
+
state.
|
|
18
|
+
|
|
19
|
+
## Isolation
|
|
20
|
+
|
|
21
|
+
A file that fails to parse is recorded in `errors` with a typed reason and does
|
|
22
|
+
not stop the run. A Python file with a syntax error cannot be imported or
|
|
23
|
+
executed by anyone, so it is broken for the repository too — but "we could not
|
|
24
|
+
read seven of your files" and "we read them and found nothing" are different
|
|
25
|
+
statements, and only the ledger keeps them apart.
|
|
26
|
+
|
|
27
|
+
Requires nothing outside the standard library, so it runs against a repository
|
|
28
|
+
with no dependencies installed. That is the R0 contract.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import ast
|
|
34
|
+
import json
|
|
35
|
+
import re
|
|
36
|
+
import sys
|
|
37
|
+
|
|
38
|
+
# --- DEC-069: which module-level assignments are type aliases ----------------
|
|
39
|
+
#
|
|
40
|
+
# Python has no dedicated alias syntax before 3.12, so `X = Literal["a"]` and
|
|
41
|
+
# `MAX = 5` are the same grammar and telling them apart is a judgement call.
|
|
42
|
+
# Precision over recall decides it: this is a *closed* list of syntactically
|
|
43
|
+
# unambiguous type expressions, and anything not on it stays unmodelled.
|
|
44
|
+
|
|
45
|
+
#: Modules that provide type constructors. `collections.abc` belongs here and
|
|
46
|
+
#: leaving it out was a measured defect — PEP 585 deprecated `typing.Callable`
|
|
47
|
+
#: in favour of `collections.abc.Callable` in 3.9, and on the repository this
|
|
48
|
+
#: was sized against 5 of 9 `Callable` imports already take the modern form.
|
|
49
|
+
#: Admitting only `typing` rejected 12 of 31 aliases.
|
|
50
|
+
TYPING_MODULES = frozenset({"typing", "typing_extensions", "collections.abc"})
|
|
51
|
+
|
|
52
|
+
#: Subscriptable constructors that only ever appear in a type expression.
|
|
53
|
+
TYPING_CONSTRUCTORS = frozenset(
|
|
54
|
+
{
|
|
55
|
+
"Annotated", "Callable", "Literal", "Optional", "Union", "Sequence", "Mapping",
|
|
56
|
+
"MutableMapping", "MutableSequence", "Iterable", "Iterator", "AsyncIterator",
|
|
57
|
+
"AsyncIterable", "Awaitable", "Coroutine", "Generator", "AsyncGenerator",
|
|
58
|
+
"Dict", "List", "Set", "FrozenSet", "Tuple", "Type", "DefaultDict", "Deque",
|
|
59
|
+
"Final", "ClassVar", "Concatenate", "Required", "NotRequired", "Unpack",
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
#: Builtins whose subscripted form is a type expression and never a value.
|
|
64
|
+
BUILTIN_CONTAINERS = frozenset({"list", "dict", "set", "tuple", "frozenset", "type"})
|
|
65
|
+
|
|
66
|
+
#: Calls that *are* the declaration of a type.
|
|
67
|
+
DECLARING_CALLS = frozenset({"TypeVar", "NewType", "ParamSpec", "TypeVarTuple"})
|
|
68
|
+
|
|
69
|
+
#: Names that are types but never nodes, so never constituents of an alias edge.
|
|
70
|
+
BUILTIN_TYPES = frozenset(
|
|
71
|
+
{"str", "int", "float", "bool", "bytes", "object", "complex", "bytearray", "None", "Any"}
|
|
72
|
+
) | BUILTIN_CONTAINERS
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _range(node: ast.AST) -> dict[str, int]:
|
|
76
|
+
start = getattr(node, "lineno", 1)
|
|
77
|
+
return {"startLine": start, "endLine": getattr(node, "end_lineno", None) or start}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _annotation(node: ast.AST | None) -> str | None:
|
|
81
|
+
"""An annotation as written, not as evaluated.
|
|
82
|
+
|
|
83
|
+
`ast.unparse` gives back source text — `Optional[str]`, `str | None`,
|
|
84
|
+
`list[Order]` — which is exactly what the Node side needs to decide
|
|
85
|
+
nullability and to find the type names a declaration depends on. Evaluating
|
|
86
|
+
it would mean importing the repository's modules, which is arbitrary code
|
|
87
|
+
execution on cloned customer code.
|
|
88
|
+
"""
|
|
89
|
+
if node is None:
|
|
90
|
+
return None
|
|
91
|
+
try:
|
|
92
|
+
return ast.unparse(node)
|
|
93
|
+
except Exception: # pragma: no cover - unparse is total in practice
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
#: A value was written and this reader cannot state what it is. Distinct from
|
|
98
|
+
#: "not written", and the distinction is load-bearing: `nullable=True` and
|
|
99
|
+
#: `nullable=FLAG` are both written, and reading the second as absent would let a
|
|
100
|
+
#: framework default answer a question the source has already answered.
|
|
101
|
+
_OPAQUE = object()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _literal(node: ast.AST | None):
|
|
105
|
+
"""A written constant, or `_OPAQUE` where the value is computed."""
|
|
106
|
+
if isinstance(node, ast.Constant) and (
|
|
107
|
+
node.value is None or isinstance(node.value, (str, bool, int, float))
|
|
108
|
+
):
|
|
109
|
+
return node.value
|
|
110
|
+
return _OPAQUE
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _attribute(target: ast.Name, value: ast.AST | None, annotation: ast.AST | None, line: int) -> dict:
|
|
114
|
+
"""One assignment in a class body, recorded by syntactic form.
|
|
115
|
+
|
|
116
|
+
No ORM is named here and none is recognised. `id = Column(String,
|
|
117
|
+
nullable=True)`, `total = models.IntegerField()` and `__tablename__ =
|
|
118
|
+
"orders"` are all just an assignment with a callee, some keywords and a
|
|
119
|
+
literal — deciding which of them is a persistence mapping is the Node side's
|
|
120
|
+
job, and keeping that decision out of this file is what lets a framework be
|
|
121
|
+
added or withdrawn without touching the parser.
|
|
122
|
+
"""
|
|
123
|
+
callee = None
|
|
124
|
+
args: list[str] = []
|
|
125
|
+
keywords: dict[str, object] = {}
|
|
126
|
+
opaque: list[str] = []
|
|
127
|
+
literal = _OPAQUE
|
|
128
|
+
|
|
129
|
+
if isinstance(value, ast.Call):
|
|
130
|
+
callee = _annotation(value.func)
|
|
131
|
+
args = [_annotation(argument) or "" for argument in value.args]
|
|
132
|
+
for keyword in value.keywords:
|
|
133
|
+
if keyword.arg is None:
|
|
134
|
+
continue
|
|
135
|
+
read = _literal(keyword.value)
|
|
136
|
+
if read is _OPAQUE:
|
|
137
|
+
opaque.append(keyword.arg)
|
|
138
|
+
else:
|
|
139
|
+
keywords[keyword.arg] = read
|
|
140
|
+
elif value is not None:
|
|
141
|
+
literal = _literal(value)
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
"name": target.id,
|
|
145
|
+
"annotation": _annotation(annotation),
|
|
146
|
+
"callee": callee,
|
|
147
|
+
"args": args,
|
|
148
|
+
"keywords": keywords,
|
|
149
|
+
"opaqueKeywords": opaque,
|
|
150
|
+
"literal": None if literal is _OPAQUE else literal,
|
|
151
|
+
"hasLiteral": literal is not _OPAQUE,
|
|
152
|
+
"line": line,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _call_record(node: ast.Call, line: int, scope: list[str] | None = None) -> dict:
|
|
157
|
+
"""One call, recorded by syntactic form, naming no framework.
|
|
158
|
+
|
|
159
|
+
`@router.get("/items/{id}")`, `api.include_router(x, prefix="/p")` and
|
|
160
|
+
`path("admin/", admin.site.urls)` are all just a receiver, an attribute, some
|
|
161
|
+
positional arguments and some keywords here. Deciding which of them declares
|
|
162
|
+
an HTTP route is the Node side's job, exactly as `_attribute` leaves the ORM
|
|
163
|
+
decision there.
|
|
164
|
+
|
|
165
|
+
**A positional argument is recorded three ways on purpose.** `literals`
|
|
166
|
+
carries the written constant where there is one, `names` carries the bare
|
|
167
|
+
identifier where the argument is a plain `Name`, and `args` carries the
|
|
168
|
+
source text unconditionally. A route path built at runtime and a route path
|
|
169
|
+
written as a string must stay distinguishable after serialisation, because
|
|
170
|
+
emitting a route for the first joins a caller to a path that does not exist
|
|
171
|
+
— and nothing downstream can tell that from a correct edge.
|
|
172
|
+
"""
|
|
173
|
+
receiver = None
|
|
174
|
+
attribute = None
|
|
175
|
+
func = node.func
|
|
176
|
+
if isinstance(func, ast.Attribute):
|
|
177
|
+
attribute = func.attr
|
|
178
|
+
if isinstance(func.value, ast.Name):
|
|
179
|
+
receiver = func.value.id
|
|
180
|
+
elif isinstance(func, ast.Name):
|
|
181
|
+
attribute = func.id
|
|
182
|
+
|
|
183
|
+
literals: list[object] = []
|
|
184
|
+
names: list[object] = []
|
|
185
|
+
arg_calls: list[object] = []
|
|
186
|
+
for argument in node.args:
|
|
187
|
+
read = _literal(argument)
|
|
188
|
+
literals.append(None if read is _OPAQUE else read)
|
|
189
|
+
names.append(argument.id if isinstance(argument, ast.Name) else None)
|
|
190
|
+
# One level of nesting, no more. `path("api/", include("app.urls"))`
|
|
191
|
+
# states a prefix and the module it applies to in one expression, and
|
|
192
|
+
# flattening it would lose which prefix governs which module. Deeper
|
|
193
|
+
# nesting is not recorded because nothing measured needs it, and an
|
|
194
|
+
# unbounded walk here is how this document grew to 284 MB once already.
|
|
195
|
+
arg_calls.append(
|
|
196
|
+
_call_record(argument, argument.lineno) if isinstance(argument, ast.Call) else None
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
keywords: dict[str, object] = {}
|
|
200
|
+
keywordNames: dict[str, str] = {}
|
|
201
|
+
keywordText: dict[str, str] = {}
|
|
202
|
+
keywordLists: dict[str, list] = {}
|
|
203
|
+
opaque: list[str] = []
|
|
204
|
+
for keyword in node.keywords:
|
|
205
|
+
if keyword.arg is None:
|
|
206
|
+
continue
|
|
207
|
+
read = _literal(keyword.value)
|
|
208
|
+
if read is _OPAQUE:
|
|
209
|
+
# A list or tuple of written constants is still written down.
|
|
210
|
+
# `methods=["GET", "POST"]` is the source stating the verbs outright,
|
|
211
|
+
# and reading it as opaque would make a declared method look absent.
|
|
212
|
+
if isinstance(keyword.value, (ast.List, ast.Tuple)):
|
|
213
|
+
items = [_literal(element) for element in keyword.value.elts]
|
|
214
|
+
if items and all(item is not _OPAQUE for item in items):
|
|
215
|
+
keywordLists[keyword.arg] = items
|
|
216
|
+
else:
|
|
217
|
+
opaque.append(keyword.arg)
|
|
218
|
+
else:
|
|
219
|
+
opaque.append(keyword.arg)
|
|
220
|
+
else:
|
|
221
|
+
keywords[keyword.arg] = read
|
|
222
|
+
if isinstance(keyword.value, ast.Name):
|
|
223
|
+
keywordNames[keyword.arg] = keyword.value.id
|
|
224
|
+
# The source text unconditionally. `response_model=OrderRead` is a bare
|
|
225
|
+
# name and `response_model=list[OrderRead]` is not, and both name the
|
|
226
|
+
# same type — a reader restricted to bare names sees only the first.
|
|
227
|
+
text = _annotation(keyword.value)
|
|
228
|
+
if text is not None:
|
|
229
|
+
keywordText[keyword.arg] = text
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
"callee": _annotation(func),
|
|
233
|
+
"receiver": receiver,
|
|
234
|
+
"attribute": attribute,
|
|
235
|
+
"args": [_annotation(argument) or "" for argument in node.args],
|
|
236
|
+
"literals": literals,
|
|
237
|
+
"argNames": names,
|
|
238
|
+
"argCalls": arg_calls,
|
|
239
|
+
"hasLiterals": [_literal(argument) is not _OPAQUE for argument in node.args],
|
|
240
|
+
"keywords": keywords,
|
|
241
|
+
"keywordNames": keywordNames,
|
|
242
|
+
"keywordText": keywordText,
|
|
243
|
+
"keywordLists": keywordLists,
|
|
244
|
+
"opaqueKeywords": opaque,
|
|
245
|
+
"scope": list(scope or []),
|
|
246
|
+
"line": line,
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _class_attributes(node: ast.ClassDef) -> list[dict]:
|
|
251
|
+
"""Every assignment directly in a class body, annotated or not.
|
|
252
|
+
|
|
253
|
+
`fields` above collects only `AnnAssign`, which is right for a dataclass and
|
|
254
|
+
blind to SQLAlchemy 1.x and to Django, where a mapped column is a plain
|
|
255
|
+
`Assign` with a call on the right.
|
|
256
|
+
"""
|
|
257
|
+
collected: list[dict] = []
|
|
258
|
+
for stmt in _docstring_free(node.body):
|
|
259
|
+
if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name):
|
|
260
|
+
collected.append(_attribute(stmt.target, stmt.value, stmt.annotation, stmt.lineno))
|
|
261
|
+
elif (
|
|
262
|
+
isinstance(stmt, ast.Assign)
|
|
263
|
+
and len(stmt.targets) == 1
|
|
264
|
+
and isinstance(stmt.targets[0], ast.Name)
|
|
265
|
+
):
|
|
266
|
+
collected.append(_attribute(stmt.targets[0], stmt.value, None, stmt.lineno))
|
|
267
|
+
return collected
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _docstring_free(body: list[ast.stmt]) -> list[ast.stmt]:
|
|
271
|
+
if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant):
|
|
272
|
+
if isinstance(body[0].value.value, str):
|
|
273
|
+
return body[1:]
|
|
274
|
+
return body
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class Collector(ast.NodeVisitor):
|
|
278
|
+
"""One pass, gathering declarations, references and imports per file."""
|
|
279
|
+
|
|
280
|
+
def __init__(self, path: str) -> None:
|
|
281
|
+
self.path = path
|
|
282
|
+
self.declarations: list[dict] = []
|
|
283
|
+
self.imports: list[dict] = []
|
|
284
|
+
self.references: list[dict] = []
|
|
285
|
+
# The declaration a reference is attributed to: a dotted path such as
|
|
286
|
+
# ["OrderService", "calculate_total"]. Nested functions push onto this,
|
|
287
|
+
# so a reference inside a closure is still attributed to the top-level
|
|
288
|
+
# declaration that contains it.
|
|
289
|
+
self._scope: list[str] = []
|
|
290
|
+
# Module-level `name = ClassName()`, which is the only inference this
|
|
291
|
+
# file performs and the only one it can perform without a checker.
|
|
292
|
+
self.instantiations: list[dict] = []
|
|
293
|
+
# Module-level assignments that *might* be type aliases. Classified in
|
|
294
|
+
# `collect()` rather than here, because rule 4 asks where a constructor
|
|
295
|
+
# was imported from and the import table is only complete once the whole
|
|
296
|
+
# file has been walked. The AST value is kept and never serialised.
|
|
297
|
+
self.alias_candidates: list[dict] = []
|
|
298
|
+
# DEC-072: a class referenced as a *value* rather than named in a type.
|
|
299
|
+
# Recorded by syntactic form so the Node side can decide, and so a form
|
|
300
|
+
# that turns out to be wrong can be removed without touching the rest.
|
|
301
|
+
# Deliberately an inclusion list, not "everything that is not excluded":
|
|
302
|
+
# every form here was counted on real code before it was admitted.
|
|
303
|
+
self.value_refs: list[dict] = []
|
|
304
|
+
# Module-scope calls with their arguments, by syntactic form. Route
|
|
305
|
+
# declarations and router mounts are written here in every framework
|
|
306
|
+
# surveyed; naming any of them is the Node side's job.
|
|
307
|
+
self.calls: list[dict] = []
|
|
308
|
+
# `name = Call(...)` at module scope or one level in, dotted callees
|
|
309
|
+
# included. Feeds the route extractor's router table.
|
|
310
|
+
self.constructions: list[dict] = []
|
|
311
|
+
# Module-scope `NAME = "literal"`. The one thing needed to resolve the
|
|
312
|
+
# base of `requests.get(f"{BASE}/orders")`, which is how a Python
|
|
313
|
+
# service actually writes a call to another service: across five
|
|
314
|
+
# reference repositories not one module-level HTTP verb takes a
|
|
315
|
+
# repository-relative literal, and 78 of them take an f-string whose
|
|
316
|
+
# first element is exactly this kind of name.
|
|
317
|
+
self.constants: list[dict] = []
|
|
318
|
+
# Set by `collect()` when this file imports a package the caller named.
|
|
319
|
+
self.deep_calls: bool = False
|
|
320
|
+
# `url = f"{BASE}/orders"` inside a function, in a file that imports a
|
|
321
|
+
# client package. The largest named bucket in the caller ledger: 12 of
|
|
322
|
+
# 22 rows on the small corpora and 96 of 224 on posthog pass a local
|
|
323
|
+
# assembled a few lines above the call.
|
|
324
|
+
self.local_strings: list[dict] = []
|
|
325
|
+
|
|
326
|
+
# --- declarations ------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
def _function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
|
329
|
+
path = [*self._scope, node.name]
|
|
330
|
+
self.declarations.append(
|
|
331
|
+
{
|
|
332
|
+
"kind": "function",
|
|
333
|
+
"path": path,
|
|
334
|
+
"name": ".".join(path),
|
|
335
|
+
"range": _range(node),
|
|
336
|
+
"returns": _annotation(node.returns),
|
|
337
|
+
"params": [
|
|
338
|
+
{"name": arg.arg, "annotation": _annotation(arg.annotation)}
|
|
339
|
+
for arg in [*node.args.posonlyargs, *node.args.args, *node.args.kwonlyargs]
|
|
340
|
+
# `self` and `cls` carry no annotation and name no type.
|
|
341
|
+
if arg.arg not in ("self", "cls")
|
|
342
|
+
],
|
|
343
|
+
"decorators": [_annotation(d) for d in node.decorator_list],
|
|
344
|
+
"decoratorCalls": [
|
|
345
|
+
# The scope a decorator is *evaluated* in is the one
|
|
346
|
+
# containing the declaration, not the declaration's own.
|
|
347
|
+
# `@app.route(...)` on a function nested in a test resolves
|
|
348
|
+
# `app` in that test's scope.
|
|
349
|
+
_call_record(d, d.lineno, self._scope)
|
|
350
|
+
for d in node.decorator_list
|
|
351
|
+
if isinstance(d, ast.Call)
|
|
352
|
+
],
|
|
353
|
+
}
|
|
354
|
+
)
|
|
355
|
+
self._scope.append(node.name)
|
|
356
|
+
# Attributed to the declaration being decorated, for the same reason the
|
|
357
|
+
# defaults below are: the dependency belongs to `handle`, and filing it
|
|
358
|
+
# at module level leaves it with no declaration to hang on.
|
|
359
|
+
self._decorator_refs(node)
|
|
360
|
+
# Attributed to the function being declared, not to the scope Python
|
|
361
|
+
# evaluates the default in. The dependency belongs to `create_app`, and
|
|
362
|
+
# filing it at module level leaves it with no declaration to hang on.
|
|
363
|
+
for default in [*node.args.defaults, *node.args.kw_defaults]:
|
|
364
|
+
self._value_ref(default, "default")
|
|
365
|
+
for child in node.body:
|
|
366
|
+
self.visit(child)
|
|
367
|
+
self._scope.pop()
|
|
368
|
+
|
|
369
|
+
visit_FunctionDef = _function
|
|
370
|
+
visit_AsyncFunctionDef = _function
|
|
371
|
+
|
|
372
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
373
|
+
path = [*self._scope, node.name]
|
|
374
|
+
fields = [
|
|
375
|
+
{
|
|
376
|
+
"name": stmt.target.id,
|
|
377
|
+
"annotation": _annotation(stmt.annotation),
|
|
378
|
+
"hasDefault": stmt.value is not None,
|
|
379
|
+
}
|
|
380
|
+
for stmt in _docstring_free(node.body)
|
|
381
|
+
if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name)
|
|
382
|
+
]
|
|
383
|
+
self.declarations.append(
|
|
384
|
+
{
|
|
385
|
+
"kind": "class",
|
|
386
|
+
"path": path,
|
|
387
|
+
"name": ".".join(path),
|
|
388
|
+
"range": _range(node),
|
|
389
|
+
"bases": [_annotation(b) for b in node.bases],
|
|
390
|
+
"fields": fields,
|
|
391
|
+
"attributes": _class_attributes(node),
|
|
392
|
+
"decorators": [_annotation(d) for d in node.decorator_list],
|
|
393
|
+
"decoratorCalls": [
|
|
394
|
+
# The scope a decorator is *evaluated* in is the one
|
|
395
|
+
# containing the declaration, not the declaration's own.
|
|
396
|
+
# `@app.route(...)` on a function nested in a test resolves
|
|
397
|
+
# `app` in that test's scope.
|
|
398
|
+
_call_record(d, d.lineno, self._scope)
|
|
399
|
+
for d in node.decorator_list
|
|
400
|
+
if isinstance(d, ast.Call)
|
|
401
|
+
],
|
|
402
|
+
}
|
|
403
|
+
)
|
|
404
|
+
self._scope.append(node.name)
|
|
405
|
+
self._decorator_refs(node)
|
|
406
|
+
for child in node.body:
|
|
407
|
+
self.visit(child)
|
|
408
|
+
self._scope.pop()
|
|
409
|
+
|
|
410
|
+
# --- imports -----------------------------------------------------------
|
|
411
|
+
|
|
412
|
+
def visit_Import(self, node: ast.Import) -> None:
|
|
413
|
+
for alias in node.names:
|
|
414
|
+
self.imports.append(
|
|
415
|
+
{
|
|
416
|
+
"kind": "absolute",
|
|
417
|
+
"level": 0,
|
|
418
|
+
"module": alias.name,
|
|
419
|
+
"name": None,
|
|
420
|
+
"local": alias.asname or alias.name.split(".")[0],
|
|
421
|
+
"line": node.lineno,
|
|
422
|
+
}
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
426
|
+
for alias in node.names:
|
|
427
|
+
self.imports.append(
|
|
428
|
+
{
|
|
429
|
+
# `level` is the number of leading dots and is the whole of
|
|
430
|
+
# relative-import resolution. `from . import x` is level 1
|
|
431
|
+
# with no module, which is the re-export hop pattern 3 turns
|
|
432
|
+
# on, and it is not recoverable from the text alone.
|
|
433
|
+
"kind": "relative" if node.level else "absolute",
|
|
434
|
+
"level": node.level,
|
|
435
|
+
"module": node.module,
|
|
436
|
+
"name": alias.name,
|
|
437
|
+
"local": alias.asname or alias.name,
|
|
438
|
+
"line": node.lineno,
|
|
439
|
+
}
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
# --- references --------------------------------------------------------
|
|
443
|
+
|
|
444
|
+
def visit_Call(self, node: ast.Call) -> None:
|
|
445
|
+
# Arguments first, so `isinstance(x, Foo)` and `f(response_model=Foo)`
|
|
446
|
+
# are recorded whatever the callee turns out to be. The callee itself is
|
|
447
|
+
# a constructor call and is already covered (DEC-068).
|
|
448
|
+
for argument in node.args:
|
|
449
|
+
self._value_ref(argument, "argument")
|
|
450
|
+
for keyword in node.keywords:
|
|
451
|
+
self._value_ref(keyword.value, "argument")
|
|
452
|
+
|
|
453
|
+
func = node.func
|
|
454
|
+
if isinstance(func, ast.Name):
|
|
455
|
+
self._reference("call", func.id, None, func.lineno)
|
|
456
|
+
elif isinstance(func, ast.Attribute):
|
|
457
|
+
base = func.value
|
|
458
|
+
# `service.calculate_total(...)` — the receiver is reported as
|
|
459
|
+
# written. Deciding what `service` is belongs to the Node side.
|
|
460
|
+
receiver = base.id if isinstance(base, ast.Name) else None
|
|
461
|
+
if isinstance(base, ast.Name) and base.id == "self":
|
|
462
|
+
receiver = "self"
|
|
463
|
+
self._reference("method", func.attr, receiver, func.lineno)
|
|
464
|
+
|
|
465
|
+
# Calls at module scope **and one level in**, with their arguments.
|
|
466
|
+
#
|
|
467
|
+
# "Module scope only" was the first rule and it was wrong, measured on
|
|
468
|
+
# the second repository rather than reasoned about. The application
|
|
469
|
+
# factory — `def get_app(): app = FastAPI(); app.include_router(...)` —
|
|
470
|
+
# is a documented idiom in both Flask and FastAPI, and it put the entire
|
|
471
|
+
# mount chain one level below where this was looking. On sherpa-backend
|
|
472
|
+
# that cost **47 of 49 routes**, disclosed as "router never mounted",
|
|
473
|
+
# which is a true sentence about what this file recorded and a false one
|
|
474
|
+
# about the repository.
|
|
475
|
+
#
|
|
476
|
+
# Depth 1 and no deeper: it covers a top-level function or class body,
|
|
477
|
+
# which is where declarative configuration is written, and it leaves the
|
|
478
|
+
# payload bounded. The 284 MB overflow that forced line-delimited output
|
|
479
|
+
# is the standing reminder that this document has a size budget.
|
|
480
|
+
if len(self._scope) <= 1:
|
|
481
|
+
self.calls.append(_call_record(node, node.lineno, self._scope))
|
|
482
|
+
elif self.deep_calls and isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
|
|
483
|
+
# Depth 1 is right for declarative configuration and wrong for the
|
|
484
|
+
# caller half: an HTTP call lives in a method, which is depth 2.
|
|
485
|
+
# Measured on `dispatch` — the text holds 14 module-level
|
|
486
|
+
# `requests`/`httpx` verb calls and this reader saw **4**, so ten
|
|
487
|
+
# were neither an edge nor a ledger row.
|
|
488
|
+
#
|
|
489
|
+
# Widened along two axes at once, deliberately, because the 284 MB
|
|
490
|
+
# overflow that forced line-delimited output is what a general
|
|
491
|
+
# widening here costs: only in a file that imports one of the
|
|
492
|
+
# packages the Node side named, and only for `name.attr(...)`, which
|
|
493
|
+
# is the exact shape the client reader consumes. Everything else at
|
|
494
|
+
# depth 2 and below stays unrecorded.
|
|
495
|
+
self.calls.append(_call_record(node, node.lineno, self._scope))
|
|
496
|
+
|
|
497
|
+
# A test case is a call, so it is recognised here rather than in a
|
|
498
|
+
# second walk over the same tree.
|
|
499
|
+
self.generic_visit(node)
|
|
500
|
+
|
|
501
|
+
def visit_Attribute(self, node: ast.Attribute) -> None:
|
|
502
|
+
if isinstance(node.value, ast.Name):
|
|
503
|
+
# Load or Store. `resp.total = x` writes; `order.total_amount` reads,
|
|
504
|
+
# and calling both a read would put a WRITES relationship in the
|
|
505
|
+
# graph under the READS label — a false claim about direction, which
|
|
506
|
+
# data propagation is built on.
|
|
507
|
+
kind = "attribute_write" if isinstance(node.ctx, ast.Store) else "attribute"
|
|
508
|
+
self._reference(kind, node.attr, node.value.id, node.lineno)
|
|
509
|
+
self.generic_visit(node)
|
|
510
|
+
|
|
511
|
+
def _constant(self, target: ast.expr, value: ast.expr, line: int) -> None:
|
|
512
|
+
"""Record `NAME = "literal"` at module scope, and nothing cleverer.
|
|
513
|
+
|
|
514
|
+
A string built by concatenation, `os.environ.get`, or a call is *not*
|
|
515
|
+
recorded: an unresolved base becomes a named refusal on the Node side,
|
|
516
|
+
and a base guessed from half its expression is an endpoint nobody
|
|
517
|
+
serves. Same asymmetry as everywhere else — a missing constant is a
|
|
518
|
+
disclosed gap, a wrong one is a wrong edge.
|
|
519
|
+
"""
|
|
520
|
+
if len(self._scope) != 0 or not isinstance(target, ast.Name):
|
|
521
|
+
return
|
|
522
|
+
if not isinstance(value, ast.Constant) or not isinstance(value.value, str):
|
|
523
|
+
return
|
|
524
|
+
self.constants.append({"name": target.id, "value": value.value, "line": line})
|
|
525
|
+
|
|
526
|
+
def _local_string(self, target: ast.expr, value: ast.expr, line: int) -> None:
|
|
527
|
+
"""`name = "literal"` or `name = f"..."` inside a function.
|
|
528
|
+
|
|
529
|
+
Recorded only in a file that imports a client package, and only below
|
|
530
|
+
module scope — the module-level case is already `constants`. The value
|
|
531
|
+
is kept as **source text** rather than a parsed template, because the
|
|
532
|
+
Node side owns every rule about what a path may look like and this file
|
|
533
|
+
owns none of them.
|
|
534
|
+
"""
|
|
535
|
+
if not self.deep_calls or len(self._scope) == 0:
|
|
536
|
+
return
|
|
537
|
+
if not isinstance(target, ast.Name):
|
|
538
|
+
return
|
|
539
|
+
if not isinstance(value, (ast.Constant, ast.JoinedStr)):
|
|
540
|
+
return
|
|
541
|
+
if isinstance(value, ast.Constant) and not isinstance(value.value, str):
|
|
542
|
+
return
|
|
543
|
+
unparse = getattr(ast, "unparse", None)
|
|
544
|
+
if unparse is None:
|
|
545
|
+
# 3.8 and older. A disclosed gap: this reader simply records
|
|
546
|
+
# nothing, rather than reconstructing source badly.
|
|
547
|
+
return
|
|
548
|
+
self.local_strings.append(
|
|
549
|
+
{
|
|
550
|
+
"name": target.id,
|
|
551
|
+
"value": unparse(value),
|
|
552
|
+
"scope": list(self._scope),
|
|
553
|
+
"line": line,
|
|
554
|
+
}
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
def visit_Assign(self, node: ast.Assign) -> None:
|
|
558
|
+
if len(node.targets) == 1:
|
|
559
|
+
self._constant(node.targets[0], node.value, node.lineno)
|
|
560
|
+
self._local_string(node.targets[0], node.value, node.lineno)
|
|
561
|
+
|
|
562
|
+
# Every assignment whose right-hand side is a call, dotted callee
|
|
563
|
+
# included. `instantiations` below deliberately takes only a bare
|
|
564
|
+
# `Name` constructor, because it feeds receiver typing where a dotted
|
|
565
|
+
# name resolves to nothing useful — but `app = flask.Flask(__name__)`
|
|
566
|
+
# and `app = fastapi.FastAPI()` are the same declaration written the
|
|
567
|
+
# other legal way, and a reader that only sees the bare form misses
|
|
568
|
+
# them silently. Recorded separately rather than by widening
|
|
569
|
+
# `instantiations`, so no existing pass changes behaviour.
|
|
570
|
+
if (
|
|
571
|
+
isinstance(node.value, ast.Call)
|
|
572
|
+
and len(node.targets) == 1
|
|
573
|
+
and isinstance(node.targets[0], ast.Name)
|
|
574
|
+
and len(self._scope) <= 1
|
|
575
|
+
):
|
|
576
|
+
self.constructions.append(
|
|
577
|
+
{
|
|
578
|
+
"local": node.targets[0].id,
|
|
579
|
+
"callee": _annotation(node.value.func),
|
|
580
|
+
"scope": list(self._scope),
|
|
581
|
+
"line": node.lineno,
|
|
582
|
+
}
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
# The one inference: `service = OrderService()`. Recorded only where the
|
|
586
|
+
# right-hand side is a bare constructor call, because anything else
|
|
587
|
+
# needs a type checker and guessing is what precision-over-recall
|
|
588
|
+
# forbids.
|
|
589
|
+
if (
|
|
590
|
+
isinstance(node.value, ast.Call)
|
|
591
|
+
and isinstance(node.value.func, ast.Name)
|
|
592
|
+
and len(node.targets) == 1
|
|
593
|
+
):
|
|
594
|
+
target = node.targets[0]
|
|
595
|
+
if isinstance(target, ast.Name):
|
|
596
|
+
self.instantiations.append(
|
|
597
|
+
{
|
|
598
|
+
"local": target.id,
|
|
599
|
+
"typeName": node.value.func.id,
|
|
600
|
+
"scope": list(self._scope),
|
|
601
|
+
"line": node.lineno,
|
|
602
|
+
}
|
|
603
|
+
)
|
|
604
|
+
# A module-level binding only. An indented assignment is a field or a
|
|
605
|
+
# local, and neither is importable, so neither can be an alias anything
|
|
606
|
+
# else refers to by name.
|
|
607
|
+
if not self._scope and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
|
|
608
|
+
self.alias_candidates.append(
|
|
609
|
+
{
|
|
610
|
+
"name": node.targets[0].id,
|
|
611
|
+
"range": _range(node),
|
|
612
|
+
"annotation": None,
|
|
613
|
+
"value": node.value,
|
|
614
|
+
"pep695": False,
|
|
615
|
+
}
|
|
616
|
+
)
|
|
617
|
+
self.generic_visit(node)
|
|
618
|
+
|
|
619
|
+
def _with(self, node: ast.With | ast.AsyncWith) -> None:
|
|
620
|
+
"""`with X() as name:` / `async with X() as name:` bind `name` to a
|
|
621
|
+
constructor call the same way `visit_Assign` binds an assignment
|
|
622
|
+
target — but a with-item is not an `Assign`, so without this the
|
|
623
|
+
binding was invisible to `constructions` entirely.
|
|
624
|
+
|
|
625
|
+
This is aiohttp's canonical idiom: `async with aiohttp.ClientSession()
|
|
626
|
+
as session:` never assigns `session`, and `requests`/`httpx` support
|
|
627
|
+
the identical shape (`with requests.Session() as s:`). Both left
|
|
628
|
+
`clientLocalsIn` (the Node side) with no local to resolve a later
|
|
629
|
+
`session.get(...)` receiver against — the call itself was already
|
|
630
|
+
walked and recorded via `generic_visit`, only the binding was missing.
|
|
631
|
+
|
|
632
|
+
Same depth rule as `visit_Assign`'s construction recording (`scope <=
|
|
633
|
+
1`), for the same reason: a match here has to be one function's own
|
|
634
|
+
client, not two functions' guesses merged into one local name.
|
|
635
|
+
"""
|
|
636
|
+
if len(self._scope) <= 1:
|
|
637
|
+
for item in node.items:
|
|
638
|
+
value = item.context_expr
|
|
639
|
+
target = item.optional_vars
|
|
640
|
+
if isinstance(value, ast.Call) and isinstance(target, ast.Name):
|
|
641
|
+
self.constructions.append(
|
|
642
|
+
{
|
|
643
|
+
"local": target.id,
|
|
644
|
+
"callee": _annotation(value.func),
|
|
645
|
+
"scope": list(self._scope),
|
|
646
|
+
"line": value.lineno,
|
|
647
|
+
}
|
|
648
|
+
)
|
|
649
|
+
self.generic_visit(node)
|
|
650
|
+
|
|
651
|
+
visit_With = _with
|
|
652
|
+
visit_AsyncWith = _with
|
|
653
|
+
|
|
654
|
+
def visit_TypeAlias(self, node: ast.AST) -> None:
|
|
655
|
+
"""PEP 695 `type X = …`, Python 3.12+. Unambiguous by construction."""
|
|
656
|
+
target = getattr(node, "name", None)
|
|
657
|
+
if isinstance(target, ast.Name):
|
|
658
|
+
self.alias_candidates.append(
|
|
659
|
+
{
|
|
660
|
+
"name": target.id,
|
|
661
|
+
"range": _range(node),
|
|
662
|
+
"annotation": None,
|
|
663
|
+
"value": getattr(node, "value", None),
|
|
664
|
+
"pep695": True,
|
|
665
|
+
}
|
|
666
|
+
)
|
|
667
|
+
self.generic_visit(node)
|
|
668
|
+
|
|
669
|
+
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
|
|
670
|
+
# Folded in here rather than given its own visitor: a second
|
|
671
|
+
# `visit_AnnAssign` on this class would silently override the first,
|
|
672
|
+
# and the one that lost would be whichever was defined earlier.
|
|
673
|
+
if node.value is not None:
|
|
674
|
+
self._constant(node.target, node.value, node.lineno)
|
|
675
|
+
if isinstance(node.target, ast.Name) and node.annotation is not None:
|
|
676
|
+
self.references.append(
|
|
677
|
+
{
|
|
678
|
+
"kind": "annotation",
|
|
679
|
+
"name": _annotation(node.annotation),
|
|
680
|
+
"receiver": None,
|
|
681
|
+
"scope": list(self._scope),
|
|
682
|
+
"line": node.lineno,
|
|
683
|
+
}
|
|
684
|
+
)
|
|
685
|
+
if not self._scope and node.value is not None:
|
|
686
|
+
self.alias_candidates.append(
|
|
687
|
+
{
|
|
688
|
+
"name": node.target.id,
|
|
689
|
+
"range": _range(node),
|
|
690
|
+
"annotation": _annotation(node.annotation),
|
|
691
|
+
"value": node.value,
|
|
692
|
+
"pep695": False,
|
|
693
|
+
}
|
|
694
|
+
)
|
|
695
|
+
self.generic_visit(node)
|
|
696
|
+
|
|
697
|
+
def _value_ref(self, node: ast.AST | None, form: str) -> None:
|
|
698
|
+
"""Record a bare name loaded as a value. Anything else is ignored."""
|
|
699
|
+
if isinstance(node, ast.Name):
|
|
700
|
+
self.value_refs.append(
|
|
701
|
+
{"name": node.id, "form": form, "scope": list(self._scope), "line": node.lineno}
|
|
702
|
+
)
|
|
703
|
+
elif isinstance(node, (ast.Tuple, ast.List)):
|
|
704
|
+
# `except (A, B):` and `isinstance(x, (A, B))` — the tuple is the
|
|
705
|
+
# syntax, the elements are the dependency.
|
|
706
|
+
for element in node.elts:
|
|
707
|
+
self._value_ref(element, form)
|
|
708
|
+
|
|
709
|
+
def visit_Return(self, node: ast.Return) -> None:
|
|
710
|
+
self._value_ref(node.value, "return")
|
|
711
|
+
self.generic_visit(node)
|
|
712
|
+
|
|
713
|
+
def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
|
|
714
|
+
# `except SeedError:` depends on the exception class as surely as a call
|
|
715
|
+
# does. It had no form of its own until the AST was walked to find out.
|
|
716
|
+
self._value_ref(node.type, "except")
|
|
717
|
+
self.generic_visit(node)
|
|
718
|
+
|
|
719
|
+
def visit_MatchClass(self, node: ast.AST) -> None:
|
|
720
|
+
# PEP 634 `case BillExtraction(vendor_data=data):`. The class is the whole
|
|
721
|
+
# of the pattern, and on the repository this was measured against it
|
|
722
|
+
# carries the entire extraction dispatch.
|
|
723
|
+
self._value_ref(getattr(node, "cls", None), "match")
|
|
724
|
+
self.generic_visit(node)
|
|
725
|
+
|
|
726
|
+
def visit_comprehension(self, node: ast.comprehension) -> None:
|
|
727
|
+
# `{stage.value for stage in Stage}` — iterating an enum class.
|
|
728
|
+
self._value_ref(node.iter, "iterate")
|
|
729
|
+
self.generic_visit(node)
|
|
730
|
+
|
|
731
|
+
def _collection(self, node: ast.AST) -> None:
|
|
732
|
+
for element in getattr(node, "elts", []):
|
|
733
|
+
self._value_ref(element, "collection")
|
|
734
|
+
for value in getattr(node, "values", []):
|
|
735
|
+
self._value_ref(value, "collection")
|
|
736
|
+
self.generic_visit(node)
|
|
737
|
+
|
|
738
|
+
visit_List = _collection
|
|
739
|
+
visit_Set = _collection
|
|
740
|
+
visit_Dict = _collection
|
|
741
|
+
|
|
742
|
+
def visit_For(self, node: ast.For) -> None:
|
|
743
|
+
self._value_ref(node.iter, "iterate")
|
|
744
|
+
self.generic_visit(node)
|
|
745
|
+
|
|
746
|
+
def _decorator_refs(self, node: ast.AST) -> None:
|
|
747
|
+
"""`@no_translations` — a decorator names a real dependency.
|
|
748
|
+
|
|
749
|
+
Measured at zero for classes when DEC-072 drew its form list, and at 2
|
|
750
|
+
edges for functions on `django/core`. Small, and admitted for a reason
|
|
751
|
+
the count does not carry: a decorator *wraps* the declaration, so it is
|
|
752
|
+
one of the few dependencies that can change a function's behaviour
|
|
753
|
+
without appearing anywhere in its body. `@decorator(arg)` is a call and
|
|
754
|
+
its arguments are already collected by `visit_Call`; only the bare form
|
|
755
|
+
needs handling here.
|
|
756
|
+
"""
|
|
757
|
+
for decorator in getattr(node, "decorator_list", []):
|
|
758
|
+
self._value_ref(decorator, "decorator")
|
|
759
|
+
|
|
760
|
+
def _reference(self, kind: str, name: str, receiver: str | None, line: int) -> None:
|
|
761
|
+
self.references.append(
|
|
762
|
+
{
|
|
763
|
+
"kind": kind,
|
|
764
|
+
"name": name,
|
|
765
|
+
"receiver": receiver,
|
|
766
|
+
"scope": list(self._scope),
|
|
767
|
+
"line": line,
|
|
768
|
+
}
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def _root_name(node: ast.AST) -> str | None:
|
|
773
|
+
"""The name an expression is rooted at. `DocumentType.VENDOR_BILL` -> `DocumentType`."""
|
|
774
|
+
while isinstance(node, ast.Attribute):
|
|
775
|
+
node = node.value
|
|
776
|
+
return node.id if isinstance(node, ast.Name) else None
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def _constituents(value: ast.AST | None) -> list[str]:
|
|
780
|
+
"""Type names an alias body mentions, minus the machinery that built it.
|
|
781
|
+
|
|
782
|
+
A constructor is not a constituent — `Callable[[Alpha], Beta]` depends on
|
|
783
|
+
`Alpha` and `Beta`, not on `Callable`, and an edge to the latter would point
|
|
784
|
+
outside the analysed set at something no change can break.
|
|
785
|
+
"""
|
|
786
|
+
if value is None:
|
|
787
|
+
return []
|
|
788
|
+
found: list[str] = []
|
|
789
|
+
for node in ast.walk(value):
|
|
790
|
+
name = None
|
|
791
|
+
if isinstance(node, ast.Attribute):
|
|
792
|
+
name = _root_name(node)
|
|
793
|
+
elif isinstance(node, ast.Name):
|
|
794
|
+
name = node.id
|
|
795
|
+
if name is None or name in found:
|
|
796
|
+
continue
|
|
797
|
+
if name in TYPING_CONSTRUCTORS or name in DECLARING_CALLS or name in BUILTIN_TYPES:
|
|
798
|
+
continue
|
|
799
|
+
found.append(name)
|
|
800
|
+
return found
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _env_reads(tree: ast.Module, imports: list[dict]) -> tuple[list[dict], int]:
|
|
804
|
+
"""`attrs.envReads` (DEC-120, carried on `MODULE` per DEC-124).
|
|
805
|
+
|
|
806
|
+
DEC-121: a read asserts *requirement* only when the accessor itself fails
|
|
807
|
+
on absence — a subscript, which raises `KeyError`, or a config-accessor
|
|
808
|
+
call with no `default=` keyword, which raises. `os.environ.get`/
|
|
809
|
+
`os.getenv` both yield `None` and never fail; they are recorded as reads
|
|
810
|
+
but never as required, however the result is used afterwards — deciding
|
|
811
|
+
that needs dataflow this level does not have (DEC-121's own withdrawn
|
|
812
|
+
first rule).
|
|
813
|
+
|
|
814
|
+
Two accessor shapes read a variable through a `default=`-or-raise call:
|
|
815
|
+
`decouple.config`, imported directly, and Starlette's `Config` (also
|
|
816
|
+
FastAPI's, which re-exports it) — instantiated once as `config =
|
|
817
|
+
Config(".env")` and then called like a function. Both are recognised only
|
|
818
|
+
from a *verified* import, never from the bare name `config` —
|
|
819
|
+
"if the framework will tell you, never infer it" applies to an accessor's
|
|
820
|
+
identity as much as to a route's. `config = Config(".env")` was the case
|
|
821
|
+
that made this two shapes instead of one: dispatch reads five required
|
|
822
|
+
variables through it and none through `decouple`.
|
|
823
|
+
|
|
824
|
+
There is no scope tracking at this level, so a name used as a function
|
|
825
|
+
PARAMETER anywhere in the file is dropped from consideration entirely,
|
|
826
|
+
for every accessor shape — a local shadow calling itself is not this
|
|
827
|
+
file's real accessor, and ambiguous counts as incorrect (DEC-057).
|
|
828
|
+
"""
|
|
829
|
+
decouple_names = {r["local"] for r in imports if r["module"] == "decouple" and r["name"] == "config"}
|
|
830
|
+
decouple_modules = {r["local"] for r in imports if r["module"] == "decouple" and r["name"] is None}
|
|
831
|
+
config_class_names = {
|
|
832
|
+
r["local"]
|
|
833
|
+
for r in imports
|
|
834
|
+
if r["module"] in ("starlette.config", "fastapi.datastructures") and r["name"] == "Config"
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
# `X = Config(".env")` — the ONE assignment shape that DEFINES an accessor
|
|
838
|
+
# rather than shadowing one, so it runs before the parameter-shadow guard
|
|
839
|
+
# below rather than being caught by it.
|
|
840
|
+
config_instances: set[str] = set()
|
|
841
|
+
for node in ast.walk(tree):
|
|
842
|
+
if not (isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name)):
|
|
843
|
+
continue
|
|
844
|
+
value = node.value
|
|
845
|
+
if isinstance(value, ast.Call) and isinstance(value.func, ast.Name) and value.func.id in config_class_names:
|
|
846
|
+
config_instances.add(node.targets[0].id)
|
|
847
|
+
|
|
848
|
+
param_shadowed: set[str] = set()
|
|
849
|
+
for node in ast.walk(tree):
|
|
850
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
|
|
851
|
+
for arg in (*node.args.posonlyargs, *node.args.args, *node.args.kwonlyargs):
|
|
852
|
+
param_shadowed.add(arg.arg)
|
|
853
|
+
decouple_names -= param_shadowed
|
|
854
|
+
decouple_modules -= param_shadowed
|
|
855
|
+
config_instances -= param_shadowed
|
|
856
|
+
|
|
857
|
+
reads: dict[str, dict] = {}
|
|
858
|
+
dynamic = 0
|
|
859
|
+
|
|
860
|
+
def add(name: str, required: bool, line: int) -> None:
|
|
861
|
+
existing = reads.get(name)
|
|
862
|
+
if existing is None:
|
|
863
|
+
reads[name] = {"name": name, "required": required, "line": line}
|
|
864
|
+
elif required and not existing["required"]:
|
|
865
|
+
existing["required"] = True
|
|
866
|
+
|
|
867
|
+
def is_os_environ(node: ast.AST) -> bool:
|
|
868
|
+
return (
|
|
869
|
+
isinstance(node, ast.Attribute)
|
|
870
|
+
and node.attr == "environ"
|
|
871
|
+
and isinstance(node.value, ast.Name)
|
|
872
|
+
and node.value.id == "os"
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
def is_os_getenv(node: ast.AST) -> bool:
|
|
876
|
+
return (
|
|
877
|
+
isinstance(node, ast.Attribute)
|
|
878
|
+
and node.attr == "getenv"
|
|
879
|
+
and isinstance(node.value, ast.Name)
|
|
880
|
+
and node.value.id == "os"
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
def is_environ_get(node: ast.AST) -> bool:
|
|
884
|
+
return isinstance(node, ast.Attribute) and node.attr == "get" and is_os_environ(node.value)
|
|
885
|
+
|
|
886
|
+
def string_key(node: ast.AST | None) -> str | None:
|
|
887
|
+
return node.value if isinstance(node, ast.Constant) and isinstance(node.value, str) else None
|
|
888
|
+
|
|
889
|
+
for node in ast.walk(tree):
|
|
890
|
+
if isinstance(node, ast.Subscript) and is_os_environ(node.value):
|
|
891
|
+
# `os.environ["X"] = "…"` writes; only a read can raise on absence.
|
|
892
|
+
if not isinstance(node.ctx, ast.Load):
|
|
893
|
+
continue
|
|
894
|
+
key = string_key(node.slice)
|
|
895
|
+
if key is None:
|
|
896
|
+
dynamic += 1
|
|
897
|
+
else:
|
|
898
|
+
add(key, True, node.lineno)
|
|
899
|
+
continue
|
|
900
|
+
|
|
901
|
+
if not isinstance(node, ast.Call):
|
|
902
|
+
continue
|
|
903
|
+
func = node.func
|
|
904
|
+
|
|
905
|
+
if is_os_getenv(func) or is_environ_get(func):
|
|
906
|
+
key = string_key(node.args[0]) if node.args else None
|
|
907
|
+
if key is None:
|
|
908
|
+
dynamic += 1
|
|
909
|
+
else:
|
|
910
|
+
add(key, False, node.lineno)
|
|
911
|
+
continue
|
|
912
|
+
|
|
913
|
+
is_config_call = (
|
|
914
|
+
(isinstance(func, ast.Name) and (func.id in decouple_names or func.id in config_instances))
|
|
915
|
+
or (
|
|
916
|
+
isinstance(func, ast.Attribute)
|
|
917
|
+
and func.attr == "config"
|
|
918
|
+
and isinstance(func.value, ast.Name)
|
|
919
|
+
and func.value.id in decouple_modules
|
|
920
|
+
)
|
|
921
|
+
)
|
|
922
|
+
if is_config_call:
|
|
923
|
+
key = string_key(node.args[0]) if node.args else None
|
|
924
|
+
has_default = any(kw.arg == "default" for kw in node.keywords)
|
|
925
|
+
if key is None:
|
|
926
|
+
dynamic += 1
|
|
927
|
+
else:
|
|
928
|
+
add(key, not has_default, node.lineno)
|
|
929
|
+
|
|
930
|
+
return list(reads.values()), dynamic
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def _migration_ops(tree: ast.Module, imports: list[dict]) -> list[dict]:
|
|
934
|
+
"""`attrs.migrationOps` (DEC-122, carried on `MODULE` per DEC-124).
|
|
935
|
+
|
|
936
|
+
Alembic-shaped: a call on a name VERIFIED as `from alembic import op`.
|
|
937
|
+
`section` comes from which top-level function enclosed the call —
|
|
938
|
+
Alembic's actual `def upgrade()`/`def downgrade()` split, the real
|
|
939
|
+
forward/reverse mechanism, not the comment-directive shape the SQL-format
|
|
940
|
+
half of this check matches (`migrations.ts`'s docstring is about a
|
|
941
|
+
DIFFERENT input). A call in neither is `unknown` and dropped downstream
|
|
942
|
+
by the check itself (DEC-122: unknown is not a synonym for forward).
|
|
943
|
+
|
|
944
|
+
Only the operations DEC-122 scoped in: `drop_table`, `drop_column`,
|
|
945
|
+
`rename_table`, and `alter_column(..., new_column_name=...)` — which is
|
|
946
|
+
a rename ONLY when that keyword is present; without it, `alter_column`
|
|
947
|
+
changes a type or nullability, not a name. `drop_index`/`drop_constraint`
|
|
948
|
+
are excluded on purpose — both recoverable, DEC-122's own scope fence.
|
|
949
|
+
No Alembic construct in this reference set guards a drop the way
|
|
950
|
+
`DROP … IF EXISTS` does in raw DDL, so `guarded` is always `False` here —
|
|
951
|
+
stated rather than silently defaulted, so a future guarded form is a
|
|
952
|
+
change to this line, not a silent gap.
|
|
953
|
+
"""
|
|
954
|
+
op_names = {r["local"] for r in imports if r["module"] == "alembic" and r["name"] == "op"}
|
|
955
|
+
|
|
956
|
+
param_shadowed: set[str] = set()
|
|
957
|
+
for node in ast.walk(tree):
|
|
958
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
|
|
959
|
+
for arg in (*node.args.posonlyargs, *node.args.args, *node.args.kwonlyargs):
|
|
960
|
+
param_shadowed.add(arg.arg)
|
|
961
|
+
op_names -= param_shadowed
|
|
962
|
+
|
|
963
|
+
def string_arg(node: ast.AST | None) -> str | None:
|
|
964
|
+
return node.value if isinstance(node, ast.Constant) and isinstance(node.value, str) else None
|
|
965
|
+
|
|
966
|
+
def section_of(function_name: str) -> str:
|
|
967
|
+
if function_name == "upgrade":
|
|
968
|
+
return "forward"
|
|
969
|
+
if function_name == "downgrade":
|
|
970
|
+
return "reverse"
|
|
971
|
+
return "unknown"
|
|
972
|
+
|
|
973
|
+
ops: list[dict] = []
|
|
974
|
+
for top in tree.body:
|
|
975
|
+
if not isinstance(top, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
976
|
+
continue
|
|
977
|
+
section = section_of(top.name)
|
|
978
|
+
|
|
979
|
+
for node in ast.walk(top):
|
|
980
|
+
if not isinstance(node, ast.Call):
|
|
981
|
+
continue
|
|
982
|
+
func = node.func
|
|
983
|
+
if not (isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name) and func.value.id in op_names):
|
|
984
|
+
continue
|
|
985
|
+
method = func.attr
|
|
986
|
+
args = node.args
|
|
987
|
+
kwargs = {kw.arg: kw.value for kw in node.keywords if kw.arg is not None}
|
|
988
|
+
|
|
989
|
+
if method == "drop_table":
|
|
990
|
+
target = string_arg(args[0]) if args else None
|
|
991
|
+
if target is not None:
|
|
992
|
+
ops.append({"kind": "DROP_TABLE", "target": target, "line": node.lineno, "section": section, "guarded": False})
|
|
993
|
+
elif method == "drop_column":
|
|
994
|
+
table = string_arg(args[0]) if len(args) > 0 else None
|
|
995
|
+
column = string_arg(args[1]) if len(args) > 1 else None
|
|
996
|
+
if table is not None and column is not None:
|
|
997
|
+
ops.append(
|
|
998
|
+
{"kind": "DROP_COLUMN", "target": f"{table}.{column}", "line": node.lineno, "section": section, "guarded": False}
|
|
999
|
+
)
|
|
1000
|
+
elif method == "rename_table":
|
|
1001
|
+
old = string_arg(args[0]) if args else None
|
|
1002
|
+
if old is not None:
|
|
1003
|
+
ops.append({"kind": "RENAME", "target": old, "line": node.lineno, "section": section, "guarded": False})
|
|
1004
|
+
elif method == "alter_column" and "new_column_name" in kwargs:
|
|
1005
|
+
table = string_arg(args[0]) if len(args) > 0 else None
|
|
1006
|
+
column = string_arg(args[1]) if len(args) > 1 else None
|
|
1007
|
+
if table is not None and column is not None:
|
|
1008
|
+
ops.append(
|
|
1009
|
+
{"kind": "RENAME", "target": f"{table}.{column}", "line": node.lineno, "section": section, "guarded": False}
|
|
1010
|
+
)
|
|
1011
|
+
|
|
1012
|
+
ops.extend(_django_migration_ops(tree, imports))
|
|
1013
|
+
return ops
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
#: Django's own destructive/rename operations — the four DEC-122 admits,
|
|
1017
|
+
#: named the way Django names them rather than the way Alembic does.
|
|
1018
|
+
#: `AlterField` is deliberately excluded: it changes a field's type or
|
|
1019
|
+
#: nullability, never its name, the same exclusion `alter_column` earns
|
|
1020
|
+
#: above when `new_column_name` is absent. `RemoveIndex`/`RemoveConstraint`
|
|
1021
|
+
#: are excluded for the same reason `drop_index`/`drop_constraint` are —
|
|
1022
|
+
#: both recoverable, DEC-122's own scope fence.
|
|
1023
|
+
_DJANGO_MIGRATION_CLASSES = ("DeleteModel", "RemoveField", "RenameModel", "RenameField")
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def _django_migration_op_callees(imports: list[dict]) -> dict[str, set[str]]:
|
|
1027
|
+
"""Every callee text, in THIS file, that names one of the four classes.
|
|
1028
|
+
|
|
1029
|
+
Two independent import shapes name the same class: `from django.db import
|
|
1030
|
+
migrations` then `migrations.DeleteModel(...)`, or `from
|
|
1031
|
+
django.db.migrations import DeleteModel` then `DeleteModel(...)` directly
|
|
1032
|
+
— same asymmetry `_migration_ops`'s Alembic half does not have to handle,
|
|
1033
|
+
because Alembic's `op` is always a namespace, never a class imported by
|
|
1034
|
+
name.
|
|
1035
|
+
"""
|
|
1036
|
+
migrations_locals = {
|
|
1037
|
+
r["local"] for r in imports if r["module"] == "django.db" and r["name"] == "migrations"
|
|
1038
|
+
}
|
|
1039
|
+
callees: dict[str, set[str]] = {name: set() for name in _DJANGO_MIGRATION_CLASSES}
|
|
1040
|
+
for local in migrations_locals:
|
|
1041
|
+
for name in callees:
|
|
1042
|
+
callees[name].add(f"{local}.{name}")
|
|
1043
|
+
for record in imports:
|
|
1044
|
+
if (
|
|
1045
|
+
record["module"] in ("django.db.migrations", "django.db.migrations.operations")
|
|
1046
|
+
and record["name"] in callees
|
|
1047
|
+
):
|
|
1048
|
+
callees[record["name"]].add(record["local"])
|
|
1049
|
+
return callees
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def _django_migration_ops(tree: ast.Module, imports: list[dict]) -> list[dict]:
|
|
1053
|
+
"""Django writes migrations as Python, not as `upgrade()`/`downgrade()`
|
|
1054
|
+
calls on a namespace — a `class Migration(migrations.Migration):
|
|
1055
|
+
operations = [DeleteModel(...), RemoveField(...), ...]`.
|
|
1056
|
+
|
|
1057
|
+
All `forward`: Django computes a migration's reverse from each
|
|
1058
|
+
operation's own `database_backwards()` rather than from a second
|
|
1059
|
+
written section, so there is no reverse text this reader could read
|
|
1060
|
+
even in principle — `section: "unknown"` would be the wrong signal
|
|
1061
|
+
(DEC-122: unknown is not a synonym for forward, but this is not an
|
|
1062
|
+
unattributable call either, it is a call this format never writes a
|
|
1063
|
+
reverse form of).
|
|
1064
|
+
"""
|
|
1065
|
+
|
|
1066
|
+
def string_arg(node: ast.AST | None) -> str | None:
|
|
1067
|
+
return node.value if isinstance(node, ast.Constant) and isinstance(node.value, str) else None
|
|
1068
|
+
|
|
1069
|
+
callees = _django_migration_op_callees(imports)
|
|
1070
|
+
if not any(callees.values()):
|
|
1071
|
+
return []
|
|
1072
|
+
|
|
1073
|
+
ops: list[dict] = []
|
|
1074
|
+
for node in ast.walk(tree):
|
|
1075
|
+
if not (
|
|
1076
|
+
isinstance(node, ast.Assign)
|
|
1077
|
+
and len(node.targets) == 1
|
|
1078
|
+
and isinstance(node.targets[0], ast.Name)
|
|
1079
|
+
and node.targets[0].id == "operations"
|
|
1080
|
+
and isinstance(node.value, ast.List)
|
|
1081
|
+
):
|
|
1082
|
+
continue
|
|
1083
|
+
|
|
1084
|
+
for element in node.value.elts:
|
|
1085
|
+
if not isinstance(element, ast.Call):
|
|
1086
|
+
continue
|
|
1087
|
+
callee_text = _annotation(element.func)
|
|
1088
|
+
if callee_text is None:
|
|
1089
|
+
continue
|
|
1090
|
+
kwargs = {kw.arg: kw.value for kw in element.keywords if kw.arg is not None}
|
|
1091
|
+
|
|
1092
|
+
def positional_or_kw(index: int, name: str) -> str | None:
|
|
1093
|
+
if index < len(element.args):
|
|
1094
|
+
return string_arg(element.args[index])
|
|
1095
|
+
return string_arg(kwargs.get(name))
|
|
1096
|
+
|
|
1097
|
+
if callee_text in callees["DeleteModel"]:
|
|
1098
|
+
target = positional_or_kw(0, "name")
|
|
1099
|
+
if target is not None:
|
|
1100
|
+
ops.append({"kind": "DROP_TABLE", "target": target, "line": element.lineno, "section": "forward", "guarded": False})
|
|
1101
|
+
elif callee_text in callees["RemoveField"]:
|
|
1102
|
+
model = positional_or_kw(0, "model_name")
|
|
1103
|
+
field = positional_or_kw(1, "name")
|
|
1104
|
+
if model is not None and field is not None:
|
|
1105
|
+
ops.append(
|
|
1106
|
+
{"kind": "DROP_COLUMN", "target": f"{model}.{field}", "line": element.lineno, "section": "forward", "guarded": False}
|
|
1107
|
+
)
|
|
1108
|
+
elif callee_text in callees["RenameModel"]:
|
|
1109
|
+
old = positional_or_kw(0, "old_name")
|
|
1110
|
+
if old is not None:
|
|
1111
|
+
ops.append({"kind": "RENAME", "target": old, "line": element.lineno, "section": "forward", "guarded": False})
|
|
1112
|
+
elif callee_text in callees["RenameField"]:
|
|
1113
|
+
model = positional_or_kw(0, "model_name")
|
|
1114
|
+
old = positional_or_kw(1, "old_name")
|
|
1115
|
+
if model is not None and old is not None:
|
|
1116
|
+
ops.append(
|
|
1117
|
+
{"kind": "RENAME", "target": f"{model}.{old}", "line": element.lineno, "section": "forward", "guarded": False}
|
|
1118
|
+
)
|
|
1119
|
+
|
|
1120
|
+
return ops
|
|
1121
|
+
|
|
1122
|
+
|
|
1123
|
+
def _classify_alias(candidate: dict, typing_names: set[str], bound: set[str]) -> str | None:
|
|
1124
|
+
"""DEC-069's closed admission list, in order. Returns the rule, or None.
|
|
1125
|
+
|
|
1126
|
+
`bound` is every name resolvable in this file, used only by rule 6 to check
|
|
1127
|
+
that a `|` union's operands are types rather than values.
|
|
1128
|
+
"""
|
|
1129
|
+
value = candidate["value"]
|
|
1130
|
+
if candidate["pep695"]:
|
|
1131
|
+
return "2-pep695"
|
|
1132
|
+
annotation = candidate["annotation"]
|
|
1133
|
+
if annotation is not None and "TypeAlias" in re.findall(r"[A-Za-z_][A-Za-z0-9_]*", annotation):
|
|
1134
|
+
return "1-explicit"
|
|
1135
|
+
if value is None:
|
|
1136
|
+
return None
|
|
1137
|
+
|
|
1138
|
+
if isinstance(value, ast.Call) and isinstance(value.func, ast.Name):
|
|
1139
|
+
if value.func.id in DECLARING_CALLS:
|
|
1140
|
+
return f"3-{value.func.id}"
|
|
1141
|
+
|
|
1142
|
+
if isinstance(value, ast.Subscript) and isinstance(value.value, ast.Name):
|
|
1143
|
+
head = value.value.id
|
|
1144
|
+
# The import check is the precision control. Matching the bare name
|
|
1145
|
+
# would classify a local class called `Literal` as a type constructor.
|
|
1146
|
+
if head in TYPING_CONSTRUCTORS and head in typing_names:
|
|
1147
|
+
return f"4-{head}"
|
|
1148
|
+
if head in BUILTIN_CONTAINERS:
|
|
1149
|
+
return f"5-{head}"
|
|
1150
|
+
return None
|
|
1151
|
+
|
|
1152
|
+
if isinstance(value, ast.BinOp) and isinstance(value.op, ast.BitOr):
|
|
1153
|
+
operands: list[ast.AST] = []
|
|
1154
|
+
stack = [value]
|
|
1155
|
+
while stack:
|
|
1156
|
+
current = stack.pop()
|
|
1157
|
+
if isinstance(current, ast.BinOp) and isinstance(current.op, ast.BitOr):
|
|
1158
|
+
stack.extend([current.left, current.right])
|
|
1159
|
+
else:
|
|
1160
|
+
operands.append(current)
|
|
1161
|
+
for operand in operands:
|
|
1162
|
+
if isinstance(operand, ast.Constant) and operand.value is None:
|
|
1163
|
+
continue
|
|
1164
|
+
if isinstance(operand, ast.Subscript):
|
|
1165
|
+
head = _root_name(operand.value)
|
|
1166
|
+
if head in BUILTIN_CONTAINERS or (head in TYPING_CONSTRUCTORS and head in typing_names):
|
|
1167
|
+
continue
|
|
1168
|
+
return None
|
|
1169
|
+
name = _root_name(operand)
|
|
1170
|
+
if name is None:
|
|
1171
|
+
return None
|
|
1172
|
+
if name in BUILTIN_TYPES or name in typing_names or name in bound:
|
|
1173
|
+
continue
|
|
1174
|
+
return None
|
|
1175
|
+
return "6-union"
|
|
1176
|
+
|
|
1177
|
+
# `X = SomeName` is deliberately rejected. It is often an alias and is
|
|
1178
|
+
# indistinguishable from a constant holding a class object, and under
|
|
1179
|
+
# precision over recall an indistinguishable case is omitted. TypeScript's
|
|
1180
|
+
# `type Chain = Pair` has no such ambiguity because the keyword marks it —
|
|
1181
|
+
# the divergence is between the two languages, not between the adapters.
|
|
1182
|
+
return None
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
def collect(path: str, source: str, deep_call_modules: frozenset[str] = frozenset()) -> dict:
|
|
1186
|
+
tree = ast.parse(source, filename=path)
|
|
1187
|
+
collector = Collector(path)
|
|
1188
|
+
# Imports are only complete after the walk, and this decision has to be made
|
|
1189
|
+
# before it — so the import statements are read first, in their own pass.
|
|
1190
|
+
if deep_call_modules:
|
|
1191
|
+
for node in ast.walk(tree):
|
|
1192
|
+
if isinstance(node, ast.Import):
|
|
1193
|
+
names = [alias.name for alias in node.names]
|
|
1194
|
+
elif isinstance(node, ast.ImportFrom):
|
|
1195
|
+
names = [node.module or ""]
|
|
1196
|
+
else:
|
|
1197
|
+
continue
|
|
1198
|
+
if any(name.split(".")[0] in deep_call_modules for name in names):
|
|
1199
|
+
collector.deep_calls = True
|
|
1200
|
+
break
|
|
1201
|
+
for statement in tree.body:
|
|
1202
|
+
collector.visit(statement)
|
|
1203
|
+
|
|
1204
|
+
# --- DEC-069: type aliases, classified now that imports are complete ------
|
|
1205
|
+
typing_names = {
|
|
1206
|
+
record["local"]
|
|
1207
|
+
for record in collector.imports
|
|
1208
|
+
if record["module"] in TYPING_MODULES and record["name"] is not None
|
|
1209
|
+
}
|
|
1210
|
+
declared_names = {
|
|
1211
|
+
declaration["path"][0]
|
|
1212
|
+
for declaration in collector.declarations
|
|
1213
|
+
if len(declaration["path"]) == 1
|
|
1214
|
+
}
|
|
1215
|
+
bound = declared_names | {record["local"] for record in collector.imports}
|
|
1216
|
+
aliases: list[dict] = []
|
|
1217
|
+
claimed: set[str] = set(declared_names)
|
|
1218
|
+
for candidate in collector.alias_candidates:
|
|
1219
|
+
# A `def`/`class` of the same name owns it. Rebinding a declared name at
|
|
1220
|
+
# module level is legal and rare, and the declaration is the better node.
|
|
1221
|
+
if candidate["name"] in claimed:
|
|
1222
|
+
continue
|
|
1223
|
+
rule = _classify_alias(candidate, typing_names, bound)
|
|
1224
|
+
if rule is None:
|
|
1225
|
+
continue
|
|
1226
|
+
claimed.add(candidate["name"])
|
|
1227
|
+
aliases.append(
|
|
1228
|
+
{
|
|
1229
|
+
"name": candidate["name"],
|
|
1230
|
+
"range": candidate["range"],
|
|
1231
|
+
"rule": rule,
|
|
1232
|
+
"constituents": _constituents(candidate["value"]),
|
|
1233
|
+
}
|
|
1234
|
+
)
|
|
1235
|
+
|
|
1236
|
+
env_reads, env_dynamic_reads = _env_reads(tree, collector.imports)
|
|
1237
|
+
migration_ops = _migration_ops(tree, collector.imports)
|
|
1238
|
+
|
|
1239
|
+
return {
|
|
1240
|
+
"declarations": collector.declarations,
|
|
1241
|
+
"imports": collector.imports,
|
|
1242
|
+
"references": collector.references,
|
|
1243
|
+
"instantiations": collector.instantiations,
|
|
1244
|
+
"valueRefs": collector.value_refs,
|
|
1245
|
+
"aliases": aliases,
|
|
1246
|
+
"calls": collector.calls,
|
|
1247
|
+
"constructions": collector.constructions,
|
|
1248
|
+
"constants": collector.constants,
|
|
1249
|
+
"localStrings": collector.local_strings,
|
|
1250
|
+
"envReads": env_reads,
|
|
1251
|
+
"envDynamicReads": env_dynamic_reads,
|
|
1252
|
+
"migrationOps": migration_ops,
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
def main() -> int:
|
|
1257
|
+
request = json.load(sys.stdin)
|
|
1258
|
+
root = request["root"]
|
|
1259
|
+
files: list[str] = request["files"]
|
|
1260
|
+
# Which imports make a file worth walking to full depth. Supplied by the
|
|
1261
|
+
# Node side rather than decided here: *that* a package is interesting is
|
|
1262
|
+
# framework knowledge and belongs above this file; *how* to record a call is
|
|
1263
|
+
# syntax and belongs in it.
|
|
1264
|
+
deep_call_modules = frozenset(request.get("deepCallModules") or ())
|
|
1265
|
+
|
|
1266
|
+
parsed: dict[str, dict] = {}
|
|
1267
|
+
errors: dict[str, str] = {}
|
|
1268
|
+
|
|
1269
|
+
for relative in files:
|
|
1270
|
+
absolute = f"{root}/{relative}"
|
|
1271
|
+
try:
|
|
1272
|
+
with open(absolute, encoding="utf8") as handle:
|
|
1273
|
+
source = handle.read()
|
|
1274
|
+
except OSError as error:
|
|
1275
|
+
errors[relative] = f"unreadable: {error.strerror or type(error).__name__}"
|
|
1276
|
+
continue
|
|
1277
|
+
try:
|
|
1278
|
+
parsed[relative] = collect(relative, source, deep_call_modules)
|
|
1279
|
+
except SyntaxError as error:
|
|
1280
|
+
# Disclosed, never silent. This interpreter's grammar is the one in
|
|
1281
|
+
# force, so a repository using newer syntax lands here too — which
|
|
1282
|
+
# is a real limit of DEC-062 and belongs in the report.
|
|
1283
|
+
errors[relative] = f"syntax error at line {error.lineno}: {error.msg}"
|
|
1284
|
+
except (ValueError, RecursionError) as error:
|
|
1285
|
+
errors[relative] = f"{type(error).__name__}: {error}"
|
|
1286
|
+
|
|
1287
|
+
# **Line-delimited, not one object.** A single `json.dump` of a large
|
|
1288
|
+
# repository produced 284.4 MB for home-assistant-core, which overflowed the
|
|
1289
|
+
# reader's buffer; the reader then reported a successful R0 run with an empty
|
|
1290
|
+
# graph. Streaming a line per file removes the wall rather than moving it:
|
|
1291
|
+
# neither side ever holds the whole payload as one string, and a truncated
|
|
1292
|
+
# stream is detectable because the trailer is missing.
|
|
1293
|
+
out = sys.stdout
|
|
1294
|
+
out.write(
|
|
1295
|
+
json.dumps(
|
|
1296
|
+
{
|
|
1297
|
+
"kind": "header",
|
|
1298
|
+
"pythonVersion": ".".join(str(part) for part in sys.version_info[:3]),
|
|
1299
|
+
"fileCount": len(files),
|
|
1300
|
+
}
|
|
1301
|
+
)
|
|
1302
|
+
)
|
|
1303
|
+
out.write("\n")
|
|
1304
|
+
for relative, record in parsed.items():
|
|
1305
|
+
out.write(json.dumps({"kind": "file", "file": relative, "parsed": record}))
|
|
1306
|
+
out.write("\n")
|
|
1307
|
+
for relative, message in errors.items():
|
|
1308
|
+
out.write(json.dumps({"kind": "error", "file": relative, "error": message}))
|
|
1309
|
+
out.write("\n")
|
|
1310
|
+
# The trailer is the completeness proof. Without it the reader cannot tell a
|
|
1311
|
+
# truncated stream from a short one, which is exactly the ambiguity that let
|
|
1312
|
+
# an empty result be reported as a success.
|
|
1313
|
+
out.write(json.dumps({"kind": "trailer", "files": len(parsed), "errors": len(errors)}))
|
|
1314
|
+
out.write("\n")
|
|
1315
|
+
return 0
|
|
1316
|
+
|
|
1317
|
+
|
|
1318
|
+
if __name__ == "__main__":
|
|
1319
|
+
sys.exit(main())
|