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