codeanalyzer-python 0.3.0__py3-none-any.whl → 1.0.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 (46) hide show
  1. codeanalyzer/__main__.py +77 -4
  2. codeanalyzer/core.py +174 -72
  3. codeanalyzer/dataflow/__init__.py +35 -0
  4. codeanalyzer/dataflow/access_paths.py +563 -0
  5. codeanalyzer/dataflow/alias.py +93 -0
  6. codeanalyzer/dataflow/builder.py +688 -0
  7. codeanalyzer/dataflow/cfg.py +605 -0
  8. codeanalyzer/dataflow/defuse.py +113 -0
  9. codeanalyzer/dataflow/dominance.py +140 -0
  10. codeanalyzer/dataflow/identity.py +91 -0
  11. codeanalyzer/dataflow/pdg.py +100 -0
  12. codeanalyzer/dataflow/scalpel_oracle.py +269 -0
  13. codeanalyzer/dataflow/scc.py +91 -0
  14. codeanalyzer/dataflow/sdg.py +424 -0
  15. codeanalyzer/dataflow/slicing.py +93 -0
  16. codeanalyzer/dataflow/summaries.py +217 -0
  17. codeanalyzer/dataflow/syntactic.py +26 -0
  18. codeanalyzer/neo4j/__init__.py +1 -1
  19. codeanalyzer/neo4j/bolt.py +19 -4
  20. codeanalyzer/neo4j/cypher.py +9 -3
  21. codeanalyzer/neo4j/emit.py +10 -5
  22. codeanalyzer/neo4j/project.py +307 -60
  23. codeanalyzer/neo4j/rows.py +18 -15
  24. codeanalyzer/neo4j/schema.py +297 -15
  25. codeanalyzer/options/options.py +4 -0
  26. codeanalyzer/provenance.py +61 -0
  27. codeanalyzer/schema/__init__.py +19 -0
  28. codeanalyzer/schema/assign_ids.py +37 -0
  29. codeanalyzer/schema/call_graph_ids.py +12 -0
  30. codeanalyzer/schema/ids.py +23 -0
  31. codeanalyzer/schema/l1_body.py +29 -0
  32. codeanalyzer/schema/l2_callees.py +36 -0
  33. codeanalyzer/schema/py_schema.py +175 -26
  34. codeanalyzer/semantic_analysis/call_graph.py +24 -27
  35. codeanalyzer/semantic_analysis/pycg/pycg_analysis.py +10 -10
  36. codeanalyzer/semantic_analysis/pycg/shard_planner.py +7 -7
  37. codeanalyzer/syntactic_analysis/import_resolver.py +67 -0
  38. codeanalyzer/syntactic_analysis/symbol_table_builder.py +103 -20
  39. {codeanalyzer_python-0.3.0.dist-info → codeanalyzer_python-1.0.0.dist-info}/METADATA +233 -43
  40. codeanalyzer_python-1.0.0.dist-info/RECORD +59 -0
  41. {codeanalyzer_python-0.3.0.dist-info → codeanalyzer_python-1.0.0.dist-info}/WHEEL +1 -1
  42. codeanalyzer/neo4j/catalog.py +0 -245
  43. codeanalyzer_python-0.3.0.dist-info/RECORD +0 -38
  44. {codeanalyzer_python-0.3.0.dist-info → codeanalyzer_python-1.0.0.dist-info}/entry_points.txt +0 -0
  45. {codeanalyzer_python-0.3.0.dist-info → codeanalyzer_python-1.0.0.dist-info}/licenses/LICENSE +0 -0
  46. {codeanalyzer_python-0.3.0.dist-info → codeanalyzer_python-1.0.0.dist-info}/licenses/NOTICE +0 -0
@@ -0,0 +1,605 @@
1
+ ################################################################################
2
+ # Copyright IBM Corporation 2025
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ ################################################################################
16
+
17
+ """Stage 1 of the level-3 dataflow ladder: the exceptional, statement-level CFG.
18
+
19
+ One CFG per callable, lowered from the stdlib ``ast`` tree — the same parse the
20
+ symbol-table builder uses, so node spans and callable signatures line up with
21
+ the rest of ``analysis.json``.
22
+
23
+ Lowering rules (the Python checklist from the CLDK dataflow contract):
24
+
25
+ - One synthetic ``ENTRY`` (node id 0) and one synthetic ``EXIT`` (last CFG id).
26
+ Multi-exit is normalized: every ``return``/``raise``/fall-off-end gets an
27
+ edge to ``EXIT`` with the appropriate kind.
28
+ - ``if``/``while``/``for`` headers are their own nodes (kinds ``branch`` /
29
+ ``loop``) with ``true``/``false`` out-edges; loop back edges carry
30
+ ``loop_back``; ``break``/``continue`` carry their own kinds.
31
+ - ``try/except/else/finally``: the try body is lowered in sequence; each
32
+ statement that can raise gets an ``exception`` edge to the innermost
33
+ enclosing handler chain (or ``EXIT`` when there is none). ``except`` match
34
+ clauses are ``handler`` nodes chained by ``false`` edges; an unmatched
35
+ exception propagates outward. ``finally`` bodies are lowered once, on the
36
+ normal path; abrupt entries (return / unhandled raise / break / continue
37
+ observed in the protected region) add corresponding out-edges from the
38
+ finally's end. Exceptions raised inside nested ``finally``-protected regions
39
+ connect straight to the enclosing handler chain — a documented
40
+ over-approximation (the finally body still executes on every normal path,
41
+ so its definitions are never lost, only their ordering on pure-exception
42
+ paths).
43
+ - ``with``/``async with``: the header is a ``statement`` node that defines the
44
+ ``as`` targets; the implicit ``__exit__`` try/finally is *not* materialized
45
+ (documented over-approximation); body statements keep their exception edges.
46
+ - Generators: a statement containing ``yield``/``yield from`` gets its
47
+ fall-through successor edge with kind ``yield`` (the resume path) plus a
48
+ ``yield`` edge to ``EXIT`` (the generator may never be resumed).
49
+ ``await`` marks the successor edge ``await_resume``.
50
+ - ``raise`` → ``exception`` edge to the handler chain / EXIT, no fall-through.
51
+ ``assert`` gets a fall-through plus an ``exception`` edge.
52
+ - Expression-level short-circuit (``and``/``or``/ternary) stays atomic inside
53
+ its statement node — the CFG is statement-level by contract.
54
+ - Comprehensions are atomic expressions of their statement (their implicit
55
+ loop and scope are handled by the access-path model, not the CFG).
56
+ - Nested ``def``/``class`` statements are single ``statement`` nodes (the
57
+ binding); their bodies get their own CFGs keyed by their own signatures.
58
+ Decorators are call-site facts, not CFG nodes.
59
+ - Infinite loops (``while True:`` with no break) get a synthetic ``exception``
60
+ edge from the loop header to ``EXIT`` so post-dominance stays well-formed
61
+ (in Python any loop can exit via an async signal such as KeyboardInterrupt,
62
+ so the edge is semantically honest).
63
+ - Statements unreachable from ``ENTRY`` (dead code after a return/raise) are
64
+ pruned: they cannot carry dependence.
65
+
66
+ Statements are considered able to raise when they contain a call, attribute
67
+ access, subscript, explicit ``raise``/``assert``, a ``with`` header, or a
68
+ ``for`` header (iterator protocol) — over-approximate by design.
69
+ """
70
+
71
+ from __future__ import annotations
72
+
73
+ import ast
74
+ from dataclasses import dataclass, field
75
+ from typing import Dict, List, Optional, Set, Tuple
76
+
77
+ # The shared, cross-language node-kind and edge-kind vocabulary. Python adds no
78
+ # renamed/repurposed kinds; `yield` / `await_resume` are the contract's own.
79
+ NODE_KINDS = (
80
+ "entry",
81
+ "exit",
82
+ "statement",
83
+ "branch",
84
+ "loop",
85
+ "return",
86
+ "raise",
87
+ "handler",
88
+ )
89
+
90
+ EDGE_KINDS = (
91
+ "fallthrough",
92
+ "true",
93
+ "false",
94
+ "switch_case",
95
+ "loop_back",
96
+ "exception",
97
+ "return",
98
+ "break",
99
+ "continue",
100
+ "yield",
101
+ "await_resume",
102
+ )
103
+
104
+
105
+ @dataclass
106
+ class CFGNode:
107
+ """A statement-level CFG node. ``id`` is assigned in source-span order
108
+ after construction (ENTRY = 0, EXIT = last CFG id)."""
109
+
110
+ id: int
111
+ kind: str
112
+ start_line: int = -1
113
+ end_line: int = -1
114
+ start_column: int = -1
115
+ end_column: int = -1
116
+ # The owning AST statement/expression (None for ENTRY/EXIT). Not emitted;
117
+ # used by later stages to compute def/use sets.
118
+ ast_node: Optional[ast.AST] = field(default=None, repr=False, compare=False)
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class CFGEdge:
123
+ source: int
124
+ target: int
125
+ kind: str
126
+
127
+
128
+ @dataclass
129
+ class ControlFlowGraph:
130
+ """CFG of a single callable, keyed externally by the callable signature."""
131
+
132
+ nodes: List[CFGNode]
133
+ edges: List[CFGEdge]
134
+ entry_id: int
135
+ exit_id: int
136
+
137
+ def successors(self) -> Dict[int, List[Tuple[int, str]]]:
138
+ succ: Dict[int, List[Tuple[int, str]]] = {n.id: [] for n in self.nodes}
139
+ for e in self.edges:
140
+ succ[e.source].append((e.target, e.kind))
141
+ return succ
142
+
143
+ def predecessors(self) -> Dict[int, List[Tuple[int, str]]]:
144
+ pred: Dict[int, List[Tuple[int, str]]] = {n.id: [] for n in self.nodes}
145
+ for e in self.edges:
146
+ pred[e.target].append((e.source, e.kind))
147
+ return pred
148
+
149
+ def node_by_id(self, node_id: int) -> CFGNode:
150
+ return next(n for n in self.nodes if n.id == node_id)
151
+
152
+
153
+ class _TempNode:
154
+ """Mutable node used during lowering, renumbered at finalize time."""
155
+
156
+ __slots__ = ("kind", "ast_node", "span", "seq")
157
+
158
+ def __init__(self, kind: str, ast_node: Optional[ast.AST], span, seq: int):
159
+ self.kind = kind
160
+ self.ast_node = ast_node
161
+ self.span = span # (start_line, start_col, end_line, end_col)
162
+ self.seq = seq
163
+
164
+
165
+ def _span_of(node: ast.AST) -> Tuple[int, int, int, int]:
166
+ return (
167
+ getattr(node, "lineno", -1),
168
+ getattr(node, "col_offset", -1),
169
+ getattr(node, "end_lineno", getattr(node, "lineno", -1)),
170
+ getattr(node, "end_col_offset", -1),
171
+ )
172
+
173
+
174
+ def _contains(node: ast.AST, types: tuple, *, into_nested_defs: bool = False) -> bool:
175
+ """True if ``node`` contains an AST node of one of ``types``, without
176
+ descending into nested function/class definitions (their bodies belong to
177
+ other CFGs) unless requested."""
178
+ stop = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)
179
+ for child in ast.iter_child_nodes(node):
180
+ if isinstance(child, types):
181
+ return True
182
+ if not into_nested_defs and isinstance(child, stop):
183
+ continue
184
+ if _contains(child, types, into_nested_defs=into_nested_defs):
185
+ return True
186
+ return False
187
+
188
+
189
+ def _can_raise(stmt: ast.stmt) -> bool:
190
+ """Over-approximate: if we can't prove the statement doesn't throw, it
191
+ gets the exception edge (contract rule)."""
192
+ if isinstance(stmt, (ast.Raise, ast.Assert, ast.With, ast.AsyncWith, ast.For, ast.AsyncFor)):
193
+ return True
194
+ return _contains(stmt, (ast.Call, ast.Attribute, ast.Subscript, ast.Await))
195
+
196
+
197
+ def _stmt_kind(stmt: ast.stmt) -> str:
198
+ if isinstance(stmt, ast.Return):
199
+ return "return"
200
+ if isinstance(stmt, ast.Raise):
201
+ return "raise"
202
+ if isinstance(stmt, ast.If):
203
+ return "branch"
204
+ if isinstance(stmt, (ast.While, ast.For, ast.AsyncFor)):
205
+ return "loop"
206
+ return "statement"
207
+
208
+
209
+ def _resume_kind(stmt: ast.stmt) -> str:
210
+ """Edge kind of the statement's normal successor edge: generators resume
211
+ after a yield, coroutines after an await."""
212
+ if _contains(stmt, (ast.Yield, ast.YieldFrom)):
213
+ return "yield"
214
+ if _contains(stmt, (ast.Await,)):
215
+ return "await_resume"
216
+ return "fallthrough"
217
+
218
+
219
+ class _LoopFrame:
220
+ __slots__ = ("header", "break_fringe")
221
+
222
+ def __init__(self, header: _TempNode):
223
+ self.header = header
224
+ # (node, kind) dangling edges produced by `break` — connected to the
225
+ # loop's successor once the loop is fully lowered.
226
+ self.break_fringe: List[Tuple[_TempNode, str]] = []
227
+
228
+
229
+ class _FinallyFrame:
230
+ """Tracks a try/finally protected region while its body is lowered.
231
+
232
+ ``entry_fringe`` collects the abrupt-exit nodes (return / raise / break /
233
+ continue) observed inside the protected region — they become incoming
234
+ edges of the finally body, which is how the finally stays reachable when
235
+ the try body never completes normally. ``abrupt`` records which exit kinds
236
+ were seen so the finally's end re-emits a matching out-edge for each."""
237
+
238
+ __slots__ = ("abrupt", "entry_fringe")
239
+
240
+ def __init__(self):
241
+ self.abrupt: Set[str] = set()
242
+ self.entry_fringe: List[Tuple[_TempNode, str]] = []
243
+
244
+
245
+ class CFGBuilder:
246
+ """Lowers one callable's AST into a :class:`ControlFlowGraph`."""
247
+
248
+ def __init__(self) -> None:
249
+ self._nodes: List[_TempNode] = []
250
+ self._edges: List[Tuple[_TempNode, _TempNode, str]] = []
251
+ self._seq = 0
252
+ self._loop_stack: List[_LoopFrame] = []
253
+ # Innermost-first chain of exception targets: (first handler node of a
254
+ # try's except chain, finally-stack depth when it was pushed). The
255
+ # depth lets exception edges mark only the finally frames *inside* the
256
+ # protected region as abruptly exited — an exception caught by this
257
+ # try's own handler re-enters the normal path.
258
+ self._handler_stack: List[Tuple[_TempNode, int]] = []
259
+ self._finally_stack: List[_FinallyFrame] = []
260
+
261
+ # ---------------------------------------------------------------- helpers
262
+
263
+ def _new_node(self, kind: str, ast_node: Optional[ast.AST], span=None) -> _TempNode:
264
+ node = _TempNode(kind, ast_node, span or (_span_of(ast_node) if ast_node else (-1, -1, -1, -1)), self._seq)
265
+ self._seq += 1
266
+ self._nodes.append(node)
267
+ return node
268
+
269
+ def _connect(self, fringe: List[Tuple[_TempNode, str]], target: _TempNode) -> None:
270
+ for source, kind in fringe:
271
+ self._edges.append((source, target, kind))
272
+
273
+ def _exception_target(self) -> Optional[_TempNode]:
274
+ return self._handler_stack[-1][0] if self._handler_stack else None
275
+
276
+ def _mark_exception_transit(self, node: Optional[_TempNode] = None) -> None:
277
+ """Mark the finally frames an in-flight exception passes through:
278
+ every frame inside the innermost handler's protected region, or all
279
+ frames when the exception escapes the function."""
280
+ depth = self._handler_stack[-1][1] if self._handler_stack else 0
281
+ transit = self._finally_stack[depth:]
282
+ for frame in transit:
283
+ frame.abrupt.add("exception")
284
+ if node is not None and transit:
285
+ transit[-1].entry_fringe.append((node, "exception"))
286
+
287
+ def _add_exception_edge(self, node: _TempNode, exit_node: _TempNode) -> None:
288
+ target = self._exception_target() or exit_node
289
+ self._edges.append((node, target, "exception"))
290
+ self._mark_exception_transit()
291
+
292
+ # ----------------------------------------------------------------- build
293
+
294
+ def build(self, func: ast.AST) -> ControlFlowGraph:
295
+ """``func`` is a FunctionDef / AsyncFunctionDef whose body is lowered.
296
+ ENTRY takes the ``def`` line's span; EXIT the end of the callable."""
297
+ entry = self._new_node("entry", None, span=(func.lineno, func.col_offset, func.lineno, func.col_offset))
298
+ end_line = getattr(func, "end_lineno", func.lineno)
299
+ end_col = getattr(func, "end_col_offset", -1)
300
+ self._exit = self._new_node("exit", None, span=(end_line, end_col, end_line, end_col))
301
+
302
+ fringe = self._lower_block(func.body, [(entry, "fallthrough")])
303
+ # Fall-off-end is an implicit `return None`.
304
+ self._connect([(n, "return") for n, _ in fringe], self._exit)
305
+
306
+ return self._finalize(entry, self._exit)
307
+
308
+ # ------------------------------------------------------------- lowering
309
+
310
+ def _lower_block(
311
+ self, stmts: List[ast.stmt], fringe: List[Tuple[_TempNode, str]]
312
+ ) -> List[Tuple[_TempNode, str]]:
313
+ for stmt in stmts:
314
+ if not fringe:
315
+ # Dead code after return/raise/break/continue: lower it anyway
316
+ # (nodes unreachable from ENTRY are pruned at finalize).
317
+ pass
318
+ fringe = self._lower_stmt(stmt, fringe)
319
+ return fringe
320
+
321
+ def _lower_stmt(
322
+ self, stmt: ast.stmt, fringe: List[Tuple[_TempNode, str]]
323
+ ) -> List[Tuple[_TempNode, str]]:
324
+ if isinstance(stmt, ast.If):
325
+ return self._lower_if(stmt, fringe)
326
+ if isinstance(stmt, ast.While):
327
+ return self._lower_while(stmt, fringe)
328
+ if isinstance(stmt, (ast.For, ast.AsyncFor)):
329
+ return self._lower_for(stmt, fringe)
330
+ if isinstance(stmt, ast.Try):
331
+ return self._lower_try(stmt, fringe)
332
+ if isinstance(stmt, (ast.With, ast.AsyncWith)):
333
+ return self._lower_with(stmt, fringe)
334
+ if isinstance(stmt, ast.Return):
335
+ return self._lower_return(stmt, fringe)
336
+ if isinstance(stmt, ast.Raise):
337
+ return self._lower_raise(stmt, fringe)
338
+ if isinstance(stmt, ast.Break):
339
+ return self._lower_break(stmt, fringe)
340
+ if isinstance(stmt, ast.Continue):
341
+ return self._lower_continue(stmt, fringe)
342
+ # Simple statement (incl. nested def/class = the binding statement).
343
+ node = self._new_node(_stmt_kind(stmt), stmt)
344
+ self._connect(fringe, node)
345
+ if _can_raise(stmt):
346
+ self._add_exception_edge(node, self._exit)
347
+ resume = _resume_kind(stmt)
348
+ if resume == "yield":
349
+ # The generator may be abandoned at any yield.
350
+ self._edges.append((node, self._exit, "yield"))
351
+ return [(node, resume)]
352
+
353
+ def _lower_if(self, stmt: ast.If, fringe):
354
+ header = self._new_node("branch", stmt, span=_span_of(stmt.test))
355
+ self._connect(fringe, header)
356
+ if _can_raise_expr(stmt.test):
357
+ self._add_exception_edge(header, self._exit)
358
+ then_fringe = self._lower_block(stmt.body, [(header, "true")])
359
+ if stmt.orelse:
360
+ else_fringe = self._lower_block(stmt.orelse, [(header, "false")])
361
+ else:
362
+ else_fringe = [(header, "false")]
363
+ return then_fringe + else_fringe
364
+
365
+ def _lower_while(self, stmt: ast.While, fringe):
366
+ header = self._new_node("loop", stmt, span=_span_of(stmt.test))
367
+ self._connect(fringe, header)
368
+ if _can_raise_expr(stmt.test):
369
+ self._add_exception_edge(header, self._exit)
370
+
371
+ frame = _LoopFrame(header)
372
+ self._loop_stack.append(frame)
373
+ body_fringe = self._lower_block(stmt.body, [(header, "true")])
374
+ self._loop_stack.pop()
375
+ self._connect([(n, "loop_back") for n, _ in body_fringe], header)
376
+
377
+ # `while True:` / constant-true tests never take the false edge.
378
+ always_true = isinstance(stmt.test, ast.Constant) and bool(stmt.test.value)
379
+ out = [] if always_true else [(header, "false")]
380
+ if stmt.orelse:
381
+ out = self._lower_block(stmt.orelse, out)
382
+ return out + frame.break_fringe
383
+
384
+ def _lower_for(self, stmt, fringe):
385
+ header = self._new_node("loop", stmt, span=_span_of(stmt.iter))
386
+ self._connect(fringe, header)
387
+ # The iterator protocol can raise.
388
+ self._add_exception_edge(header, self._exit)
389
+
390
+ frame = _LoopFrame(header)
391
+ self._loop_stack.append(frame)
392
+ body_fringe = self._lower_block(stmt.body, [(header, "true")])
393
+ self._loop_stack.pop()
394
+ self._connect([(n, "loop_back") for n, _ in body_fringe], header)
395
+
396
+ out = [(header, "false")]
397
+ if stmt.orelse:
398
+ out = self._lower_block(stmt.orelse, out)
399
+ return out + frame.break_fringe
400
+
401
+ def _lower_try(self, stmt: ast.Try, fringe):
402
+ has_finally = bool(stmt.finalbody)
403
+ finally_frame = _FinallyFrame() if has_finally else None
404
+
405
+ handler_entry: Optional[_TempNode] = None
406
+ handler_nodes: List[_TempNode] = []
407
+ if stmt.handlers:
408
+ for handler in stmt.handlers:
409
+ node = self._new_node("handler", handler, span=(
410
+ handler.lineno,
411
+ handler.col_offset,
412
+ getattr(handler.type, "end_lineno", handler.lineno) if handler.type else handler.lineno,
413
+ getattr(handler.type, "end_col_offset", -1) if handler.type else -1,
414
+ ))
415
+ handler_nodes.append(node)
416
+ handler_entry = handler_nodes[0]
417
+
418
+ if finally_frame is not None:
419
+ self._finally_stack.append(finally_frame)
420
+
421
+ # Protected region: body (+ else) raises reach this try's handlers.
422
+ if handler_entry is not None:
423
+ self._handler_stack.append((handler_entry, len(self._finally_stack)))
424
+ body_fringe = self._lower_block(stmt.body, fringe)
425
+ if stmt.orelse:
426
+ body_fringe = self._lower_block(stmt.orelse, body_fringe)
427
+ if handler_entry is not None:
428
+ self._handler_stack.pop()
429
+
430
+ # Handler chain: matched → handler body; unmatched → next handler,
431
+ # falling off the chain propagates outward (outer handler or EXIT).
432
+ handler_exit_fringes: List[Tuple[_TempNode, str]] = []
433
+ for i, (handler, node) in enumerate(zip(stmt.handlers, handler_nodes)):
434
+ hb_fringe = self._lower_block(handler.body, [(node, "true")])
435
+ handler_exit_fringes.extend(hb_fringe)
436
+ is_catch_all = handler.type is None
437
+ if i + 1 < len(handler_nodes):
438
+ self._edges.append((node, handler_nodes[i + 1], "false"))
439
+ elif not is_catch_all:
440
+ outer = self._exception_target() or self._exit
441
+ self._edges.append((node, outer, "exception"))
442
+ self._mark_exception_transit(node)
443
+
444
+ normal_fringe = body_fringe + handler_exit_fringes
445
+
446
+ if finally_frame is not None:
447
+ self._finally_stack.pop()
448
+ fin_entry = normal_fringe + finally_frame.entry_fringe
449
+ fin_fringe = self._lower_block(stmt.finalbody, fin_entry)
450
+ # Abrupt completions observed in the protected region re-emerge
451
+ # from the finally body's end.
452
+ for node, _kind in list(fin_fringe):
453
+ if "return" in finally_frame.abrupt:
454
+ self._edges.append((node, self._exit, "return"))
455
+ if "exception" in finally_frame.abrupt:
456
+ target = self._exception_target() or self._exit
457
+ self._edges.append((node, target, "exception"))
458
+ if "break" in finally_frame.abrupt and self._loop_stack:
459
+ self._loop_stack[-1].break_fringe.append((node, "break"))
460
+ if "continue" in finally_frame.abrupt and self._loop_stack:
461
+ self._edges.append((node, self._loop_stack[-1].header, "continue"))
462
+ return fin_fringe
463
+
464
+ return normal_fringe
465
+
466
+ def _lower_with(self, stmt, fringe):
467
+ node = self._new_node("statement", stmt, span=(
468
+ stmt.lineno,
469
+ stmt.col_offset,
470
+ stmt.items[-1].context_expr.end_lineno,
471
+ stmt.items[-1].context_expr.end_col_offset,
472
+ ))
473
+ self._connect(fringe, node)
474
+ self._add_exception_edge(node, self._exit)
475
+ return self._lower_block(stmt.body, [(node, "fallthrough")])
476
+
477
+ def _lower_return(self, stmt: ast.Return, fringe):
478
+ node = self._new_node("return", stmt)
479
+ self._connect(fringe, node)
480
+ if stmt.value is not None and _can_raise_expr(stmt.value):
481
+ self._add_exception_edge(node, self._exit)
482
+ if self._finally_stack:
483
+ # Routed through the innermost finally; its end re-emits `return`.
484
+ for frame in self._finally_stack:
485
+ frame.abrupt.add("return")
486
+ self._finally_stack[-1].entry_fringe.append((node, "return"))
487
+ return []
488
+ self._edges.append((node, self._exit, "return"))
489
+ return []
490
+
491
+ def _lower_raise(self, stmt: ast.Raise, fringe):
492
+ node = self._new_node("raise", stmt)
493
+ self._connect(fringe, node)
494
+ target = self._exception_target() or self._exit
495
+ self._edges.append((node, target, "exception"))
496
+ self._mark_exception_transit(node)
497
+ return []
498
+
499
+ def _lower_break(self, stmt: ast.Break, fringe):
500
+ node = self._new_node("statement", stmt)
501
+ self._connect(fringe, node)
502
+ if self._loop_stack:
503
+ self._loop_stack[-1].break_fringe.append((node, "break"))
504
+ for frame in self._finally_stack:
505
+ frame.abrupt.add("break")
506
+ if self._finally_stack:
507
+ self._finally_stack[-1].entry_fringe.append((node, "break"))
508
+ return []
509
+
510
+ def _lower_continue(self, stmt: ast.Continue, fringe):
511
+ node = self._new_node("statement", stmt)
512
+ self._connect(fringe, node)
513
+ if self._loop_stack:
514
+ self._edges.append((node, self._loop_stack[-1].header, "continue"))
515
+ for frame in self._finally_stack:
516
+ frame.abrupt.add("continue")
517
+ if self._finally_stack:
518
+ self._finally_stack[-1].entry_fringe.append((node, "continue"))
519
+ return []
520
+
521
+ # ------------------------------------------------------------- finalize
522
+
523
+ def _finalize(self, entry: _TempNode, exit_node: _TempNode) -> ControlFlowGraph:
524
+ # 1. Prune nodes unreachable from ENTRY (dead code).
525
+ succ: Dict[_TempNode, List[Tuple[_TempNode, str]]] = {n: [] for n in self._nodes}
526
+ for s, t, k in self._edges:
527
+ succ[s].append((t, k))
528
+ reachable: Set[_TempNode] = set()
529
+ stack = [entry]
530
+ while stack:
531
+ n = stack.pop()
532
+ if n in reachable:
533
+ continue
534
+ reachable.add(n)
535
+ for t, _ in succ[n]:
536
+ if t not in reachable:
537
+ stack.append(t)
538
+ reachable.add(exit_node) # EXIT always exists even if nothing reaches it yet
539
+
540
+ # 2. Synthetic escape edges: any reachable node that cannot reach EXIT
541
+ # sits in an infinite loop; give its loop header an `exception`
542
+ # edge to EXIT (documented above).
543
+ live_edges = [(s, t, k) for s, t, k in self._edges if s in reachable and t in reachable]
544
+ pred: Dict[_TempNode, List[_TempNode]] = {n: [] for n in reachable}
545
+ for s, t, _ in live_edges:
546
+ pred[t].append(s)
547
+ reaches_exit: Set[_TempNode] = set()
548
+ stack = [exit_node]
549
+ while stack:
550
+ n = stack.pop()
551
+ if n in reaches_exit:
552
+ continue
553
+ reaches_exit.add(n)
554
+ for p in pred[n]:
555
+ if p not in reaches_exit:
556
+ stack.append(p)
557
+ stuck = [n for n in reachable if n not in reaches_exit]
558
+ if stuck:
559
+ headers = [n for n in stuck if n.kind == "loop"] or stuck
560
+ for header in headers:
561
+ live_edges.append((header, exit_node, "exception"))
562
+
563
+ # 3. Renumber in source-span order: ENTRY = 0, EXIT = last.
564
+ middle = sorted(
565
+ (n for n in reachable if n is not entry and n is not exit_node),
566
+ key=lambda n: (n.span, n.seq),
567
+ )
568
+ ordered = [entry] + middle + [exit_node]
569
+ ids = {n: i for i, n in enumerate(ordered)}
570
+
571
+ nodes = [
572
+ CFGNode(
573
+ id=ids[n],
574
+ kind=n.kind,
575
+ start_line=n.span[0],
576
+ start_column=n.span[1],
577
+ end_line=n.span[2],
578
+ end_column=n.span[3],
579
+ ast_node=n.ast_node,
580
+ )
581
+ for n in ordered
582
+ ]
583
+ seen: Set[Tuple[int, int, str]] = set()
584
+ edges: List[CFGEdge] = []
585
+ for s, t, k in sorted(live_edges, key=lambda e: (ids[e[0]], ids[e[1]], e[2])):
586
+ key = (ids[s], ids[t], k)
587
+ if key in seen:
588
+ continue
589
+ seen.add(key)
590
+ edges.append(CFGEdge(source=ids[s], target=ids[t], kind=k))
591
+
592
+ return ControlFlowGraph(
593
+ nodes=nodes, edges=edges, entry_id=ids[entry], exit_id=ids[exit_node]
594
+ )
595
+
596
+
597
+ def _can_raise_expr(expr: ast.expr) -> bool:
598
+ return isinstance(expr, (ast.Call, ast.Attribute, ast.Subscript, ast.Await)) or _contains(
599
+ expr, (ast.Call, ast.Attribute, ast.Subscript, ast.Await)
600
+ )
601
+
602
+
603
+ def build_cfg(func: ast.AST) -> ControlFlowGraph:
604
+ """Build the exceptional, statement-level CFG of one callable."""
605
+ return CFGBuilder().build(func)