coretrace-python-analyzer 0.1.0__py3-none-any.whl

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.
Files changed (126) hide show
  1. coretrace_python/__init__.py +4 -0
  2. coretrace_python/__main__.py +4 -0
  3. coretrace_python/abstract/__init__.py +45 -0
  4. coretrace_python/abstract/constants.py +226 -0
  5. coretrace_python/abstract/heap.py +252 -0
  6. coretrace_python/abstract/ranges.py +285 -0
  7. coretrace_python/abstract/values.py +56 -0
  8. coretrace_python/analysis/__init__.py +31 -0
  9. coretrace_python/analysis/manager.py +165 -0
  10. coretrace_python/analysis/provider.py +73 -0
  11. coretrace_python/bundled/dependency/dependency_policy/dependency_policy.py +46 -0
  12. coretrace_python/bundled/dependency/dependency_policy/plugin.toml +9 -0
  13. coretrace_python/bundled/dependency/reachable_vulnerability/plugin.toml +9 -0
  14. coretrace_python/bundled/dependency/reachable_vulnerability/reachable_vulnerability.py +56 -0
  15. coretrace_python/bundled/dependency/sample_advisories/plugin.toml +9 -0
  16. coretrace_python/bundled/dependency/sample_advisories/sample_advisories.py +109 -0
  17. coretrace_python/bundled/dependency/vulnerable_dependency/plugin.toml +9 -0
  18. coretrace_python/bundled/dependency/vulnerable_dependency/vulnerable_dependency.py +43 -0
  19. coretrace_python/bundled/models/cli/cli_models.py +35 -0
  20. coretrace_python/bundled/models/cli/plugin.toml +9 -0
  21. coretrace_python/bundled/models/credentials/credential_models.py +43 -0
  22. coretrace_python/bundled/models/credentials/plugin.toml +9 -0
  23. coretrace_python/bundled/models/django/django_models.py +123 -0
  24. coretrace_python/bundled/models/django/plugin.toml +9 -0
  25. coretrace_python/bundled/models/fastapi/fastapi_models.py +29 -0
  26. coretrace_python/bundled/models/fastapi/plugin.toml +9 -0
  27. coretrace_python/bundled/models/flask/flask_models.py +53 -0
  28. coretrace_python/bundled/models/flask/plugin.toml +9 -0
  29. coretrace_python/bundled/models/http_clients/http_client_models.py +43 -0
  30. coretrace_python/bundled/models/http_clients/plugin.toml +9 -0
  31. coretrace_python/bundled/models/python_stdlib/plugin.toml +9 -0
  32. coretrace_python/bundled/models/python_stdlib/python_stdlib.py +68 -0
  33. coretrace_python/bundled/models/sqlalchemy/plugin.toml +9 -0
  34. coretrace_python/bundled/models/sqlalchemy/sqlalchemy_models.py +47 -0
  35. coretrace_python/bundled/secrets/config_secrets/config_secrets.py +38 -0
  36. coretrace_python/bundled/secrets/config_secrets/plugin.toml +9 -0
  37. coretrace_python/bundled/secrets/hardcoded_secrets/hardcoded_secrets.py +19 -0
  38. coretrace_python/bundled/secrets/hardcoded_secrets/plugin.toml +9 -0
  39. coretrace_python/bundled/security/command_injection/command_injection.py +17 -0
  40. coretrace_python/bundled/security/command_injection/plugin.toml +9 -0
  41. coretrace_python/bundled/security/insecure_deserialization/insecure_deserialization.py +17 -0
  42. coretrace_python/bundled/security/insecure_deserialization/plugin.toml +9 -0
  43. coretrace_python/bundled/security/open_redirect/open_redirect.py +17 -0
  44. coretrace_python/bundled/security/open_redirect/plugin.toml +9 -0
  45. coretrace_python/bundled/security/path_traversal/path_traversal.py +17 -0
  46. coretrace_python/bundled/security/path_traversal/plugin.toml +9 -0
  47. coretrace_python/bundled/security/plaintext_credentials/plaintext_credentials.py +21 -0
  48. coretrace_python/bundled/security/plaintext_credentials/plugin.toml +9 -0
  49. coretrace_python/bundled/security/sql_injection/plugin.toml +9 -0
  50. coretrace_python/bundled/security/sql_injection/sql_injection.py +17 -0
  51. coretrace_python/bundled/security/ssrf/plugin.toml +9 -0
  52. coretrace_python/bundled/security/ssrf/ssrf.py +17 -0
  53. coretrace_python/bundled/security/xss/plugin.toml +9 -0
  54. coretrace_python/bundled/security/xss/xss.py +17 -0
  55. coretrace_python/bundled/syntax/dangerous_eval/dangerous_eval.py +19 -0
  56. coretrace_python/bundled/syntax/dangerous_eval/plugin.toml +9 -0
  57. coretrace_python/bundled/syntax/flask_debug/flask_debug.py +63 -0
  58. coretrace_python/bundled/syntax/flask_debug/plugin.toml +9 -0
  59. coretrace_python/bundled/syntax/missing_timeout/missing_timeout.py +51 -0
  60. coretrace_python/bundled/syntax/missing_timeout/plugin.toml +9 -0
  61. coretrace_python/bundled/syntax/weak_crypto/plugin.toml +9 -0
  62. coretrace_python/bundled/syntax/weak_crypto/weak_crypto.py +19 -0
  63. coretrace_python/cache.py +310 -0
  64. coretrace_python/cfg/__init__.py +46 -0
  65. coretrace_python/cfg/builder.py +589 -0
  66. coretrace_python/cfg/dominance.py +183 -0
  67. coretrace_python/cfg/model.py +166 -0
  68. coretrace_python/cli.py +216 -0
  69. coretrace_python/dataflow/__init__.py +29 -0
  70. coretrace_python/dataflow/lattice.py +78 -0
  71. coretrace_python/dataflow/solver.py +96 -0
  72. coretrace_python/dependency/__init__.py +44 -0
  73. coretrace_python/dependency/advisories.py +168 -0
  74. coretrace_python/dependency/correlation.py +87 -0
  75. coretrace_python/dependency/graph.py +274 -0
  76. coretrace_python/dependency/policy.py +65 -0
  77. coretrace_python/dependency/sbom.py +65 -0
  78. coretrace_python/engine.py +688 -0
  79. coretrace_python/findings/__init__.py +13 -0
  80. coretrace_python/findings/coverage.py +45 -0
  81. coretrace_python/findings/model.py +49 -0
  82. coretrace_python/findings/refutation.py +427 -0
  83. coretrace_python/frontend/__init__.py +18 -0
  84. coretrace_python/frontend/ast_adapter.py +447 -0
  85. coretrace_python/frontend/parser.py +23 -0
  86. coretrace_python/hir/__init__.py +5 -0
  87. coretrace_python/hir/nodes.py +584 -0
  88. coretrace_python/hir/visitors.py +36 -0
  89. coretrace_python/interprocedural/__init__.py +49 -0
  90. coretrace_python/interprocedural/callgraph.py +291 -0
  91. coretrace_python/interprocedural/modulegraph.py +218 -0
  92. coretrace_python/interprocedural/summaries.py +463 -0
  93. coretrace_python/ir/__init__.py +5 -0
  94. coretrace_python/ir/defuse.py +74 -0
  95. coretrace_python/ir/lowering.py +575 -0
  96. coretrace_python/ir/model.py +481 -0
  97. coretrace_python/ir/printer.py +201 -0
  98. coretrace_python/ir/ssa.py +277 -0
  99. coretrace_python/plugins/__init__.py +58 -0
  100. coretrace_python/plugins/api.py +153 -0
  101. coretrace_python/plugins/detectors.py +114 -0
  102. coretrace_python/plugins/loader.py +84 -0
  103. coretrace_python/plugins/manifest.py +112 -0
  104. coretrace_python/plugins/registry.py +34 -0
  105. coretrace_python/plugins/secrets.py +330 -0
  106. coretrace_python/reporters/__init__.py +22 -0
  107. coretrace_python/reporters/json_format.py +48 -0
  108. coretrace_python/reporters/report.py +23 -0
  109. coretrace_python/reporters/sarif.py +70 -0
  110. coretrace_python/reporters/text.py +24 -0
  111. coretrace_python/semantic/__init__.py +9 -0
  112. coretrace_python/semantic/identity.py +39 -0
  113. coretrace_python/semantic/imports.py +131 -0
  114. coretrace_python/semantic/scopes.py +473 -0
  115. coretrace_python/semantic/symbols.py +87 -0
  116. coretrace_python/source/__init__.py +13 -0
  117. coretrace_python/source/manager.py +81 -0
  118. coretrace_python/source/model.py +57 -0
  119. coretrace_python/taint/__init__.py +55 -0
  120. coretrace_python/taint/engine.py +800 -0
  121. coretrace_python/taint/models.py +317 -0
  122. coretrace_python/taint/routes.py +99 -0
  123. coretrace_python_analyzer-0.1.0.dist-info/METADATA +74 -0
  124. coretrace_python_analyzer-0.1.0.dist-info/RECORD +126 -0
  125. coretrace_python_analyzer-0.1.0.dist-info/WHEEL +4 -0
  126. coretrace_python_analyzer-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,589 @@
1
+ """Build a control-flow graph from a PyHIR function."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import fields, is_dataclass, replace
7
+ from typing import Any, ClassVar
8
+
9
+ from coretrace_python.analysis import AnalysisContext, FunctionAnalysis
10
+ from coretrace_python.cfg.model import (
11
+ CFG,
12
+ BasicBlock,
13
+ BlockId,
14
+ Branch,
15
+ CFGError,
16
+ ForEach,
17
+ Jump,
18
+ Raise,
19
+ Return,
20
+ Terminator,
21
+ )
22
+ from coretrace_python.hir import nodes
23
+ from coretrace_python.source import SourceSpan
24
+
25
+
26
+ class _Open:
27
+ """A block that is still collecting statements and has no terminator yet."""
28
+
29
+ def __init__(self, block_id: BlockId) -> None:
30
+ self.id = block_id
31
+ self.statements: list[nodes.Statement] = []
32
+
33
+
34
+ class _Builder:
35
+ def __init__(
36
+ self, function: nodes.Function, match_args: Mapping[str, tuple[str, ...]] | None = None
37
+ ) -> None:
38
+ self.match_args = dict(match_args or {})
39
+ self.function = function
40
+ self.blocks: dict[BlockId, BasicBlock] = {}
41
+ self.counters: dict[str, int] = {}
42
+ self.loops: list[tuple[BlockId, BlockId]] = []
43
+ self.handlers: list[tuple[BlockId, ...]] = []
44
+ self.synthetic: set[str] = set()
45
+
46
+ def build(self) -> CFG:
47
+ entry = BlockId("entry")
48
+ end = self.sequence(self.function.body, _Open(entry), None, self.function.span)
49
+ if end is not None:
50
+ self.finish(end, Return(None, self.function.span))
51
+ return CFG(entry, self.blocks, frozenset(self.synthetic))
52
+
53
+ # ------------------------------------------------------------------ expression-level control flow
54
+
55
+ def hidden(self, kind: str, span: SourceSpan) -> nodes.Name:
56
+ name = f"_coretrace_{kind}_{span.start_line}_{span.start_column}_{len(self.synthetic)}"
57
+ self.synthetic.add(name)
58
+ return nodes.Name(name, span)
59
+
60
+ def desugared(self, node: nodes.Statement, block: _Open) -> tuple[nodes.Statement, _Open]:
61
+ """``node`` with its conditional expressions and comprehensions replaced by reads
62
+ of synthetic locals, after laying out the statements that compute them."""
63
+
64
+ pending: list[nodes.Statement] = []
65
+ if isinstance(node, nodes.While):
66
+ if not _has_control_flow(node.condition):
67
+ return node, block
68
+ # ``while <control flow>:`` recomputes its condition every iteration: it
69
+ # becomes ``while True`` with the condition laid out at the top of the body
70
+ # and a ``break``; the ``else`` clause runs before that ``break``.
71
+ inner: list[nodes.Statement] = []
72
+ condition = self.hoist(node.condition, inner)
73
+ stop = nodes.If(
74
+ nodes.UnaryOp("not", condition, node.span),
75
+ (*node.orelse, nodes.Break(node.span)),
76
+ (),
77
+ node.span,
78
+ )
79
+ return (
80
+ replace(
81
+ node,
82
+ condition=nodes.Constant(True, node.span),
83
+ body=(*inner, stop, *node.body),
84
+ orelse=(),
85
+ ),
86
+ block,
87
+ )
88
+ if isinstance(node, nodes.Assign | nodes.AugAssign):
89
+ node = replace(node, target=self.hoist(node.target, pending), value=self.hoist(node.value, pending))
90
+ elif isinstance(node, nodes.ExpressionStatement):
91
+ node = replace(node, expression=self.hoist(node.expression, pending))
92
+ elif isinstance(node, nodes.Return | nodes.Raise) and node.__class__ is nodes.Return:
93
+ if node.value is not None:
94
+ node = replace(node, value=self.hoist(node.value, pending))
95
+ elif isinstance(node, nodes.Raise):
96
+ exception = self.hoist(node.exception, pending) if node.exception is not None else None
97
+ cause = self.hoist(node.cause, pending) if node.cause is not None else None
98
+ node = replace(node, exception=exception, cause=cause)
99
+ elif isinstance(node, nodes.Assert):
100
+ message = self.hoist(node.message, pending) if node.message is not None else None
101
+ node = replace(node, test=self.hoist(node.test, pending), message=message)
102
+ elif isinstance(node, nodes.If):
103
+ node = replace(node, condition=self.hoist(node.condition, pending))
104
+ elif isinstance(node, nodes.For):
105
+ node = replace(node, iterable=self.hoist(node.iterable, pending))
106
+ elif isinstance(node, nodes.With):
107
+ items = tuple(replace(item, context=self.hoist(item.context, pending)) for item in node.items)
108
+ node = replace(node, items=items)
109
+ if not pending:
110
+ return node, block
111
+ laid_out = self.sequence(tuple(pending), block, None, node.span)
112
+ assert laid_out is not None, "hoisted statements always fall through"
113
+ return node, laid_out
114
+
115
+ def hoist(self, node: Any, pending: list[nodes.Statement]) -> Any:
116
+ """Rewrite one expression tree, appending the statements it needs to ``pending``."""
117
+
118
+ if isinstance(node, nodes.Conditional):
119
+ test = self.hoist(node.test, pending)
120
+ result = self.hidden("cond", node.span)
121
+ pending.append(
122
+ nodes.If(
123
+ test,
124
+ (nodes.Assign(result, node.body, node.span),),
125
+ (nodes.Assign(result, node.orelse, node.span),),
126
+ node.span,
127
+ )
128
+ )
129
+ return result
130
+ if isinstance(node, nodes.Comprehension):
131
+ return self.comprehension(node, pending)
132
+ if isinstance(node, nodes.Lambda):
133
+ defaults = tuple(
134
+ replace(p, default=self.hoist(p.default, pending)) if p.default is not None else p
135
+ for p in node.parameters
136
+ )
137
+ return replace(node, parameters=defaults)
138
+ if isinstance(node, tuple):
139
+ return tuple(self.hoist(item, pending) for item in node)
140
+ if is_dataclass(node) and not isinstance(node, type):
141
+ changes = {
142
+ f.name: self.hoist(getattr(node, f.name), pending)
143
+ for f in fields(node)
144
+ if f.name != "span" and _may_hold_expressions(getattr(node, f.name))
145
+ }
146
+ return replace(node, **changes) if changes else node
147
+ return node
148
+
149
+ def comprehension(self, node: nodes.Comprehension, pending: list[nodes.Statement]) -> nodes.Name:
150
+ """Lay a comprehension out as loops filling a synthetic collection."""
151
+
152
+ span = node.span
153
+ result = self.hidden("comp", span)
154
+ renames: dict[str, str] = {}
155
+ for generator in node.generators:
156
+ for name in _bound_names(generator.target):
157
+ renames[name] = self.hidden(f"var_{name}", generator.target.span).identifier
158
+ if node.kind == "set":
159
+ initial: nodes.Expression = nodes.Call(nodes.Name("set", span), (), (), span)
160
+ elif node.kind == "dict":
161
+ initial = nodes.Dict((), span)
162
+ else:
163
+ initial = nodes.List((), span)
164
+ pending.append(nodes.Assign(result, initial, span))
165
+
166
+ element = _rename(node.element, renames)
167
+ if node.kind == "dict":
168
+ assert node.key is not None
169
+ innermost: nodes.Statement = nodes.Assign(
170
+ nodes.Subscript(result, _rename(node.key, renames), span), element, span
171
+ )
172
+ else:
173
+ method = "add" if node.kind == "set" else "append"
174
+ call = nodes.Call(nodes.Attribute(result, method, span), (element,), (), span)
175
+ innermost = nodes.ExpressionStatement(call, span)
176
+
177
+ body: tuple[nodes.Statement, ...] = (innermost,)
178
+ for index in range(len(node.generators) - 1, -1, -1):
179
+ generator = node.generators[index]
180
+ for condition in reversed(generator.conditions):
181
+ body = (nodes.If(_rename(condition, renames), body, (), condition.span),)
182
+ iterable = _rename(generator.iterable, renames) if index else self.hoist(generator.iterable, pending)
183
+ if isinstance(generator.target, nodes.Name):
184
+ target = nodes.Name(renames[generator.target.identifier], generator.target.span)
185
+ else:
186
+ target = self.hidden("item", generator.target.span)
187
+ body = (nodes.Assign(_rename_target(generator.target, renames), target, generator.target.span), *body)
188
+ body = (nodes.For(target, iterable, body, False, generator.span),)
189
+ pending.extend(body)
190
+ return result
191
+
192
+ # ------------------------------------------------------------------ blocks
193
+
194
+ def new_id(self, kind: str) -> BlockId:
195
+ self.counters[kind] = self.counters.get(kind, 0) + 1
196
+ return BlockId(f"{kind}_{self.counters[kind]}")
197
+
198
+ def finish(self, block: _Open, terminator: Terminator) -> None:
199
+ self.blocks[block.id] = BasicBlock(
200
+ block.id, tuple(block.statements), terminator, self.exception_targets()
201
+ )
202
+
203
+ def header(self, block_id: BlockId, terminator: Terminator) -> None:
204
+ self.blocks[block_id] = BasicBlock(block_id, (), terminator, self.exception_targets())
205
+
206
+ def exception_targets(self) -> tuple[BlockId, ...]:
207
+ return self.handlers[-1] if self.handlers else ()
208
+
209
+ # ------------------------------------------------------------------ statements
210
+
211
+ def sequence(
212
+ self,
213
+ statements: tuple[nodes.Statement, ...],
214
+ block: _Open,
215
+ continuation: BlockId | None,
216
+ join_span: SourceSpan,
217
+ ) -> _Open | None:
218
+ """Lay ``statements`` out from ``block``.
219
+
220
+ Returns the block left open at the end, or ``None`` when control left the
221
+ sequence. With a ``continuation``, an open end jumps there instead.
222
+ """
223
+
224
+ current: _Open | None = block
225
+ last_index = len(statements) - 1
226
+ for index, statement in enumerate(statements):
227
+ assert current is not None
228
+ is_last = index == last_index
229
+ current = self.statement(statement, current, continuation if is_last else None)
230
+ if current is None and not is_last:
231
+ current = _Open(self.new_id("dead"))
232
+ if current is not None and continuation is not None:
233
+ self.finish(current, Jump(continuation, join_span))
234
+ return None
235
+ return current
236
+
237
+ def statement(
238
+ self, node: nodes.Statement, block: _Open, continuation: BlockId | None
239
+ ) -> _Open | None:
240
+ node, block = self.desugared(node, block)
241
+ if isinstance(node, nodes.Return):
242
+ self.finish(block, Return(node.value, node.span))
243
+ return None
244
+ if isinstance(node, nodes.Raise):
245
+ self.finish(block, Raise(node.exception, node.span, node.cause))
246
+ return None
247
+ if isinstance(node, nodes.Break):
248
+ self.finish(block, Jump(self.loop("break", node.span)[1], node.span))
249
+ return None
250
+ if isinstance(node, nodes.Continue):
251
+ self.finish(block, Jump(self.loop("continue", node.span)[0], node.span))
252
+ return None
253
+ if isinstance(node, nodes.If):
254
+ return self.conditional(node, block, continuation)
255
+ if isinstance(node, nodes.While | nodes.For):
256
+ return self.loop_statement(node, block, continuation)
257
+ if isinstance(node, nodes.Match):
258
+ return self.match_statement(node, block, continuation)
259
+ if isinstance(node, nodes.With):
260
+ return self.with_statement(node, block)
261
+ if isinstance(node, nodes.Try):
262
+ return self.try_statement(node, block, continuation)
263
+ block.statements.append(node)
264
+ return block
265
+
266
+ def try_statement(
267
+ self, node: nodes.Try, block: _Open, continuation: BlockId | None
268
+ ) -> _Open | None:
269
+ """Body blocks carry exception edges to every handler; handlers, ``else`` and
270
+ ``finally`` join on the normal path (exceptional exits are approximated)."""
271
+
272
+ body_id = self.new_id("try")
273
+ handler_ids = tuple(self.new_id("handler") for _ in node.handlers)
274
+ else_id = self.new_id("else") if node.orelse else None
275
+ final_id = self.new_id("finally") if node.finalbody else None
276
+ after_id = continuation if continuation is not None and final_id is None else None
277
+ if after_id is None:
278
+ after_id = self.new_id("after") if final_id is None or continuation is None else None
279
+ join = final_id if final_id is not None else after_id
280
+ assert join is not None
281
+
282
+ self.finish(block, Jump(body_id, node.span))
283
+ self.handlers.append(handler_ids)
284
+ try:
285
+ # The whole body, including the block that leaves it, may raise into a handler.
286
+ end = self.sequence(node.body, _Open(body_id), None, node.span)
287
+ if end is not None:
288
+ self.finish(end, Jump(else_id if else_id is not None else join, node.span))
289
+ finally:
290
+ self.handlers.pop()
291
+ if end is not None and else_id is not None:
292
+ self.sequence(node.orelse, _Open(else_id), join, node.span)
293
+ for handler, handler_id in zip(node.handlers, handler_ids, strict=True):
294
+ opened = _Open(handler_id)
295
+ opened.statements.append(nodes.EnterHandler(handler, handler.span))
296
+ self.sequence(handler.body, opened, join, node.span)
297
+ if final_id is not None:
298
+ target = continuation if continuation is not None else after_id
299
+ assert target is not None
300
+ self.sequence(node.finalbody, _Open(final_id), target, node.span)
301
+ return None if continuation is not None else _Open(target)
302
+ return None if continuation is not None else _Open(after_id) # type: ignore[arg-type]
303
+
304
+ def with_statement(self, node: nodes.With, block: _Open) -> _Open | None:
305
+ """Lay the body out inline; an early exit skips the ``ExitWith`` statements."""
306
+
307
+ for item in node.items:
308
+ block.statements.append(nodes.EnterWith(item, node.span))
309
+ current = self.sequence(node.body, block, None, node.span)
310
+ if current is None:
311
+ return None
312
+ for item in reversed(node.items):
313
+ current.statements.append(nodes.ExitWith(item, node.span))
314
+ return current
315
+
316
+ def conditional(
317
+ self, node: nodes.If, block: _Open, continuation: BlockId | None
318
+ ) -> _Open | None:
319
+ merge = continuation if continuation is not None else self.new_id("merge")
320
+ then_id = self.new_id("then")
321
+ else_id = self.new_id("else") if node.orelse else merge
322
+ self.finish(block, Branch(node.condition, then_id, else_id, node.span))
323
+ self.sequence(node.body, _Open(then_id), merge, node.span)
324
+ if node.orelse:
325
+ self.sequence(node.orelse, _Open(else_id), merge, node.span)
326
+ return None if continuation is not None else _Open(merge)
327
+
328
+ def loop_statement(
329
+ self, node: nodes.While | nodes.For, block: _Open, continuation: BlockId | None
330
+ ) -> _Open | None:
331
+ header_id = self.new_id("loop")
332
+ body_id = self.new_id("body")
333
+ after_id = continuation if continuation is not None else self.new_id("exit")
334
+ # An ``else`` clause runs when the loop is exhausted; ``break`` skips it.
335
+ exit_id = self.new_id("else") if node.orelse else after_id
336
+ self.finish(block, Jump(header_id, node.span))
337
+ if isinstance(node, nodes.While):
338
+ self.header(header_id, Branch(node.condition, body_id, exit_id, node.span))
339
+ else:
340
+ self.header(header_id, ForEach(node.target, node.iterable, body_id, exit_id, node.span))
341
+ self.loops.append((header_id, after_id))
342
+ try:
343
+ self.sequence(node.body, _Open(body_id), header_id, node.span)
344
+ finally:
345
+ self.loops.pop()
346
+ if node.orelse:
347
+ self.sequence(node.orelse, _Open(exit_id), after_id, node.span)
348
+ return None if continuation is not None else _Open(after_id)
349
+
350
+ def match_statement(
351
+ self, node: nodes.Match, block: _Open, continuation: BlockId | None
352
+ ) -> _Open | None:
353
+ """``match`` as an ``if`` chain over a hidden subject: literal, singleton, capture,
354
+ wildcard and or-patterns, with guards; other patterns are reported."""
355
+
356
+ subject = self.hidden("match", node.span)
357
+ statements: tuple[nodes.Statement, ...] = ()
358
+ for case in reversed(node.cases):
359
+ condition, bindings = self.pattern(case.pattern, subject)
360
+ if case.guard is not None:
361
+ condition = nodes.BoolOp("and", (condition, case.guard), case.span)
362
+ statements = (*bindings, nodes.If(condition, case.body, statements, case.span))
363
+ chain = (nodes.Assign(subject, node.subject, node.span), *statements)
364
+ return self.sequence(chain, block, continuation, node.span)
365
+
366
+ def pattern(
367
+ self, node: nodes.Pattern, subject: nodes.Expression
368
+ ) -> tuple[nodes.Expression, tuple[nodes.Statement, ...]]:
369
+ """The condition a pattern tests on ``subject`` and the names it binds."""
370
+
371
+ if isinstance(node, nodes.ValuePattern):
372
+ return nodes.Compare("eq", subject, node.value, node.span), ()
373
+ if isinstance(node, nodes.SingletonPattern):
374
+ return nodes.Compare("is", subject, nodes.Constant(node.value, node.span), node.span), ()
375
+ if isinstance(node, nodes.WildcardPattern):
376
+ return nodes.Constant(True, node.span), ()
377
+ if isinstance(node, nodes.CapturePattern):
378
+ binding = nodes.Assign(nodes.Name(node.name, node.span), subject, node.span)
379
+ if node.pattern is None:
380
+ return nodes.Constant(True, node.span), (binding,)
381
+ condition, inner = self.pattern(node.pattern, subject)
382
+ return condition, (*inner, binding)
383
+ if isinstance(node, nodes.OrPattern):
384
+ conditions: list[nodes.Expression] = []
385
+ bindings: list[nodes.Statement] = []
386
+ for alternative in node.alternatives:
387
+ condition, inner = self.pattern(alternative, subject)
388
+ conditions.append(condition)
389
+ bindings.extend(inner)
390
+ return nodes.BoolOp("or", tuple(conditions), node.span), tuple(bindings)
391
+ if isinstance(node, nodes.SequencePattern):
392
+ return self.sequence_pattern(node, subject)
393
+ if isinstance(node, nodes.MappingPattern):
394
+ return self.mapping_pattern(node, subject)
395
+ if isinstance(node, nodes.ClassPattern):
396
+ return self.class_pattern(node, subject)
397
+ if isinstance(node, nodes.StarPattern):
398
+ raise CFGError(f"{node.span.display()}: a star pattern only belongs in a sequence pattern")
399
+ raise CFGError(f"{node.span.display()}: match pattern {node.kind} is not supported yet")
400
+
401
+ def sequence_pattern(
402
+ self, node: nodes.SequencePattern, subject: nodes.Expression
403
+ ) -> tuple[nodes.Expression, tuple[nodes.Statement, ...]]:
404
+ """``[a, 0, *rest, b]``: the length fits, each item matches its sub-pattern and
405
+ the star captures the middle slice."""
406
+
407
+ span = node.span
408
+ count = len(node.patterns)
409
+ star = next((i for i, p in enumerate(node.patterns) if isinstance(p, nodes.StarPattern)), None)
410
+ length = nodes.Call(nodes.Name("len", span), (subject,), (), span)
411
+ if star is None:
412
+ conditions: list[nodes.Expression] = [nodes.Compare("eq", length, nodes.Constant(count, span), span)]
413
+ else:
414
+ conditions = [nodes.Compare("gt_eq", length, nodes.Constant(count - 1, span), span)]
415
+ bindings: list[nodes.Statement] = []
416
+ for index, sub in enumerate(node.patterns):
417
+ if isinstance(sub, nodes.StarPattern):
418
+ if sub.name is not None:
419
+ upper = None if index == count - 1 else nodes.Constant(index - count + 1, span)
420
+ piece = nodes.Subscript(subject, nodes.Slice(nodes.Constant(index, span), upper, None, span), span)
421
+ bindings.append(nodes.Assign(nodes.Name(sub.name, sub.span), piece, sub.span))
422
+ continue
423
+ position = index if star is None or index < star else index - count
424
+ item = nodes.Subscript(subject, nodes.Constant(position, span), span)
425
+ condition, inner = self.pattern(sub, item)
426
+ conditions.append(condition)
427
+ bindings.extend(inner)
428
+ return _conjunction(conditions, span), tuple(bindings)
429
+
430
+ def mapping_pattern(
431
+ self, node: nodes.MappingPattern, subject: nodes.Expression
432
+ ) -> tuple[nodes.Expression, tuple[nodes.Statement, ...]]:
433
+ """``{key: pattern, **rest}``: every key is present and its value matches."""
434
+
435
+ span = node.span
436
+ conditions: list[nodes.Expression] = []
437
+ bindings: list[nodes.Statement] = []
438
+ for key, sub in zip(node.keys, node.patterns, strict=True):
439
+ conditions.append(nodes.Compare("in", key, subject, span))
440
+ condition, inner = self.pattern(sub, nodes.Subscript(subject, key, span))
441
+ conditions.append(condition)
442
+ bindings.extend(inner)
443
+ if node.rest is not None:
444
+ bindings.append(nodes.Assign(nodes.Name(node.rest, span), subject, span))
445
+ return _conjunction(conditions, span), tuple(bindings)
446
+
447
+ def class_pattern(
448
+ self, node: nodes.ClassPattern, subject: nodes.Expression
449
+ ) -> tuple[nodes.Expression, tuple[nodes.Statement, ...]]:
450
+ """``Cls(name=pattern)``: an instance of the class whose attributes match."""
451
+
452
+ span = node.span
453
+ conditions: list[nodes.Expression] = [
454
+ nodes.Call(nodes.Name("isinstance", span), (subject, node.cls), (), span)
455
+ ]
456
+ bindings: list[nodes.Statement] = []
457
+ # Positional sub-patterns match the attributes ``__match_args__`` names, known
458
+ # for the module's classes; an unknown class gets a conservative position.
459
+ known = self.match_args.get(node.cls.identifier, ()) if isinstance(node.cls, nodes.Name) else ()
460
+ positional = [
461
+ (known[i] if i < len(known) else f"_match_arg_{i}", sub) for i, sub in enumerate(node.patterns)
462
+ ]
463
+ for name, sub in (*positional, *zip(node.keyword_names, node.keyword_patterns, strict=True)):
464
+ condition, inner = self.pattern(sub, nodes.Attribute(subject, name, span))
465
+ conditions.append(condition)
466
+ bindings.extend(inner)
467
+ return _conjunction(conditions, span), tuple(bindings)
468
+
469
+ def loop(self, keyword: str, span: SourceSpan) -> tuple[BlockId, BlockId]:
470
+ if not self.loops:
471
+ raise CFGError(f"{span.display()}: '{keyword}' outside loop")
472
+ return self.loops[-1]
473
+
474
+
475
+ def _conjunction(conditions: list[nodes.Expression], span: SourceSpan) -> nodes.Expression:
476
+ return conditions[0] if len(conditions) == 1 else nodes.BoolOp("and", tuple(conditions), span)
477
+
478
+
479
+ def _may_hold_expressions(value: object) -> bool:
480
+ return isinstance(value, tuple) or (is_dataclass(value) and not isinstance(value, type))
481
+
482
+
483
+ def _has_control_flow(node: object) -> bool:
484
+ if isinstance(node, nodes.Conditional | nodes.Comprehension):
485
+ return True
486
+ if isinstance(node, nodes.Lambda):
487
+ return False
488
+ if isinstance(node, tuple):
489
+ return any(_has_control_flow(item) for item in node)
490
+ if is_dataclass(node) and not isinstance(node, type):
491
+ return any(_has_control_flow(getattr(node, f.name)) for f in fields(node) if f.name != "span")
492
+ return False
493
+
494
+
495
+ def _bound_names(target: nodes.Target) -> list[str]:
496
+ if isinstance(target, nodes.Name):
497
+ return [target.identifier]
498
+ if isinstance(target, nodes.Tuple):
499
+ return [name for element in target.elements for name in _bound_names(element)] # type: ignore[arg-type]
500
+ return []
501
+
502
+
503
+ def _rename(node: Any, renames: dict[str, str]) -> Any:
504
+ """``node`` with comprehension variables replaced by their synthetic locals, without
505
+ entering scopes that rebind them."""
506
+
507
+ if not renames:
508
+ return node
509
+ if isinstance(node, nodes.Name):
510
+ return nodes.Name(renames[node.identifier], node.span) if node.identifier in renames else node
511
+ if isinstance(node, nodes.Lambda):
512
+ inner = {k: v for k, v in renames.items() if k not in {p.name for p in node.parameters}}
513
+ return replace(node, body=_rename(node.body, inner))
514
+ if isinstance(node, nodes.Comprehension):
515
+ shadowed = {name for g in node.generators for name in _bound_names(g.target)}
516
+ inner = {k: v for k, v in renames.items() if k not in shadowed}
517
+ generators = tuple(
518
+ replace(
519
+ g,
520
+ iterable=_rename(g.iterable, renames if index == 0 else inner),
521
+ conditions=_rename(g.conditions, inner),
522
+ )
523
+ for index, g in enumerate(node.generators)
524
+ )
525
+ key = _rename(node.key, inner) if node.key is not None else None
526
+ return replace(node, element=_rename(node.element, inner), generators=generators, key=key)
527
+ if isinstance(node, tuple):
528
+ return tuple(_rename(item, renames) for item in node)
529
+ if is_dataclass(node) and not isinstance(node, type):
530
+ changes = {
531
+ f.name: _rename(getattr(node, f.name), renames)
532
+ for f in fields(node)
533
+ if f.name != "span" and _may_hold_expressions(getattr(node, f.name))
534
+ }
535
+ return replace(node, **changes) if changes else node
536
+ return node
537
+
538
+
539
+ def _rename_target(target: nodes.Target, renames: dict[str, str]) -> nodes.Target:
540
+ renamed = _rename(target, renames)
541
+ assert isinstance(renamed, nodes.Name | nodes.Tuple | nodes.Attribute | nodes.Subscript)
542
+ return renamed
543
+
544
+
545
+ def build_cfg(function: nodes.Function, match_args: Mapping[str, tuple[str, ...]] | None = None) -> CFG:
546
+ return _Builder(function, match_args).build()
547
+
548
+
549
+ def match_args_of(module: nodes.Module) -> dict[str, tuple[str, ...]]:
550
+ """``__match_args__`` of the module's classes: explicit, or the field order of a
551
+ dataclass (bare annotated declarations and assignments, in order)."""
552
+
553
+ found: dict[str, tuple[str, ...]] = {}
554
+ for statement in module.body:
555
+ if not isinstance(statement, nodes.Class):
556
+ continue
557
+ explicit = None
558
+ fields: list[str] = []
559
+ for member in statement.body:
560
+ if isinstance(member, nodes.Assign) and isinstance(member.target, nodes.Name):
561
+ if member.target.identifier == "__match_args__" and isinstance(member.value, nodes.Tuple | nodes.List):
562
+ explicit = tuple(
563
+ e.value for e in member.value.elements if isinstance(e, nodes.Constant) and isinstance(e.value, str)
564
+ )
565
+ elif not member.target.identifier.startswith("_"):
566
+ fields.append(member.target.identifier)
567
+ elif isinstance(member, nodes.Declaration):
568
+ fields.append(member.name)
569
+ if explicit is not None:
570
+ found[statement.name] = explicit
571
+ elif any(_is_dataclass(d) for d in statement.decorators):
572
+ found[statement.name] = tuple(fields)
573
+ return found
574
+
575
+
576
+ def _is_dataclass(decorator: nodes.Expression) -> bool:
577
+ if isinstance(decorator, nodes.Call):
578
+ decorator = decorator.callee
579
+ if isinstance(decorator, nodes.Name):
580
+ return decorator.identifier == "dataclass"
581
+ return isinstance(decorator, nodes.Attribute) and decorator.name == "dataclass"
582
+
583
+
584
+ class CFGAnalysis(FunctionAnalysis[CFG]):
585
+ name: ClassVar[str] = "cfg.function"
586
+
587
+ @classmethod
588
+ def compute(cls, ctx: AnalysisContext, function: nodes.Function) -> CFG:
589
+ return build_cfg(function, match_args_of(ctx.module))