Python: wire parameters into the shared CFG (C# pattern)

Implements `AstSig::Parameter` and `callableGetParameter(c, i)` in
`AstNodeImpl.qll`, following the C# template
(`csharp/.../ControlFlowGraph.qll:147-156`) rather than Java's
`Parameter() { none() }`.

Each Python parameter (positional, *args, keyword-only, **kwargs) now
becomes a CFG node at a stable position in the enclosing callable's
entry sequence. Defaults still evaluate at function-definition time
via `FunctionDefExpr.getDefault` / `LambdaExpr.getDefault`, so
`Parameter::getDefaultValue()` returns `none()` (the shared CFG
library calls this to model the missing-argument fallback, which
Python does not surface at the CFG level).

The bindings test now exercises parameters (the `py_expr_contexts(_, 4, ...)`
exclusion has been removed). A new `parameters.py` test case covers
positional, defaulted, vararg, kwarg, keyword-only, kitchen-sink,
method (self/cls), lambda, and PEP 570 positional-only parameters.
Several other test files were updated to annotate parameters that the
test had previously hidden (synthetic `.0` comprehension parameter,
method `self`, decorator `f`, etc.).

Verified:
- All 24 ControlFlow/evaluation-order tests still pass.
- CFG consistency query (`python/ql/consistency-queries/CfgConsistency.ql`)
  shows zero violations on CPython.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Copilot
2026-05-12 12:29:28 +00:00
parent a20af3cf41
commit 0742e1e901
9 changed files with 109 additions and 26 deletions

View File

@@ -141,22 +141,63 @@ module Ast implements AstSig<Py::Location> {
/**
* A parameter of a callable.
*
* TODO: Implement in order to include parameters in the CFG.
* Modelled per the C# template (`csharp/.../ControlFlowGraph.qll:147-156`):
* each Python parameter (the `Py::Parameter` AST node, which is a `Name`
* or — Python 2 only — a `Tuple` in store context) becomes a CFG node
* at a stable position in the enclosing callable's entry sequence.
*
* Default-value expressions for positional and keyword-only parameters
* are wired separately on the `FunctionDefExpr` / `LambdaExpr` wrappers
* (they evaluate at function-definition time, not at call time).
* `Parameter::getDefaultValue()` returns `none()` here, signalling to
* the shared library that the parameter never falls back to a default
* during call binding. This mirrors C# for non-optional parameters.
*/
class Parameter extends AstNodeImpl {
Parameter() { none() }
class Parameter extends Expr {
private Py::Parameter param;
override string toString() { none() }
Parameter() { this = TPyExpr(param) }
override Py::Location getLocation() { none() }
override Callable getEnclosingCallable() { none() }
/** Gets the underlying Python parameter. */
Py::Parameter asParameter() { result = param }
/**
* Gets the default-value expression of this parameter, if any.
*
* Returns `none()`: defaults evaluate at function-definition time and
* are wired into the CFG via `FunctionDefExpr.getDefault` /
* `LambdaExpr.getDefault`. The shared library calls this predicate
* to model the "missing argument → evaluate default" fallback during
* call binding, which Python does not model at the CFG level.
*/
Expr getDefaultValue() { none() }
}
/** Gets the `index`th parameter of callable `c`. */
Parameter callableGetParameter(Callable c, int index) { none() }
/**
* Gets the `index`th parameter of callable `c`, ordered as Python binds
* them at call time: positional, then vararg (`*args`), then
* keyword-only, then kwarg (`**kwargs`).
*/
Parameter callableGetParameter(Callable c, int index) {
exists(Py::Function f | f = c.asScope() |
result.asParameter() =
rank[index + 1](Py::Parameter p, int subOrder, int subIndex |
// positional parameters first
p = f.getArg(subIndex) and subOrder = 0
or
// then *args
p = f.getVararg() and subOrder = 1 and subIndex = 0
or
// then keyword-only parameters
p = f.getKeywordOnlyArg(subIndex) and subOrder = 2
or
// finally **kwargs
p = f.getKwarg() and subOrder = 3 and subIndex = 0
|
p order by subOrder, subIndex
)
)
}
/** A statement. */
class Stmt extends AstNodeImpl, TStmt {

View File

@@ -8,10 +8,6 @@
* 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
@@ -24,7 +20,6 @@ module CfgBindingsTest implements TestSig {
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

View File

@@ -1,4 +1,6 @@
# Comprehension and `for` loop targets — wired in the new CFG.
# Comprehensions are nested function scopes with a synthetic `.0` parameter
# bound to the iterable.
# Bare-name `for` target.
for i in range(3): # $ cfgdefines=i
@@ -9,10 +11,11 @@ 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
_ = [x for x in range(3)] # $ cfgdefines=_ cfgdefines=x cfgdefines=.0
_ = {y: z for y, z in []} # $ cfgdefines=_ cfgdefines=y cfgdefines=z cfgdefines=.0
_ = (a for a in []) # $ cfgdefines=_ cfgdefines=a cfgdefines=.0
# Nested comprehensions.
_ = [b for c in [] for b in c] # $ cfgdefines=_ cfgdefines=c cfgdefines=b
_ = [b for c in [] for b in c] # $ cfgdefines=_ cfgdefines=c cfgdefines=b cfgdefines=.0

View File

@@ -1,7 +1,7 @@
# Decorated `def`/`class` — wired in the new CFG.
def deco(f): # $ cfgdefines=deco
def deco(f): # $ cfgdefines=deco cfgdefines=f
return f

View File

@@ -1,6 +1,6 @@
# Match-statement pattern bindings.
def f(subject): # $ cfgdefines=f
def f(subject): # $ cfgdefines=f cfgdefines=subject
match subject:
case x: # $ MISSING: cfgdefines=x
pass

View File

@@ -0,0 +1,42 @@
# Function parameters.
def positional(a, b): # $ cfgdefines=positional cfgdefines=a cfgdefines=b
pass
def with_default(x=1, y=2): # $ cfgdefines=with_default cfgdefines=x cfgdefines=y
pass
def with_vararg(*args): # $ cfgdefines=with_vararg cfgdefines=args
pass
def with_kwarg(**kwargs): # $ cfgdefines=with_kwarg cfgdefines=kwargs
pass
def with_kwonly(*, k1, k2=5): # $ cfgdefines=with_kwonly cfgdefines=k1 cfgdefines=k2
pass
def kitchen_sink(a, b=2, *args, k1, k2=5, **kw): # $ cfgdefines=kitchen_sink cfgdefines=a cfgdefines=b cfgdefines=args cfgdefines=k1 cfgdefines=k2 cfgdefines=kw
pass
# Methods get `self` / `cls`.
class C: # $ cfgdefines=C
def method(self, x): # $ cfgdefines=method cfgdefines=self cfgdefines=x
pass
@classmethod
def cmethod(cls, x): # $ cfgdefines=cmethod cfgdefines=cls cfgdefines=x
pass
# Lambda parameter.
_ = lambda p: p + 1 # $ cfgdefines=_ cfgdefines=p
# PEP 570 positional-only.
def pos_only(a, b, /, c): # $ cfgdefines=pos_only cfgdefines=a cfgdefines=b cfgdefines=c
pass

View File

@@ -1,6 +1,6 @@
# PEP 695 type parameters (Python 3.12+).
def func[T](x: T) -> T: # $ cfgdefines=func MISSING: cfgdefines=T
def func[T](x: T) -> T: # $ cfgdefines=func cfgdefines=x MISSING: cfgdefines=T
return x
@@ -9,7 +9,7 @@ class Box[T]: # $ cfgdefines=Box MISSING: cfgdefines=T
# 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
def multi[T: int, *Ts, **P](x: T, *args: *Ts, **kwargs: P.kwargs) -> T: # $ cfgdefines=multi cfgdefines=x cfgdefines=args cfgdefines=kwargs MISSING: cfgdefines=T MISSING: cfgdefines=Ts MISSING: cfgdefines=P
return x

View File

@@ -4,9 +4,11 @@
if (y := 5) > 0: # $ cfgdefines=y
pass
# Walrus in a comprehension.
_ = [w for _ in range(3) if (w := 1)] # $ cfgdefines=_ cfgdefines=w
# Walrus in a comprehension. The comprehension introduces a synthetic
# `.0` parameter bound to the iterable.
_ = [w for _ in range(3) if (w := 1)] # $ cfgdefines=_ cfgdefines=w cfgdefines=.0
# Starred target in a Tuple LHS.
*head, tail = [1, 2, 3] # $ cfgdefines=head cfgdefines=tail

View File

@@ -1,8 +1,8 @@
# `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__
def __enter__(self): return self # $ cfgdefines=__enter__ cfgdefines=self
def __exit__(self, *a): pass # $ cfgdefines=__exit__ cfgdefines=self cfgdefines=a
with CM() as x: # $ cfgdefines=x
pass