Python: add CFG-binding gap tests (red)

Adds inline-expectation tests for the new shared CFG implementation in
python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll,
covering every Python binding construct that introduces a variable.

The test files use MISSING: annotations to record bindings whose
defining Name AST node is *not* currently reachable from the new CFG.
These are the 'red' half of red-green commit pairs: subsequent commits
will extend AstNodeImpl to cover each construct and remove the
corresponding MISSING: marker.

Confirmed-broken categories:
- Import aliases (from x import a)
- Annotated assignment (x: int = 1)
- Exception handler (except E as e)
- Match patterns (case x, case [a,b], case ... as v)
- PEP 695 type params (def f[T], class C[T])

Confirmed-working (no MISSING:):
- Compound targets, with-as, comprehensions, decorated def/class,
  walrus, starred.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Copilot
2026-05-12 12:12:13 +00:00
committed by yoff
parent a70abdd007
commit 336c7a44a8
13 changed files with 230 additions and 0 deletions

View File

@@ -0,0 +1,37 @@
/**
* Phase -1 of the dataflow CFG migration: verifies that every variable
* binding visible to the AST (`Name.defines(v)`) corresponds to a CFG node
* in the new CFG (`semmle.python.controlflow.internal.AstNodeImpl`).
*
* The expected tag is `cfgdefines=<name>`. Each binding annotation in the
* test sources looks like `# $ cfgdefines=x` for a binding currently
* covered by the new CFG, or `# $ MISSING: cfgdefines=x` for a binding
* that is known to be uncovered (a "red" test case that should be
* green-flipped once the corresponding `cfg-ext-*` extension lands).
*
* Parameters (`def f(x):` etc.) are deliberately excluded — Java's
* pattern handles parameter writes at the SSA layer (`hasEntryDef`),
* not as CFG nodes.
*/
import python
import semmle.python.controlflow.internal.AstNodeImpl as CfgImpl
import utils.test.InlineExpectationsTest
module CfgBindingsTest implements TestSig {
string getARelevantTag() { result = "cfgdefines" }
predicate hasActualResult(Location location, string element, string tag, string value) {
exists(Name n, Variable v, CfgImpl::ControlFlowNode cfg |
n.defines(v) and
not py_expr_contexts(_, 4, n) and // exclude parameters
cfg.getAstNode().asExpr() = n and
location = n.getLocation() and
element = n.toString() and
tag = "cfgdefines" and
value = v.getId()
)
}
}
import MakeTest<CfgBindingsTest>

View File

@@ -0,0 +1,12 @@
# Annotated assignment (PEP 526). Both with and without an initializer.
a: int = 1 # $ MISSING: cfgdefines=a
b: str = "hi" # $ MISSING: cfgdefines=b
# Annotation without value: the AST records `c` as defined,
# but currently the new CFG has no node for it.
c: int # $ MISSING: cfgdefines=c
class K: # $ cfgdefines=K
field: int = 0 # $ MISSING: cfgdefines=field

View File

@@ -0,0 +1,14 @@
# Compound (tuple/list) assignment targets — actually wired in the new CFG.
a, b = (1, 2) # $ cfgdefines=a cfgdefines=b
[c, d] = [3, 4] # $ cfgdefines=c cfgdefines=d
# Nested unpacking.
(e, (f, g)) = (1, (2, 3)) # $ cfgdefines=e cfgdefines=f cfgdefines=g
# Star unpacking.
h, *i = [1, 2, 3] # $ cfgdefines=h cfgdefines=i
# Chained assignment with compound target.
j = k, l = (5, 6) # $ cfgdefines=j cfgdefines=k cfgdefines=l

View File

@@ -0,0 +1,18 @@
# Comprehension and `for` loop targets — wired in the new CFG.
# Bare-name `for` target.
for i in range(3): # $ cfgdefines=i
pass
# Compound `for` target.
for k, v in [(1, 2)]: # $ cfgdefines=k cfgdefines=v
pass
# Comprehension targets.
_ = [x for x in range(3)] # $ cfgdefines=_ cfgdefines=x
_ = {y: z for y, z in []} # $ cfgdefines=_ cfgdefines=y cfgdefines=z
_ = (a for a in []) # $ cfgdefines=_ cfgdefines=a
# Nested comprehensions.
_ = [b for c in [] for b in c] # $ cfgdefines=_ cfgdefines=c cfgdefines=b

View File

@@ -0,0 +1,30 @@
# Decorated `def`/`class` — wired in the new CFG.
def deco(f): # $ cfgdefines=deco
return f
@deco
def decorated_func(): # $ cfgdefines=decorated_func
pass
@deco
class DecoratedClass: # $ cfgdefines=DecoratedClass
pass
# Stacked decorators.
@deco
@deco
def doubly(): # $ cfgdefines=doubly
pass
# Inside a class body.
class Outer: # $ cfgdefines=Outer
@staticmethod
def inner(): # $ cfgdefines=inner
pass

View File

@@ -0,0 +1,19 @@
# Exception-handler name bindings. These are already wired in the new
# CFG provided the try body can raise; `raise` statements are reliably
# treated as exception sources.
try:
raise ValueError("oops")
except ValueError as e: # $ cfgdefines=e
pass
try:
raise TypeError("oops")
except (TypeError, KeyError) as err: # $ cfgdefines=err
pass
# Exception groups (Python 3.11+).
try:
raise ValueError("oops")
except* ValueError as eg: # $ cfgdefines=eg
pass

View File

@@ -0,0 +1,12 @@
# Import aliases. All bound names below currently lack a CFG node.
import os # $ MISSING: cfgdefines=os
import os.path # $ MISSING: cfgdefines=os
import os as o # $ MISSING: cfgdefines=o
from os import path # $ MISSING: cfgdefines=path
from os import path as p # $ MISSING: cfgdefines=p
from os import sep, linesep # $ MISSING: cfgdefines=sep MISSING: cfgdefines=linesep
from os import (
getcwd, # $ MISSING: cfgdefines=getcwd
getcwdb, # $ MISSING: cfgdefines=getcwdb
)

View File

@@ -0,0 +1,23 @@
# Match-statement pattern bindings.
def f(subject): # $ cfgdefines=f
match subject:
case x: # $ MISSING: cfgdefines=x
pass
case [a, b]: # $ MISSING: cfgdefines=a MISSING: cfgdefines=b
pass
case {"k": v}: # $ MISSING: cfgdefines=v
pass
case Point(p, q): # $ MISSING: cfgdefines=p MISSING: cfgdefines=q
pass
case [_, *rest]: # $ MISSING: cfgdefines=rest
pass
case (1 | 2) as n: # $ MISSING: cfgdefines=n
pass
class Point: # $ cfgdefines=Point
__match_args__ = ("x", "y") # $ cfgdefines=__match_args__
x: int # $ MISSING: cfgdefines=x
y: int # $ MISSING: cfgdefines=y

View File

@@ -0,0 +1,14 @@
# Simple bindings that should already work in the new CFG.
# No MISSING annotations expected.
x = 1 # $ cfgdefines=x
y = x + 1 # $ cfgdefines=y
def f(): # $ cfgdefines=f
pass
class C: # $ cfgdefines=C
pass
# Re-assignment.
x = 2 # $ cfgdefines=x

View File

@@ -0,0 +1,18 @@
# PEP 695 type parameters (Python 3.12+).
def func[T](x: T) -> T: # $ cfgdefines=func MISSING: cfgdefines=T
return x
class Box[T]: # $ cfgdefines=Box MISSING: cfgdefines=T
item: T # $ MISSING: cfgdefines=item
# Multi-parameter, with bound and variadics.
def multi[T: int, *Ts, **P](x: T, *args: *Ts, **kwargs: P.kwargs) -> T: # $ cfgdefines=multi MISSING: cfgdefines=T MISSING: cfgdefines=Ts MISSING: cfgdefines=P
return x
# `type` statement (PEP 695).
type Alias[T] = list[T] # $ MISSING: cfgdefines=Alias MISSING: cfgdefines=T

View File

@@ -0,0 +1,12 @@
# Walrus and starred-target edge cases — wired in the new CFG.
# Walrus in expression context.
if (y := 5) > 0: # $ cfgdefines=y
pass
# Walrus in a comprehension.
_ = [w for _ in range(3) if (w := 1)] # $ cfgdefines=_ cfgdefines=w
# Starred target in a Tuple LHS.
*head, tail = [1, 2, 3] # $ cfgdefines=head cfgdefines=tail

View File

@@ -0,0 +1,21 @@
# `with cm() as x:` bindings — wired in the new CFG.
class CM: # $ cfgdefines=CM
def __enter__(self): return self # $ cfgdefines=__enter__
def __exit__(self, *a): pass # $ cfgdefines=__exit__
with CM() as x: # $ cfgdefines=x
pass
# Multiple items.
with CM() as a, CM() as b: # $ cfgdefines=a cfgdefines=b
pass
# Parenthesised form (Python 3.10+).
with (CM() as p, CM() as q): # $ cfgdefines=p cfgdefines=q
pass
# Compound target in `with`.
with CM() as (m, n): # $ cfgdefines=m cfgdefines=n
pass