Merge pull request #21921 from github/yoff/python-add-new-cfg-library

Python: add new shared-CFG-backed control flow graph (additive)
This commit is contained in:
yoff
2026-07-28 15:12:31 +02:00
committed by GitHub
54 changed files with 3664 additions and 1 deletions

View File

@@ -0,0 +1,2 @@
import semmle.python.controlflow.internal.AstNodeImpl
import ControlFlow::Consistency

View File

@@ -0,0 +1,41 @@
/**
* @name Print CFG
* @description Produces a representation of a file's Control Flow Graph.
* This query is used by the VS Code extension.
* @id py/print-cfg
* @kind graph
* @tags ide-contextual-queries/print-cfg
*/
import semmle.python.Files as Files
import semmle.python.controlflow.internal.AstNodeImpl
external string selectedSourceFile();
private predicate selectedSourceFileAlias = selectedSourceFile/0;
external int selectedSourceLine();
private predicate selectedSourceLineAlias = selectedSourceLine/0;
external int selectedSourceColumn();
private predicate selectedSourceColumnAlias = selectedSourceColumn/0;
module ViewCfgQueryInput implements ControlFlow::ViewCfgQueryInputSig<Files::File> {
predicate selectedSourceFile = selectedSourceFileAlias/0;
predicate selectedSourceLine = selectedSourceLineAlias/0;
predicate selectedSourceColumn = selectedSourceColumnAlias/0;
predicate cfgScopeSpan(
Ast::Callable scope, Files::File file, int startLine, int startColumn, int endLine,
int endColumn
) {
file = scope.getLocation().getFile() and
scope.getLocation().hasLocationInfo(_, startLine, startColumn, endLine, endColumn)
}
}
import ControlFlow::ViewCfgQuery<Files::File, ViewCfgQueryInput>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,926 @@
/**
* Provides a Python control flow graph facade backed by the shared
* `codeql.controlflow.ControlFlowGraph` library (via `AstNodeImpl.qll`).
*
* This module re-exposes the same API surface as `semmle/python/Flow.qll`
* (the legacy CFG), but is implemented on the new shared CFG. It is
* intended as a drop-in replacement for use by the Python dataflow library
* and other downstream code.
*
* Layering follows the Java pattern (`java/ql/lib/semmle/code/java/Expr.qll`
* and `SsaImpl.qll`): variable identity and similar AST-level semantics
* live on the Python AST classes (`Name.defines(v)`, `Name.uses(v)`, ...);
* the CFG layer is purely positional, with `toAst` / `getNode` bridging
* back to the AST. The shared SSA library can then be parameterized on
* (`BasicBlock`, `int`) directly, with no CFG-level variable predicates.
*/
overlay[local?]
module;
private import python as Py
private import semmle.python.controlflow.internal.AstNodeImpl as CfgImpl
private import codeql.controlflow.SuccessorType
/**
* A control flow node.
*
* This is the full set of CFG nodes from the shared library — it includes
* before-nodes, in-order/post-order nodes, after-value-split nodes, and
* entry/exit nodes. This enables full control-flow-level reasoning and
* compatibility with the shared control-flow reachability library.
*
* AST-level semantics (`getNode()`, `isLoad()`, typed wrappers, etc.)
* are available only on the `injects` (canonical) node for each AST node.
* Non-injects nodes are purely positional CFG nodes with no AST mapping.
*/
class ControlFlowNode extends CfgImpl::ControlFlowNode {
/** Gets the syntactic element corresponding to this flow node, if any. */
Py::AstNode getNode() {
exists(CfgImpl::Ast::AstNode n | this.injects(n) | result = CfgImpl::astNodeToPyNode(n))
}
Py::Expr asPyExpr() { result = this.getNode() }
/** Gets a predecessor of this flow node. */
ControlFlowNode getAPredecessor() { this = result.getASuccessor() }
/** Gets a successor of this flow node. */
ControlFlowNode getASuccessor() { result = super.getASuccessor() }
/** Gets a successor for this node if the relevant condition is True. */
ControlFlowNode getATrueSuccessor() {
result = super.getASuccessor(any(BooleanSuccessor t | t.getValue() = true))
}
/** Gets a successor for this node if the relevant condition is False. */
ControlFlowNode getAFalseSuccessor() {
result = super.getASuccessor(any(BooleanSuccessor t | t.getValue() = false))
}
/** Gets a successor for this node if an exception is raised. */
ControlFlowNode getAnExceptionalSuccessor() { result = super.getAnExceptionSuccessor() }
/** Gets a successor for this node if no exception is raised. */
ControlFlowNode getANormalSuccessor() { result = super.getANormalSuccessor() }
/** Gets the basic block containing this flow node. */
BasicBlock getBasicBlock() { result = super.getBasicBlock() }
/** Gets the scope containing this flow node. */
Py::Scope getScope() { result = super.getEnclosingCallable().asScope() }
/** Gets the enclosing module. */
Py::Module getEnclosingModule() { result = this.getScope().getEnclosingModule() }
/** Gets the immediate dominator of this flow node. */
ControlFlowNode getImmediateDominator() {
// Defined positionally via the basic-block dominance tree.
exists(BasicBlock bb, int i | bb.getNode(i) = this |
// Predecessor within the same basic block.
i > 0 and result = bb.getNode(i - 1)
or
// First node of `bb`: dominator is the last node of the immediate dominator block.
i = 0 and result = bb.getImmediateDominator().getLastNode()
)
}
/** Holds if this strictly dominates `other`. */
overlay[caller?]
pragma[inline]
predicate strictlyDominates(ControlFlowNode other) { super.strictlyDominates(other) }
/** Holds if this dominates `other` (reflexively). */
overlay[caller?]
pragma[inline]
predicate dominates(ControlFlowNode other) { super.dominates(other) }
/** Holds if this is the first node in its enclosing scope. */
predicate isEntryNode() { this instanceof CfgImpl::ControlFlow::EntryNode }
/** Holds if this is the first node of a module. */
predicate isModuleEntry() {
this.isEntryNode() and super.getAstNode().asScope() instanceof Py::Module
}
/** Holds if this node may exit its scope by raising an exception. */
predicate isExceptionalExit(Py::Scope s) {
this instanceof CfgImpl::ControlFlow::ExceptionalExitNode and
super.getEnclosingCallable().asScope() = s
}
/** Holds if this node is a normal (non-exceptional) exit. */
predicate isNormalExit() { this instanceof CfgImpl::ControlFlow::NormalExitNode }
// ===== AST-shape predicates (bridges to the wrapped Python AST) =====
/**
* Holds if this flow node is a load (including those in augmented
* assignments).
*
* Note: an augmented-assignment target (`x[i]` in `x[i] += 1`) is
* both a load and a store — `isLoad` and `isStore` both hold on the
* canonical CFG node. This mirrors Java's `VarAccess.isVarRead`,
* which holds on the destination of compound and unary assignments
* even though the destination is also a write.
*/
predicate isLoad() { py_expr_contexts(_, 3, this.asPyExpr()) }
/** Holds if this flow node is a store (including those in augmented assignments). */
predicate isStore() { py_expr_contexts(_, 5, this.asPyExpr()) or augstore(_, this) }
/** Holds if this flow node is a delete. */
predicate isDelete() { py_expr_contexts(_, 2, this.asPyExpr()) }
/** Holds if this flow node is a parameter. */
predicate isParameter() { py_expr_contexts(_, 4, this.asPyExpr()) }
/** Holds if this flow node is a store in an augmented assignment. */
predicate isAugStore() { augstore(_, this) }
/** Holds if this flow node is a load in an augmented assignment. */
predicate isAugLoad() { augstore(this, _) }
/** Holds if this flow node corresponds to a literal. */
predicate isLiteral() {
this.getNode() instanceof Py::Bytes or
this.getNode() instanceof Py::Dict or
this.getNode() instanceof Py::DictComp or
this.getNode() instanceof Py::Set or
this.getNode() instanceof Py::SetComp or
this.getNode() instanceof Py::Ellipsis or
this.getNode() instanceof Py::GeneratorExp or
this.getNode() instanceof Py::Lambda or
this.getNode() instanceof Py::ListComp or
this.getNode() instanceof Py::List or
this.getNode() instanceof Py::Num or
this.getNode() instanceof Py::Tuple or
this.getNode() instanceof Py::Unicode or
this.getNode() instanceof Py::NameConstant
}
/** Holds if this flow node corresponds to an attribute expression. */
predicate isAttribute() { this.getNode() instanceof Py::Attribute }
/** Holds if this flow node corresponds to a subscript expression. */
predicate isSubscript() { this.getNode() instanceof Py::Subscript }
/** Holds if this flow node corresponds to an import member. */
predicate isImportMember() { this.getNode() instanceof Py::ImportMember }
/** Holds if this flow node corresponds to a call. */
predicate isCall() { this.getNode() instanceof Py::Call }
/** Holds if this flow node corresponds to an import. */
predicate isImport() { this.getNode() instanceof Py::ImportExpr }
/** Holds if this flow node corresponds to a conditional expression. */
predicate isIfExp() { this.getNode() instanceof Py::IfExp }
/** Holds if this flow node corresponds to a function definition expression. */
predicate isFunction() { this.getNode() instanceof Py::FunctionExpr }
/** Holds if this flow node corresponds to a class definition expression. */
predicate isClass() { this.getNode() instanceof Py::ClassExpr }
/**
* Holds if this flow node is a branch (i.e. has both a true and a
* false successor).
*/
predicate isBranch() { exists(this.getATrueSuccessor()) or exists(this.getAFalseSuccessor()) }
/**
* Gets a CFG child of this node, defined as a CFG node whose AST node
* is a child of this CFG node's AST node, restricted to nodes that
* dominate this one (so the child has been evaluated by the time we
* reach this node).
*
* Mirrors `Flow.qll`'s `getAChild`. UnaryExprNode is excluded because
* its operand is its CFG predecessor (handled separately).
*/
pragma[nomagic]
ControlFlowNode getAChild() {
this.getNode().(Py::Expr).getAChildNode() = result.getNode() and
result.getBasicBlock().dominates(this.getBasicBlock()) and
not this instanceof UnaryExprNode
}
/** Holds if this flow node strictly reaches `other`. */
predicate strictlyReaches(ControlFlowNode other) { this.getASuccessor+() = other }
}
/**
* Holds if `load` is the load half of an augmented-assignment target,
* and `store` is the corresponding store half.
*
* In the legacy CFG (`Flow.qll`) the same Python `Name` had two
* distinct CFG nodes — a load node (context 3) earlier in the BB, and
* a store node (context 5) later. The legacy `augstore` related the
* pair via dominance.
*
* In the new (shared) CFG, the canonical node for an AST expression is
* unique, so `load` and `store` collapse onto the same CFG node. The
* predicate is therefore reflexive on the augmented-assignment
* target's canonical node.
*/
private predicate augstore(ControlFlowNode load, ControlFlowNode store) {
exists(Py::AugAssign aa | aa.getTarget() = load.getNode()) and
load = store
}
/**
* A basic block — a maximal-length sequence of control flow nodes such
* that no node except the first has a predecessor outside the sequence,
* and no node except the last has a successor outside the sequence.
*/
class BasicBlock extends CfgImpl::BasicBlock {
/** Gets the `n`th node in this basic block. */
ControlFlowNode getNode(int n) { result = super.getNode(n) }
/** Gets a node in this basic block. */
ControlFlowNode getANode() { result = super.getNode(_) }
/** Gets the first node in this basic block. */
ControlFlowNode firstNode() { result = this.getNode(0) }
/** Gets the last node in this basic block. */
ControlFlowNode getLastNode() { result = super.getLastNode() }
/** Holds if this basic block contains `node`. */
predicate contains(ControlFlowNode node) { node = this.getANode() }
// Inherited from the shared library's `BasicBlock`:
// getASuccessor(), getASuccessor(SuccessorType), getAPredecessor(),
// strictlyDominates(), dominates(), getImmediateDominator(),
// length(), inLoop().
// We shadow `getNode(int)` etc. to return `ControlFlowNode` (this
// facade's type) and add Python-style helpers below.
/** Gets a true successor to this basic block. */
BasicBlock getATrueSuccessor() {
result = super.getASuccessor(any(BooleanSuccessor t | t.getValue() = true))
}
/** Gets a false successor to this basic block. */
BasicBlock getAFalseSuccessor() {
result = super.getASuccessor(any(BooleanSuccessor t | t.getValue() = false))
}
/** Gets an unconditional successor to this basic block. */
BasicBlock getAnUnconditionalSuccessor() {
result = super.getASuccessor() and
not result = this.getATrueSuccessor() and
not result = this.getAFalseSuccessor()
}
/** Gets an exceptional successor to this basic block. */
BasicBlock getAnExceptionalSuccessor() { result = super.getASuccessor(any(ExceptionSuccessor t)) }
/**
* Holds if this basic block is in the dominance frontier of `df`.
*
* Note: implemented locally rather than via the shared lib, which
* doesn't currently expose a `dominanceFrontier` predicate at this
* level.
*/
predicate inDominanceFrontier(BasicBlock df) { super.inDominanceFrontier(df) }
/** Holds if this basic block strictly reaches `other`. */
predicate strictlyReaches(BasicBlock other) { super.getASuccessor+() = other }
/** Holds if this basic block reaches `other` (reflexively). */
predicate reaches(BasicBlock other) { this = other or this.strictlyReaches(other) }
/** Holds if flow from this basic block reaches a normal exit from its scope. */
predicate reachesExit() {
this.getANode() instanceof CfgImpl::ControlFlow::NormalExitNode
or
exists(BasicBlock succ | succ = super.getASuccessor() and succ.reachesExit())
}
/** Gets the scope of this basic block. */
Py::Scope getScope() { exists(ControlFlowNode n | n = this.getANode() | result = n.getScope()) }
/** Holds if flow from this BasicBlock always reaches `succ`. */
predicate alwaysReaches(BasicBlock succ) {
succ = this
or
strictcount(BasicBlock s | s = super.getASuccessor()) = 1 and
succ = super.getASuccessor()
or
forex(BasicBlock immsucc | immsucc = super.getASuccessor() | immsucc.alwaysReaches(succ))
}
/**
* Holds if this basic block ends in a node that branches on a boolean
* outcome, and `other` is dominated by the corresponding successor
* for `branch` while not being reachable from the other branch
* without going through this BB.
*
* In other words: any execution that reaches `other` must have just
* evaluated the last node of this BB and taken the `branch` outcome.
* This mirrors the legacy `ConditionBlock.controls(BB, branch)`.
*/
predicate controls(BasicBlock other, boolean branch) {
super.edgeDominates(other, any(BooleanSuccessor t | t.getValue() = branch))
}
}
// ===========================================================================
// Re-exports for SSA / dominance consumers
//
// The shared `BB::CfgSig` requires `EntryBasicBlock` and `dominatingEdge` in
// addition to the BasicBlock class we already expose. They are provided by
// the shared CFG library on the `BB::Make` instantiation produced by
// `AstNodeImpl.qll`.
// ===========================================================================
/** An entry basic block, that is, a basic block whose first node is an entry node. */
class EntryBasicBlock = CfgImpl::Cfg::EntryBasicBlock;
/**
* Holds if `bb1` has `bb2` as a direct successor and the edge between `bb1`
* and `bb2` is a dominating edge.
*/
predicate dominatingEdge = CfgImpl::Cfg::dominatingEdge/2;
// ===========================================================================
// AST-shape subclasses of ControlFlowNode
//
// Each class is a thin wrapper around the canonical CFG node for a given
// kind of Python AST node. Methods that take/return CFG nodes look up
// related CFG nodes by AST identity (via `getNode()`), and the dominance
// constraint from the old CFG (`result.getBasicBlock().dominates(this.getBasicBlock())`)
// is preserved.
// ===========================================================================
/** Gets the canonical `ControlFlowNode` for AST expression `e`. */
ControlFlowNode astExprToCfg(Py::Expr e) { result.getNode() = e }
/** A control flow node corresponding to a `Name` or `PlaceHolder` expression. */
class NameNode extends ControlFlowNode {
NameNode() {
this.getNode() instanceof Py::Name
or
this.getNode() instanceof Py::PlaceHolder
}
/**
* Holds if this flow node defines the variable `v`.
*
* This includes augmented-assignment targets — `n += 1` is both a
* read and a write of `n`, so `defines(n)` and `uses(n)` both hold
* on the same canonical CFG node. Mirrors Java's `VariableUpdate`
* semantics where compound assignments register both a write
* (`VarWrite`) and a read (`VarRead`) on the destination.
*/
predicate defines(Py::Variable v) { exists(Py::Name n | n = this.getNode() and n.defines(v)) }
/** Holds if this flow node deletes the variable `v`. */
predicate deletes(Py::Variable v) { exists(Py::Name n | n = this.getNode() and n.deletes(v)) }
/** Holds if this flow node uses the variable `v`. */
predicate uses(Py::Variable v) {
this.isLoad() and
exists(Py::Name u | u = this.getNode() and u.uses(v))
or
exists(Py::PlaceHolder u |
u = this.getNode() and u.getVariable() = v and u.getCtx() instanceof Py::Load
)
}
/** Gets the identifier of this name node. */
string getId() {
result = this.getNode().(Py::Name).getId()
or
result = this.getNode().(Py::PlaceHolder).getId()
}
/** Holds if this is a use of a local variable. */
predicate isLocal() { exists(Py::Variable v | this.uses(v) and v instanceof Py::LocalVariable) }
/** Holds if this is a use of a non-local variable. */
predicate isNonLocal() {
exists(Py::Variable v | this.uses(v) and v.getScope() != this.getScope())
}
/** Holds if this is a use of a global (including builtin) variable. */
predicate isGlobal() { exists(Py::Variable v | this.uses(v) and v instanceof Py::GlobalVariable) }
/**
* Holds if this is a use of `self` — the first parameter of an
* enclosing method.
*
* AST-level approximation: matches when the Name uses a `Variable`
* that is the first parameter of an enclosing `Function` defined
* inside a `Class`.
*/
predicate isSelf() {
exists(Py::Variable v, Py::Function f, Py::Class c |
this.uses(v) and
f = c.getAMethod() and
v.getScope() = f and
v = f.getArg(0).(Py::Name).getVariable()
)
}
}
/** A control flow node corresponding to a named constant (`None`, `True`, `False`). */
class NameConstantNode extends NameNode {
NameConstantNode() { this.getNode() instanceof Py::NameConstant }
}
/** A control flow node corresponding to a call. */
class CallNode extends ControlFlowNode {
CallNode() { super.getNode() instanceof Py::Call }
override Py::Call getNode() { result = super.getNode() }
/** Gets the underlying Python `Call`. */
Py::Call getCall() { result = this.getNode() }
/** Gets the flow node for the function component of this call. */
ControlFlowNode getFunction() {
this.getCall().getFunc() = result.getNode() and
result.getBasicBlock().dominates(this.getBasicBlock())
}
/** Gets the flow node for the `n`th positional argument. */
ControlFlowNode getArg(int n) {
this.getCall().getArg(n) = result.getNode() and
result.getBasicBlock().dominates(this.getBasicBlock())
}
/** Gets the flow node for the named argument with name `name`. */
ControlFlowNode getArgByName(string name) {
exists(Py::Keyword k |
k = this.getCall().getANamedArg() and
k.getValue() = result.getNode() and
k.getArg() = name and
result.getBasicBlock().dominates(this.getBasicBlock())
)
}
/** Gets a flow node corresponding to any argument. */
ControlFlowNode getAnArg() { result = this.getArg(_) or result = this.getArgByName(_) }
/** Gets the first tuple (`*args`) argument, if any. */
ControlFlowNode getStarArg() {
this.getCall().getStarArg() = result.getNode() and
result.getBasicBlock().dominates(this.getBasicBlock())
}
/** Gets a dictionary (`**kwargs`) argument, if any. */
ControlFlowNode getKwargs() {
this.getCall().getKwargs() = result.getNode() and
result.getBasicBlock().dominates(this.getBasicBlock())
}
/** Holds if this call is a decorator call applied to a class or a function. */
predicate isDecoratorCall() { this.isClassDecoratorCall() or this.isFunctionDecoratorCall() }
/** Holds if this call is a decorator call applied to a class. */
predicate isClassDecoratorCall() {
exists(Py::ClassExpr cls | this.getNode() = cls.getADecoratorCall())
}
/** Holds if this call is a decorator call applied to a function. */
predicate isFunctionDecoratorCall() {
exists(Py::FunctionExpr func | this.getNode() = func.getADecoratorCall())
}
}
/** A control flow node corresponding to an attribute expression. */
class AttrNode extends ControlFlowNode {
AttrNode() { super.getNode() instanceof Py::Attribute }
override Py::Attribute getNode() { result = super.getNode() }
/** Gets the flow node for the object of the attribute expression. */
ControlFlowNode getObject() {
this.getNode().getObject() = result.getNode() and
result.getBasicBlock().dominates(this.getBasicBlock())
}
/** Gets the flow node for the object of this attribute expression, with the matching name. */
ControlFlowNode getObject(string name) {
this.getName() = name and
result = this.getObject()
}
/** Gets the attribute name. */
string getName() { result = this.getNode().getName() }
}
/** A control flow node corresponding to an import statement (`import x`). */
class ImportExprNode extends ControlFlowNode {
ImportExprNode() { super.getNode() instanceof Py::ImportExpr }
override Py::ImportExpr getNode() { result = super.getNode() }
}
/** A control flow node corresponding to a `from ... import name` expression. */
class ImportMemberNode extends ControlFlowNode {
ImportMemberNode() { super.getNode() instanceof Py::ImportMember }
override Py::ImportMember getNode() { result = super.getNode() }
/** Gets the flow node for the module being imported from, with the matching name. */
ControlFlowNode getModule(string name) {
exists(Py::ImportMember i |
i = this.getNode() and
i.getModule() = result.getNode() and
i.getName() = name and
result.getBasicBlock().dominates(this.getBasicBlock())
)
}
}
/** A control flow node corresponding to a `from ... import *` statement. */
class ImportStarNode extends ControlFlowNode {
ImportStarNode() { super.getNode() instanceof Py::ImportStar }
override Py::ImportStar getNode() { result = super.getNode() }
/** Gets the flow node for the module being imported from. */
ControlFlowNode getModule() {
this.getNode().getModuleExpr() = result.getNode() and
result.getBasicBlock().dominates(this.getBasicBlock())
}
}
/** A control flow node corresponding to a subscript expression. */
class SubscriptNode extends ControlFlowNode {
SubscriptNode() { super.getNode() instanceof Py::Subscript }
override Py::Subscript getNode() { result = super.getNode() }
/** Gets the flow node for the value being subscripted. */
ControlFlowNode getObject() {
this.getNode().getObject() = result.getNode() and
result.getBasicBlock().dominates(this.getBasicBlock())
}
/** Gets the flow node for the index expression. */
ControlFlowNode getIndex() {
this.getNode().getIndex() = result.getNode() and
result.getBasicBlock().dominates(this.getBasicBlock())
}
}
/** A control flow node corresponding to a comparison operation. */
class CompareNode extends ControlFlowNode {
CompareNode() { super.getNode() instanceof Py::Compare }
override Py::Compare getNode() { result = super.getNode() }
/** Holds if `left` and `right` are a pair of operands for this comparison. */
predicate operands(ControlFlowNode left, Py::Cmpop op, ControlFlowNode right) {
exists(Py::Compare c, Py::Expr eleft, Py::Expr eright |
c = this.getNode() and eleft = left.getNode() and eright = right.getNode()
|
eleft = c.getLeft() and eright = c.getComparator(0) and op = c.getOp(0)
or
exists(int i |
eleft = c.getComparator(i - 1) and eright = c.getComparator(i) and op = c.getOp(i)
)
) and
left.getBasicBlock().dominates(this.getBasicBlock()) and
right.getBasicBlock().dominates(this.getBasicBlock())
}
}
/** A control flow node corresponding to a conditional expression (`x if c else y`). */
class IfExprNode extends ControlFlowNode {
IfExprNode() { super.getNode() instanceof Py::IfExp }
override Py::IfExp getNode() { result = super.getNode() }
/** Gets the flow node for one of the value operands (true-branch or false-branch). */
ControlFlowNode getAnOperand() {
exists(Py::IfExp ie |
ie = this.getNode() and
(result.getNode() = ie.getBody() or result.getNode() = ie.getOrelse())
)
}
}
/** A control flow node corresponding to an assignment expression (walrus `:=`). */
class AssignmentExprNode extends ControlFlowNode {
AssignmentExprNode() { super.getNode() instanceof Py::AssignExpr }
override Py::AssignExpr getNode() { result = super.getNode() }
/** Gets the flow node for the left-hand side. */
ControlFlowNode getTarget() {
this.getNode().getTarget() = result.getNode() and
result.getBasicBlock().dominates(this.getBasicBlock())
}
/** Gets the flow node for the right-hand side. */
ControlFlowNode getValue() {
this.getNode().getValue() = result.getNode() and
result.getBasicBlock().dominates(this.getBasicBlock())
}
}
/** A control flow node corresponding to a binary expression (`a + b` etc.). */
class BinaryExprNode extends ControlFlowNode {
BinaryExprNode() { super.getNode() instanceof Py::BinaryExpr }
override Py::BinaryExpr getNode() { result = super.getNode() }
ControlFlowNode getLeft() {
this.getNode().getLeft() = result.getNode() and
result.getBasicBlock().dominates(this.getBasicBlock())
}
ControlFlowNode getRight() {
this.getNode().getRight() = result.getNode() and
result.getBasicBlock().dominates(this.getBasicBlock())
}
Py::Operator getOp() { result = this.getNode().(Py::BinaryExpr).getOp() }
/** Holds if `left` and `right` are the operands and `op` is the operator. */
predicate operands(ControlFlowNode left, Py::Operator op, ControlFlowNode right) {
left = this.getLeft() and right = this.getRight() and op = this.getOp()
}
/** Gets either operand. */
ControlFlowNode getAnOperand() { result = this.getLeft() or result = this.getRight() }
}
/** A control flow node corresponding to a boolean expression (`a and b`, `a or b`). */
class BoolExprNode extends ControlFlowNode {
BoolExprNode() { super.getNode() instanceof Py::BoolExpr }
override Py::BoolExpr getNode() { result = super.getNode() }
Py::Boolop getOp() { result = this.getNode().(Py::BoolExpr).getOp() }
/** Gets any operand of this boolean expression. */
ControlFlowNode getAnOperand() { this.getNode().getAValue() = result.getNode() }
}
/** A control flow node corresponding to a unary expression (`-x`, `not x`, etc.). */
class UnaryExprNode extends ControlFlowNode {
UnaryExprNode() { super.getNode() instanceof Py::UnaryExpr }
override Py::UnaryExpr getNode() { result = super.getNode() }
ControlFlowNode getOperand() {
this.getNode().getOperand() = result.getNode() and
result.getBasicBlock().dominates(this.getBasicBlock())
}
Py::Unaryop getOp() { result = this.getNode().(Py::UnaryExpr).getOp() }
}
/**
* A control flow node that is a definition: it appears in a context that
* binds a variable (assignment target, parameter, etc.).
*/
class DefinitionNode extends ControlFlowNode {
DefinitionNode() { this.isStore() or this.isParameter() }
/** Gets the value assigned, if any. */
ControlFlowNode getValue() {
// For-target: the value is the for-loop's iter expression (which
// is also where `Cfg::ForNode` lives — its `getNode()` returns the
// enclosing `Py::For` statement). Treated specially because there
// is no AST node holding the result of `iter(next(seq))`; we use
// the iter expression's CFG node as the stand-in.
exists(Py::For f |
f.getTarget() = this.getNode() and
result.getNode() = f.getIter()
)
or
exists(Py::AstNode value | value = assignedValue(this.getNode()) |
result.getNode() = value and
(
result.getBasicBlock().dominates(this.getBasicBlock())
or
result.isImport()
or
// The default value for a parameter is evaluated in the same basic block as
// the function definition, but the parameter belongs to the basic block of the
// function, so there is no dominance relationship between the two.
exists(Py::Parameter param | this.getNode() = param.asName())
)
)
}
}
/**
* Gets the AST node that holds the value assigned to `lhs` in a binding
* context. Mirrors `Flow.qll::assigned_value`.
*/
private Py::AstNode assignedValue(Py::Expr lhs) {
// lhs = result
exists(Py::Assign a | a.getATarget() = lhs and result = a.getValue())
or
// lhs := result
exists(Py::AssignExpr a | a.getTarget() = lhs and result = a.getValue())
or
// lhs: annotation = result
exists(Py::AnnAssign a | a.getTarget() = lhs and result = a.getValue())
or
// import result as lhs (also covers plain `import lhs`, where alias.getAsname() = lhs)
exists(Py::Alias a | a.getAsname() = lhs and result = a.getValue())
or
// lhs += x -> result is the (lhs + x) binary expression
exists(Py::AugAssign a, Py::BinaryExpr b |
b = a.getOperation() and result = b and lhs = b.getLeft()
)
or
// Nested sequence assign: ..., lhs, ... = ..., result, ...
exists(Py::Assign a | nestedSequenceAssign(a.getATarget(), a.getValue(), lhs, result))
or
// Parameter default
exists(Py::Parameter param | lhs = param.asName() and result = param.getDefault())
}
/**
* Helper for nested sequence assignments such as `(a, b), c = (1, 2), 3`.
*/
private predicate nestedSequenceAssign(
Py::Expr leftParent, Py::Expr rightParent, Py::Expr left, Py::Expr right
) {
exists(int i |
leftParent.(Py::Tuple).getElt(i) = left and rightParent.(Py::Tuple).getElt(i) = right
or
leftParent.(Py::List).getElt(i) = left and rightParent.(Py::List).getElt(i) = right
)
or
exists(Py::Expr leftMid, Py::Expr rightMid |
nestedSequenceAssign(leftParent, rightParent, leftMid, rightMid) and
nestedSequenceAssign(leftMid, rightMid, left, right)
)
}
/** A control flow node corresponding to a deletion (`del x`). */
class DeletionNode extends ControlFlowNode {
DeletionNode() { this.isDelete() }
}
/** A control flow node corresponding to a `for` loop target. */
class ForNode extends ControlFlowNode {
ForNode() { exists(Py::For f | this.getNode() = f.getIter()) }
/** Gets the iterable expression. */
ControlFlowNode getIter() {
result = this and result = result // canonical "after" of the iterable
}
/** Gets the sequence expression (alias for `getIter()`, matches legacy Flow naming). */
ControlFlowNode getSequence() { result = this.getIter() }
/** Gets the target (loop variable) of the `for` loop. */
ControlFlowNode getTarget() {
exists(Py::For f |
f.getIter() = this.getNode() and
f.getTarget() = result.getNode()
)
}
/** Holds if `target` is the loop variable and `sequence` is the iterable. */
predicate iterates(ControlFlowNode target, ControlFlowNode sequence) {
target = this.getTarget() and sequence = this.getSequence()
}
}
/** A control flow node corresponding to a `raise` statement. */
class RaiseStmtNode extends ControlFlowNode {
RaiseStmtNode() { super.getNode() instanceof Py::Raise }
override Py::Raise getNode() { result = super.getNode() }
/** Gets the exception expression, if any. */
ControlFlowNode getException() {
this.getNode().getException() = result.getNode() and
result.getBasicBlock().dominates(this.getBasicBlock())
}
}
/** A control flow node corresponding to a starred expression (`*x`). */
class StarredNode extends ControlFlowNode {
StarredNode() { this.getNode() instanceof Py::Starred }
/** Gets the value being starred. */
ControlFlowNode getValue() {
exists(Py::Starred s |
s = this.getNode() and
s.getValue() = result.getNode() and
result.getBasicBlock().dominates(this.getBasicBlock())
)
}
}
/** A control flow node corresponding to an `except` clause's name binding. */
class ExceptFlowNode extends ControlFlowNode {
ExceptFlowNode() { exists(Py::ExceptStmt e | this.getNode() = e.getName()) }
/** Gets the CFG node for the bound `as`-name itself. */
ControlFlowNode getName() { result = this }
/** Gets the type expression of this exception handler. */
ControlFlowNode getType() {
exists(Py::ExceptStmt e |
e.getName() = this.getNode() and
e.getType() = result.getNode() and
result.getBasicBlock().dominates(this.getBasicBlock())
)
}
}
/** A control flow node corresponding to an `except*` clause's name binding. */
class ExceptGroupFlowNode extends ControlFlowNode {
ExceptGroupFlowNode() { exists(Py::ExceptGroupStmt e | this.getNode() = e.getName()) }
/** Gets the CFG node for the bound `as`-name itself. */
ControlFlowNode getName() { result = this }
}
/** A control flow node corresponding to a sequence (tuple, list). */
abstract class SequenceNode extends ControlFlowNode {
/** Gets the `n`th element of this sequence. */
abstract ControlFlowNode getElement(int n);
/** Gets any element of this sequence. */
ControlFlowNode getAnElement() { result = this.getElement(_) }
}
/** A control flow node corresponding to a tuple literal. */
class TupleNode extends SequenceNode {
TupleNode() { this.getNode() instanceof Py::Tuple }
override ControlFlowNode getElement(int n) {
exists(Py::Tuple t |
t = this.getNode() and
t.getElt(n) = result.getNode() and
result.getBasicBlock().dominates(this.getBasicBlock())
)
}
}
/** A control flow node corresponding to a list literal. */
class ListNode extends SequenceNode {
ListNode() { this.getNode() instanceof Py::List }
override ControlFlowNode getElement(int n) {
exists(Py::List l |
l = this.getNode() and
l.getElt(n) = result.getNode() and
result.getBasicBlock().dominates(this.getBasicBlock())
)
}
}
/** A control flow node corresponding to a set literal. */
class SetNode extends ControlFlowNode {
SetNode() { this.getNode() instanceof Py::Set }
/** Gets the flow node for an element of the set. */
ControlFlowNode getAnElement() {
exists(Py::Set s |
s = this.getNode() and
s.getAnElt() = result.getNode() and
result.getBasicBlock().dominates(this.getBasicBlock())
)
}
}
/** A control flow node corresponding to a dict literal. */
class DictNode extends ControlFlowNode {
DictNode() { this.getNode() instanceof Py::Dict }
/** Gets the flow node for a key of the dict. */
ControlFlowNode getAKey() {
exists(Py::Dict d |
d = this.getNode() and
d.getAKey() = result.getNode() and
result.getBasicBlock().dominates(this.getBasicBlock())
)
}
/** Gets the flow node for a value of the dict. */
ControlFlowNode getAValue() {
exists(Py::Dict d |
d = this.getNode() and
d.getAValue() = result.getNode() and
result.getBasicBlock().dominates(this.getBasicBlock())
)
}
}
/** A control flow node corresponding to an iterable in a `for` loop. */
class IterableNode extends ControlFlowNode {
IterableNode() {
this instanceof SequenceNode
or
this instanceof SetNode
}
/** Gets the control flow node for an element of this iterable. */
ControlFlowNode getAnElement() {
result = this.(SequenceNode).getAnElement()
or
result = this.(SetNode).getAnElement()
}
}

View File

@@ -0,0 +1,4 @@
consistencyOverview
| deadEnd | 1 |
deadEnd
| without_loop.py:7:5:7:9 | Break |

View File

@@ -0,0 +1,32 @@
/**
* 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).
*/
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
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,13 @@
# Annotated assignment (PEP 526). Both with and without an initializer.
a: int = 1 # $ cfgdefines=a
b: str = "hi" # $ cfgdefines=b
# Annotation without value: the AST records `c` as defined,
# and the new CFG now visits it via the AnnAssignStmt wrapper.
c: int # $ cfgdefines=c
class K: # $ cfgdefines=K
field: int = 0 # $ 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,21 @@
# 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
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 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 cfgdefines=.0

View File

@@ -0,0 +1,53 @@
# Reachability of code following a try whose body always returns.
#
# 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.
#
# 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
try:
return len(obj)
except TypeError:
pass
# 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__ # $ cfgdefines=hint
except AttributeError:
return None
return hint
def g(): # $ cfgdefines=g
try:
raise Exception("inner")
except:
raise Exception("outer")
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
def h(cache, key): # $ cfgdefines=h cfgdefines=cache cfgdefines=key
try:
return cache[key]
except KeyError:
pass
# Same pattern as `f`: reachable via the except-handler fall-through.
value = compute(key) # $ cfgdefines=value
cache[key] = value
return value

View File

@@ -0,0 +1,30 @@
# Decorated `def`/`class` — wired in the new CFG.
def deco(f): # $ cfgdefines=deco cfgdefines=f
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,14 @@
# Import aliases — all bound names below are now reachable via the new
# CFG's `ImportStmt` wrapper.
import os # $ cfgdefines=os
import os.path # $ cfgdefines=os
import os as o # $ cfgdefines=o
from os import path # $ cfgdefines=path
from os import path as p # $ cfgdefines=p
from os import sep, linesep # $ cfgdefines=sep cfgdefines=linesep
from os import (
getcwd, # $ cfgdefines=getcwd
getcwdb, # $ cfgdefines=getcwdb
)

View File

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

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

@@ -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,4 @@
# Starred-target edge cases — wired in the new CFG.
# Starred target in a Tuple LHS.
*head, tail = [1, 2, 3] # $ cfgdefines=head cfgdefines=tail

View File

@@ -0,0 +1,21 @@
# PEP 695 type parameters (Python 3.12+).
# PEP 695 type-param names on `def`/`class` bind in an annotation scope
# that nests the function/class body — they have no CFG node in the
# enclosing scope (matching the legacy CFG).
def func[T](x: T) -> T: # $ cfgdefines=func cfgdefines=x
return x
class Box[T]: # $ cfgdefines=Box
item: T # $ SPURIOUS: cfgdefines=item
# Multi-parameter, with bound and variadics.
def multi[T: int, *Ts, **P](x: T, *args: *Ts, **kwargs: P.kwargs) -> T: # $ cfgdefines=multi cfgdefines=x cfgdefines=args cfgdefines=kwargs
return x
# `type` statement (PEP 695).
type Alias[T] = list[T] # $ cfgdefines=Alias cfgdefines=T

View File

@@ -0,0 +1,9 @@
# Walrus edge cases — wired in the new CFG.
# Walrus in expression context.
if (y := 5) > 0: # $ cfgdefines=y
pass
# 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

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__ cfgdefines=self
def __exit__(self, *a): pass # $ cfgdefines=__exit__ cfgdefines=self cfgdefines=a
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

View File

@@ -0,0 +1,13 @@
| test_boolean.py:9:10:9:43 | BoolExpr | $@ in $@ has no consecutive predecessor (expected 1) | test_boolean.py:9:59:9:59 | IntegerLiteral | Timestamp 2 | test_boolean.py:7:1:7:27 | Function test_and_both_sides | test_and_both_sides |
| test_boolean.py:15:10:15:43 | BoolExpr | $@ in $@ has no consecutive predecessor (expected 0) | test_boolean.py:15:50:15:50 | IntegerLiteral | Timestamp 1 | test_boolean.py:13:1:13:30 | Function test_and_short_circuit | test_and_short_circuit |
| test_boolean.py:21:10:21:42 | BoolExpr | $@ in $@ has no consecutive predecessor (expected 0) | test_boolean.py:21:49:21:49 | IntegerLiteral | Timestamp 1 | test_boolean.py:19:1:19:29 | Function test_or_short_circuit | test_or_short_circuit |
| test_boolean.py:27:10:27:34 | BoolExpr | $@ in $@ has no consecutive predecessor (expected 1) | test_boolean.py:27:50:27:50 | IntegerLiteral | Timestamp 2 | test_boolean.py:25:1:25:26 | Function test_or_both_sides | test_or_both_sides |
| test_boolean.py:40:10:40:61 | BoolExpr | $@ in $@ has no consecutive predecessor (expected 2) | test_boolean.py:40:86:40:86 | IntegerLiteral | Timestamp 3 | test_boolean.py:38:1:38:24 | Function test_chained_and | test_chained_and |
| test_boolean.py:46:10:46:61 | BoolExpr | $@ in $@ has no consecutive predecessor (expected 2) | test_boolean.py:46:86:46:86 | IntegerLiteral | Timestamp 3 | test_boolean.py:44:1:44:23 | Function test_chained_or | test_chained_or |
| test_boolean.py:52:10:52:95 | BoolExpr | $@ in $@ has no consecutive predecessor (expected 3) | test_boolean.py:52:120:52:120 | IntegerLiteral | Timestamp 4 | test_boolean.py:50:1:50:25 | Function test_mixed_and_or | test_mixed_and_or |
| test_boolean.py:52:11:52:47 | BoolExpr | $@ in $@ has no consecutive predecessor (expected 1) | test_boolean.py:52:63:52:63 | IntegerLiteral | Timestamp 2 | test_boolean.py:50:1:50:25 | Function test_mixed_and_or | test_mixed_and_or |
| test_boolean.py:52:78:52:79 | IntegerLiteral | $@ in $@ has no consecutive predecessor (expected 2) | test_boolean.py:52:85:52:85 | IntegerLiteral | Timestamp 3 | test_boolean.py:50:1:50:25 | Function test_mixed_and_or | test_mixed_and_or |
| test_comparison.py:37:6:37:41 | Compare | $@ in $@ has no consecutive predecessor (expected 1) | test_comparison.py:37:48:37:48 | IntegerLiteral | Timestamp 2 | test_comparison.py:32:1:32:32 | Function test_three_short_circuit | test_three_short_circuit |
| test_if.py:96:9:96:9 | x | $@ in $@ has no consecutive predecessor (expected 1) | test_if.py:96:15:96:15 | IntegerLiteral | Timestamp 2 | test_if.py:93:1:93:34 | Function test_if_compound_condition | test_if_compound_condition |
| test_if.py:96:9:96:29 | BoolExpr | $@ in $@ has no consecutive predecessor (expected 3) | test_if.py:96:36:96:36 | IntegerLiteral | Timestamp 4 | test_if.py:93:1:93:34 | Function test_if_compound_condition | test_if_compound_condition |
| test_if.py:99:13:99:13 | IntegerLiteral | $@ in $@ has no consecutive predecessor (expected 4) | test_if.py:99:19:99:19 | IntegerLiteral | Timestamp 5 | test_if.py:93:1:93:34 | Function test_if_compound_condition | test_if_compound_condition |

View File

@@ -0,0 +1,22 @@
/**
* Checks that each annotated node (except the minimum timestamp) has a
* predecessor annotation with timestamp `a - 1`. This is the reverse of
* ConsecutiveTimestamps: it catches nodes that are reachable but arrived
* at from the wrong place (skipping an intermediate node).
*
* Only applies to functions where all annotations are in the function's
* own scope (excludes tests with generators, async, comprehensions, or
* lambdas that have annotations in nested scopes).
*/
import OldCfgImpl
private module Utils = EvalOrderCfgUtils<OldCfg>;
private import Utils
private import Utils::CfgTests
from TimerAnnotation ann, int a
where consecutivePredecessorTimestamps(ann, a)
select ann, "$@ in $@ has no consecutive predecessor (expected " + (a - 1) + ")",
ann.getTimestampExpr(a), "Timestamp " + a, ann.getTestFunction(), ann.getTestFunction().getName()

View File

@@ -7,6 +7,7 @@
| test_boolean.py:52:11:52:47 | BoolExpr | $@ in $@ has no consecutive successor (expected 3) | test_boolean.py:52:63:52:63 | IntegerLiteral | Timestamp 2 | test_boolean.py:50:1:50:25 | Function test_mixed_and_or | test_mixed_and_or |
| test_boolean.py:52:27:52:31 | False | $@ in $@ has no consecutive successor (expected 2) | test_boolean.py:52:37:52:37 | IntegerLiteral | Timestamp 1 | test_boolean.py:50:1:50:25 | Function test_mixed_and_or | test_mixed_and_or |
| test_boolean.py:52:78:52:79 | IntegerLiteral | $@ in $@ has no consecutive successor (expected 4) | test_boolean.py:52:85:52:85 | IntegerLiteral | Timestamp 3 | test_boolean.py:50:1:50:25 | Function test_mixed_and_or | test_mixed_and_or |
| test_comparison.py:37:17:37:17 | IntegerLiteral | $@ in $@ has no consecutive successor (expected 2) | test_comparison.py:37:23:37:23 | IntegerLiteral | Timestamp 1 | test_comparison.py:32:1:32:32 | Function test_three_short_circuit | test_three_short_circuit |
| test_if.py:95:9:95:13 | False | $@ in $@ has no consecutive successor (expected 2) | test_if.py:95:19:95:19 | IntegerLiteral | Timestamp 1 | test_if.py:93:1:93:34 | Function test_if_compound_condition | test_if_compound_condition |
| test_if.py:96:9:96:29 | BoolExpr | $@ in $@ has no consecutive successor (expected 5) | test_if.py:96:36:96:36 | IntegerLiteral | Timestamp 4 | test_if.py:93:1:93:34 | Function test_if_compound_condition | test_if_compound_condition |
| test_if.py:96:22:96:22 | y | $@ in $@ has no consecutive successor (expected 4) | test_if.py:96:28:96:28 | IntegerLiteral | Timestamp 3 | test_if.py:93:1:93:34 | Function test_if_compound_condition | test_if_compound_condition |

View File

@@ -0,0 +1,13 @@
/** New-CFG version of AllLiveReachable. */
import python
import NewCfgImpl
private module Utils = EvalOrderCfgUtils<NewCfg>;
private import Utils
private import Utils::CfgTests
from TimerCfgNode a, TestFunction f
where allLiveReachable(a, f)
select a, "Unreachable live annotation; entry of $@ does not reach this node", f, f.getName()

View File

@@ -0,0 +1,17 @@
/**
* New-CFG version of AnnotationHasCfgNode.
*
* Checks that every timer annotation has a corresponding CFG node.
*/
import python
import NewCfgImpl
private module Utils = EvalOrderCfgUtils<NewCfg>;
private import Utils::CfgTests
from TimerAnnotation ann
where annotationWithoutCfgNode(ann)
select ann, "Annotation in $@ has no CFG node", ann.getTestFunction(),
ann.getTestFunction().getName()

View File

@@ -0,0 +1,24 @@
/**
* New-CFG version of BasicBlockAnnotationGap.
*
* Checks that within a basic block, if a node is annotated then its
* successor is also annotated (or excluded). A gap in annotations
* within a basic block indicates a missing annotation, since there
* are no branches to justify the gap.
*
* Nodes with exceptional successors are excluded, as the exception
* edge leaves the basic block and the normal successor may be dead.
*/
import python
import NewCfgImpl
private module Utils = EvalOrderCfgUtils<NewCfg>;
private import Utils
private import Utils::CfgTests
from TimerCfgNode a, CfgNode succ
where basicBlockAnnotationGap(a, succ)
select a, "Annotated node followed by unannotated $@ in the same basic block", succ,
succ.getNode().toString()

View File

@@ -0,0 +1,19 @@
/**
* New-CFG version of BasicBlockOrdering.
*
* Checks that within a single basic block, annotations appear in
* increasing minimum-timestamp order.
*/
import python
import NewCfgImpl
private module Utils = EvalOrderCfgUtils<NewCfg>;
private import Utils
private import Utils::CfgTests
from TimerCfgNode a, TimerCfgNode b, int minA, int minB
where basicBlockOrdering(a, b, minA, minB)
select a, "Basic block ordering: $@ appears before $@", a.getTimestampExpr(minA),
"timestamp " + minA, b.getTimestampExpr(minB), "timestamp " + minB

View File

@@ -0,0 +1,79 @@
/**
* New-CFG version of BranchTimestamps.
*
* Checks that when a node has both a true and false successor, the
* live timestamps on one branch are marked as dead on the other.
* This ensures that boolean branches are fully annotated with dead()
* markers for the paths not taken.
*
* Limitation: the `@ t[ts, ...]` / `dead(ts)` annotation scheme can only
* model branch-dead-ness for plain boolean control flow that reconverges
* linearly after the split — i.e. `if`-with-else and `if`-expression.
* It cannot model:
*
* * loops (`while` / `for`): body timestamps repeat across iterations,
* so the loop-exit annotation can't list them as dead;
* * `match` statements: each `case` body is a syntactically distinct
* sub-tree, and the branches don't reconverge through a common
* annotation point in the timeline;
* * `try` / `with` and `raise` / `assert`: exception edges are modeled
* as true/false but flow to syntactically distinct handlers, with no
* reconvergence in the linear annotation order;
* * short-circuit `and` / `or` (`BoolExpr`): the branches reconverge at
* the BoolExpr's after-node, so timestamps on one branch are live
* downstream of the other rather than dead;
* * `if` without an `else` clause, and `if`/`elif` chains: the false
* branch reconverges with the true branch at the post-if statement
* (no-else) or fans out across multiple elif-test annotations,
* neither of which fit the binary annotation scheme.
*
* Branch nodes inside those constructs are therefore whitelisted out
* below. The check still fires (and is useful) for plain `if`/`else`
* and conditional-expression branching.
*/
import python
import NewCfgImpl
private module Utils = EvalOrderCfgUtils<NewCfg>;
private import Utils
private import Utils::CfgTests
/**
* Holds if `f` contains a construct whose branches the linear-timestamp
* annotation scheme cannot describe (see file-level comment).
*/
private predicate hasUnmodellableBranching(Function f) {
exists(AstNode bad |
bad.getScope() = f and
(
bad instanceof While
or
bad instanceof For
or
bad instanceof MatchStmt
or
bad instanceof Try
or
bad instanceof With
or
bad instanceof Raise
or
bad instanceof Assert
or
bad instanceof BoolExpr
or
bad instanceof If and
(not exists(bad.(If).getAnOrelse()) or bad.(If).isElif())
)
)
}
from TimerCfgNode node, int ts, string branch
where
missingBranchTimestamp(node, ts, branch) and
not hasUnmodellableBranching(node.getTestFunction())
select node,
"Timestamp " + ts + " on true/false branch is missing a dead() annotation on the " + branch +
" successor in $@", node.getTestFunction(), node.getTestFunction().getName()

View File

@@ -0,0 +1 @@
| test_comparison.py:37:6:37:41 | Compare | $@ in $@ has no consecutive predecessor (expected 1) | test_comparison.py:37:48:37:48 | IntegerLiteral | Timestamp 2 | test_comparison.py:32:1:32:32 | Function test_three_short_circuit | test_three_short_circuit |

View File

@@ -0,0 +1,21 @@
/**
* New-CFG version of ConsecutivePredecessorTimestamps.
*
* Checks that each annotated node (except the minimum timestamp) has
* a predecessor annotation with timestamp `a - 1`. This is the reverse
* of ConsecutiveTimestamps: it catches nodes that are reachable but
* arrived at from the wrong place (skipping an intermediate node).
*/
import python
import NewCfgImpl
private module Utils = EvalOrderCfgUtils<NewCfg>;
private import Utils
private import Utils::CfgTests
from TimerAnnotation ann, int a
where consecutivePredecessorTimestamps(ann, a)
select ann, "$@ in $@ has no consecutive predecessor (expected " + (a - 1) + ")",
ann.getTimestampExpr(a), "Timestamp " + a, ann.getTestFunction(), ann.getTestFunction().getName()

View File

@@ -0,0 +1 @@
| test_comparison.py:37:17:37:17 | IntegerLiteral | $@ in $@ has no consecutive successor (expected 2) | test_comparison.py:37:23:37:23 | IntegerLiteral | Timestamp 1 | test_comparison.py:32:1:32:32 | Function test_three_short_circuit | test_three_short_circuit |

View File

@@ -0,0 +1,27 @@
/**
* New-CFG version of ConsecutiveTimestamps.
*
* Checks that consecutive annotated nodes have consecutive timestamps:
* for each annotation with timestamp `a`, some CFG node for that annotation
* must have a next annotation containing `a + 1`.
*
* Handles CFG splitting (e.g., finally blocks duplicated for normal/exceptional
* flow) by checking that at least one split has the required successor.
*
* Only applies to functions where all annotations are in the function's
* own scope (excludes tests with generators, async, comprehensions, or
* lambdas that have annotations in nested scopes).
*/
import python
import NewCfgImpl
private module Utils = EvalOrderCfgUtils<NewCfg>;
private import Utils
private import Utils::CfgTests
from TimerAnnotation ann, int a
where consecutiveTimestamps(ann, a)
select ann, "$@ in $@ has no consecutive successor (expected " + (a + 1) + ")",
ann.getTimestampExpr(a), "Timestamp " + a, ann.getTestFunction(), ann.getTestFunction().getName()

View File

@@ -0,0 +1,118 @@
/**
* Implementation of the evaluation-order CFG signature using the new
* shared control flow graph from AstNodeImpl.
*/
private import python as Py
import TimerUtils
private import semmle.python.controlflow.internal.AstNodeImpl as CfgImpl
private import codeql.controlflow.SuccessorType
private class NewControlFlowNode = CfgImpl::ControlFlowNode;
private class NewBasicBlock = CfgImpl::BasicBlock;
/** New (shared) CFG implementation of the evaluation-order signature. */
module NewCfg implements EvalOrderCfgSig {
class CfgNode instanceof NewControlFlowNode {
// We must pick a *unique* representative CFG node for each AST node. The
// shared CFG has several nodes per AST node (before / in-post-order / after
// / after-value splits), but the timer test framework keys annotations on
// `getNode()` and assumes one CFG node per annotated AST node. Without a
// filter, an annotated `f()` would map to both `f()` and `After f()`, which
// breaks two framework invariants: (1) the "no shared reachable" check
// requires that two distinct nodes sharing a timestamp be mutually
// unreachable (true/false branches of a condition), but `Before f()`,
// `f()` and `After f()` share the annotation's timestamp *and* lie on one
// linear path; and (2) the annotation walk (`nextTimerAnnotation`) halts at
// the first reachable representative, so a second node for the same AST
// node would stall the walk on the same timestamp instead of advancing to
// the next evaluation event.
//
// We use the "after" node (`isAfter`) rather than the canonical `injects`
// node, because `injects` represents short-circuit / conditional
// expressions (`and`/`or`/`not`/ternary) by their *before* node, placing
// them ahead of their operands — wrong for evaluation order. `isAfter`
// instead picks the post-evaluation node: the merged before/after node for
// simple leaves, the `TAfterNode` for post-order expressions, and the
// `AfterValueNode`(s) for pre-order conditionals, all positioned after the
// operands. The two value-split nodes of a conditional are genuinely
// distinct evaluation outcomes (handled by `getATrueSuccessor` /
// `getAFalseSuccessor`), so they do not violate the uniqueness assumption.
CfgNode() { NewControlFlowNode.super.isAfter(_) }
string toString() { result = NewControlFlowNode.super.toString() }
Py::Location getLocation() { result = NewControlFlowNode.super.getLocation() }
Py::AstNode getNode() {
result = CfgImpl::astNodeToPyNode(NewControlFlowNode.super.getAstNode())
}
CfgNode getASuccessor() { nextCfgNode(this, result) }
CfgNode getATrueSuccessor() {
NewControlFlowNode.super.isAfterTrue(_) and
// Only where there's also a false branch (true boolean split)
exists(NewControlFlowNode other | other.isAfterFalse(NewControlFlowNode.super.getAstNode())) and
nextCfgNodeFrom(this, result)
}
CfgNode getAFalseSuccessor() {
NewControlFlowNode.super.isAfterFalse(_) and
// Only where there's also a true branch (true boolean split)
exists(NewControlFlowNode other | other.isAfterTrue(NewControlFlowNode.super.getAstNode())) and
nextCfgNodeFrom(this, result)
}
CfgNode getAnExceptionalSuccessor() {
exists(NewControlFlowNode mid |
mid = NewControlFlowNode.super.getAnExceptionSuccessor() and
nextCfgNodeFrom(mid, result)
)
}
Py::Scope getScope() { result = NewControlFlowNode.super.getEnclosingCallable().asScope() }
BasicBlock getBasicBlock() { exists(NewBasicBlock bb | bb.getNode(_) = this and result = bb) }
}
/**
* Holds if `next` is the nearest CfgNode reachable from `n` via
* one or more raw CFG successor edges, skipping non-CfgNode intermediaries.
*/
private predicate nextCfgNodeFrom(NewControlFlowNode n, CfgNode next) {
next = n.getASuccessor()
or
exists(NewControlFlowNode mid |
mid = n.getASuccessor() and
not mid instanceof CfgNode and
nextCfgNodeFrom(mid, next)
)
}
/**
* Holds if `next` is the nearest CfgNode successor of `n`,
* skipping synthetic intermediate nodes.
*/
private predicate nextCfgNode(CfgNode n, CfgNode next) { nextCfgNodeFrom(n, next) }
class BasicBlock instanceof NewBasicBlock {
string toString() { result = NewBasicBlock.super.toString() }
CfgNode getNode(int n) { result = NewBasicBlock.super.getNode(n) }
predicate reaches(BasicBlock bb) { this = bb or this.strictlyReaches(bb) }
predicate strictlyReaches(BasicBlock bb) { NewBasicBlock.super.getASuccessor+() = bb }
predicate strictlyDominates(BasicBlock bb) { NewBasicBlock.super.strictlyDominates(bb) }
}
CfgNode scopeGetEntryNode(Py::Scope s) {
exists(CfgImpl::ControlFlow::EntryNode entry |
entry.getEnclosingCallable().asScope() = s and
nextCfgNodeFrom(entry, result)
)
}
}

View File

@@ -0,0 +1,19 @@
/**
* New-CFG version of NeverReachable.
*
* Checks that expressions annotated with `t.never` either have no CFG
* node, or if they do, that the node is not reachable from its scope's
* entry (including within the same basic block).
*/
import python
import NewCfgImpl
private module Utils = EvalOrderCfgUtils<NewCfg>;
private import Utils::CfgTests
from TimerAnnotation ann
where neverReachable(ann)
select ann, "Node annotated with t.never is reachable in $@", ann.getTestFunction(),
ann.getTestFunction().getName()

View File

@@ -0,0 +1,20 @@
/**
* New-CFG version of NoBackwardFlow.
*
* Checks that time never flows backward between consecutive timer annotations
* in the CFG. For each pair of consecutive annotated nodes (A -> B), there must
* exist timestamps a in A and b in B with a < b.
*/
import python
import NewCfgImpl
private module Utils = EvalOrderCfgUtils<NewCfg>;
private import Utils
private import Utils::CfgTests
from TimerCfgNode a, TimerCfgNode b, int minA, int maxB
where noBackwardFlow(a, b, minA, maxB)
select a, "Backward flow: $@ flows to $@ (max timestamp $@)", a.getTimestampExpr(minA),
minA.toString(), b, b.getNode().toString(), b.getTimestampExpr(maxB), maxB.toString()

View File

@@ -0,0 +1,17 @@
/**
* New-CFG version of NoBasicBlock.
*
* Checks that every annotated CFG node belongs to a basic block.
*/
import python
import NewCfgImpl
private module Utils = EvalOrderCfgUtils<NewCfg>;
private import Utils
private import Utils::CfgTests
from CfgNode n, TestFunction f
where noBasicBlock(n, f)
select n, "CFG node in $@ does not belong to any basic block", f, f.getName()

View File

@@ -0,0 +1,19 @@
/**
* New-CFG version of NoSharedReachable.
*
* Checks that two annotations sharing a timestamp value are on
* mutually exclusive CFG paths (neither can reach the other).
*/
import python
import NewCfgImpl
private module Utils = EvalOrderCfgUtils<NewCfg>;
private import Utils
private import Utils::CfgTests
from TimerCfgNode a, TimerCfgNode b, int ts
where noSharedReachable(a, b, ts)
select a, "Shared timestamp $@ but this node reaches $@", a.getTimestampExpr(ts), ts.toString(), b,
b.getNode().toString()

View File

@@ -0,0 +1,20 @@
/**
* New-CFG version of StrictForward.
*
* Stronger version of NoBackwardFlow: for consecutive annotated nodes
* A -> B that both have a single timestamp (non-loop code) and B does
* NOT dominate A (forward edge), requires max(A) < min(B).
*/
import python
import NewCfgImpl
private module Utils = EvalOrderCfgUtils<NewCfg>;
private import Utils
private import Utils::CfgTests
from TimerCfgNode a, TimerCfgNode b, int maxA, int minB
where strictForward(a, b, maxA, minB)
select a, "Strict forward violation: $@ flows to $@", a.getTimestampExpr(maxA), "timestamp " + maxA,
b.getTimestampExpr(minB), "timestamp " + minB

View File

@@ -0,0 +1,37 @@
"""Comparisons and evaluation order.
Comparison operands are evaluated left-to-right, followed by the comparison
node itself. Annotations record the *run-time* evaluation order, so this file
self-validates under CPython (``python3 test_comparison.py``).
Python short-circuits *chained* comparisons (in ``a < b < c`` the operand ``c``
is skipped once ``a < b`` is false), whereas the control-flow graph
conservatively evaluates every operand. That divergence cannot be expressed in
a single annotation, so the affected CFG queries report it (captured in the
ConsecutiveTimestamps and NewCfgConsecutivePredecessorTimestamps .expected
files).
"""
from timer import test, dead
@test
def test_two(t):
# Both operands, then the comparison.
(1 @ t[0] < 0 @ t[1]) @ t[2]
@test
def test_three(t):
# Chained comparison, all comparisons hold: operands left-to-right, then
# the comparison. Run time and CFG agree.
(1 @ t[0] < 2 @ t[1] < 3 @ t[2]) @ t[3]
@test
def test_three_short_circuit(t):
# ``1 > 2`` is false, so at run time Python short-circuits and never
# evaluates ``3``; the comparison is reached at timestamp 2. The CFG still
# evaluates ``3`` (dead at run time), which is why the CFG queries report a
# gap around this comparison.
(1 @ t[0] > 2 @ t[1] < 3 @ t[dead(2)]) @ t[2]

View File

@@ -85,7 +85,7 @@ def test_nested_if_else(t):
else:
z = 2 @ t[dead(4)]
else:
z = 3 @ t[dead(4)]
z = 3 @ t[dead(3), dead(4)]
w = 0 @ t[5]

View File

@@ -0,0 +1,41 @@
/**
* Inline-expectations test for the store/load/delete/parameter
* classification predicates on the new-CFG facade.
*
* Each tag fires when the corresponding predicate (`isLoad`,
* `isStore`, `isDelete`, `isParameter`, `isAugLoad`, `isAugStore`)
* holds on the canonical CFG node wrapping a `Py::Name` with the
* given identifier. Subscript and attribute stores are not covered
* by these tags — only the `Name`-typed targets/loads they involve.
*/
import python
import semmle.python.controlflow.internal.Cfg as Cfg
import utils.test.InlineExpectationsTest
module StoreLoadTest implements TestSig {
string getARelevantTag() { result = ["load", "store", "delete", "param", "augload", "augstore"] }
predicate hasActualResult(Location location, string element, string tag, string value) {
exists(Cfg::NameNode n |
location = n.getLocation() and
element = n.toString() and
value = n.getId() and
(
n.isLoad() and not n.isAugLoad() and tag = "load"
or
n.isStore() and not n.isAugStore() and tag = "store"
or
n.isDelete() and tag = "delete"
or
n.isParameter() and tag = "param"
or
n.isAugLoad() and tag = "augload"
or
n.isAugStore() and tag = "augstore"
)
)
}
}
import MakeTest<StoreLoadTest>

View File

@@ -0,0 +1,56 @@
# Store/load/delete/parameter classification on the new-CFG facade.
#
# Each annotated location carries the (sorted, deduplicated) set of
# kinds the CFG facade reports there. Comparing against the legacy
# 'semmle.python.Flow' classification is done by the comparison query
# 'StoreLoadParity.ql' — annotations here are only the positive
# assertions for the new facade.
#
# Tags:
# load=<id> -- isLoad() fires on the Name
# store=<id> -- isStore() fires
# delete=<id> -- isDelete() fires
# param=<id> -- isParameter() fires
# augload=<id> -- isAugLoad() fires (the LHS of x += ... when read)
# augstore=<id> -- isAugStore() fires (the LHS of x += ... when written)
# --- plain load / store / delete ---
x = 1 # $ store=x
y = x + 1 # $ store=y load=x
print(y) # $ load=print load=y
del x # $ delete=x
# --- function definitions (parameters) ---
def f(a, b=2, *args, c, **kwargs): # $ store=f param=a param=b param=args param=c param=kwargs
return a + b + c # $ load=a load=b load=c
# --- augmented assignment splits one Name into load + store halves ---
def aug(): # $ store=aug
n = 0 # $ store=n
n += 1 # $ augload=n augstore=n
return n # $ load=n
# --- subscript / attribute stores ---
class C: # $ store=C
pass
def stores(obj, container, idx): # $ store=stores param=obj param=container param=idx
obj.attr = 1 # $ load=obj
container[idx] = 2 # $ load=container load=idx
return obj # $ load=obj
# --- tuple unpacking ---
def unpack(pair): # $ store=unpack param=pair
a, b = pair # $ store=a store=b load=pair
return a + b # $ load=a load=b