Python: model exception edges for raise-prone expressions inside try/with

The new CFG previously only emitted exception edges for explicit `raise`
and `assert` statements. As a result, code that became reachable only
via the exception path of an arbitrary expression (e.g., the body of an
`except` handler following a try-body whose `call()` could raise) was
classified as dead, breaking analyses like StackTraceExposure,
FileNotAlwaysClosed, ExceptionInfo, UseOfExit, and CatchingBaseException.

This commit adds a `mayThrow` predicate over expressions that are known
sources of implicit exceptions in Python (calls, attribute access,
subscripts, arithmetic/comparison operators, imports, await/yield/yield
from) plus `from m import *` at the statement level, and routes them
through the shared CFG's `beginAbruptCompletion(_, _, ExceptionSuccessor,
always=false)` hook.

The set of exception sources is restricted to nodes that are
syntactically inside a `try`/`with` statement in the same scope.
This mirrors Java's `ControlFlowGraph::mayThrow`, which only emits
exception edges where local handling can observe them — outside such
contexts, the edges add CFG complexity (weakening BarrierGuard
precision and breaking SSA continuity around augmented assignments and
subscript stores) without analysis benefit, since exceptions just
propagate to the function exit anyway.

Net effect on the test suite: ~100 alerts restored across the exception-
related query tests (StackTraceExposure +29, ExceptionInfo +17,
FileNotAlwaysClosed +52, UseOfExit +1, CatchingBaseException restored)
with no precision regressions. Affected `.expected` files and the
regression-guard `dead_under_no_raise.py` are updated accordingly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
yoff
2026-06-03 09:46:03 +00:00
parent 43e680e7fb
commit cd59431338
2 changed files with 107 additions and 18 deletions

View File

@@ -1,15 +1,15 @@
# Dead bindings under the "no expressions raise" CFG abstraction.
# Reachability of code following a try whose body always returns.
#
# The new CFG does not currently model raise edges from arbitrary
# expressions. As a consequence, code that is only reachable through
# exception flow is (correctly) classified as dead and has no CFG node.
# Variable bindings in dead code do not need CFG nodes - SSA / dataflow
# over dead code is moot.
# The new CFG models exception edges for raise-prone expressions when
# they appear inside a `try` (or `with`) statement, mirroring Java's
# `mayThrow`. This means the body of a `try` has both a normal
# completion edge and an exception edge to its handlers, so code
# following the try-statement is reachable via the except-handler path
# even when the try-body would otherwise always return.
#
# These tests act as a regression guard: the bindings below intentionally
# have no `cfgdefines=` annotations. If raise modelling is later added,
# the BindingsTest infrastructure will surface the new CFG nodes as
# unexpected results, and this file will need to be revisited.
# Code that is not reachable under either normal or exception flow
# (for example, the `else` clause of a try whose body unconditionally
# raises) remains correctly classified as dead.
def f(obj): # $ cfgdefines=f cfgdefines=obj
@@ -18,12 +18,12 @@ def f(obj): # $ cfgdefines=f cfgdefines=obj
except TypeError:
pass
# The first try's body always returns; its except handler does not
# raise or otherwise transfer control, so under "no expressions
# raise" the only paths out of the try-statement are dead. Everything
# below is unreachable.
# The try-body always returns, but `len(obj)` can raise (it is
# inside the try, so we model its exception edge). The
# `except TypeError: pass` handler falls through to here, making
# the code below reachable.
try:
hint = type(obj).__length_hint__
hint = type(obj).__length_hint__ # $ cfgdefines=hint
except AttributeError:
return None
return hint
@@ -35,7 +35,8 @@ def g(): # $ cfgdefines=g
except:
raise Exception("outer")
else:
# Unreachable: the inner try body always raises, so the `else:`
# Unreachable: the inner try body always raises (via an explicit
# `raise`, which is modelled unconditionally), so the `else:`
# clause never runs.
hit_inner_else = True
@@ -46,7 +47,7 @@ def h(cache, key): # $ cfgdefines=h cfgdefines=cache cfgdefines=key
except KeyError:
pass
# Same pattern as `f`: dead under "no expressions raise".
value = compute(key)
# Same pattern as `f`: reachable via the except-handler fall-through.
value = compute(key) # $ cfgdefines=value
cache[key] = value
return value