From 3402d0eaeb4d8c7fe5103ba1ba92fa294ee39776 Mon Sep 17 00:00:00 2001 From: Taus Date: Thu, 16 Apr 2026 14:38:34 +0000 Subject: [PATCH 01/44] Python: Add self-validating CFG tests These tests consist of various Python constructions (hopefully a somewhat comprehensive set) with specific timestamp annotations scattered throughout. When the tests are run using the Python 3 interpreter, these annotations are checked and compared to the "current timestamp" to see that they are in agreement. This is what makes the tests "self-validating". There are a few different kinds of annotations: the basic `t[4]` style (meaning this is executed at timestamp 4), the `t.dead[4]` variant (meaning this _would_ happen at timestamp 4, but it is in a dead branch), and `t.never` (meaning this is never executed at all). In addition to this, there is a query, MissingAnnotations, which checks whether we have applied these annotations maximally. Many expression nodes are not actually annotatable, so there is a sizeable list of excluded nodes for that query. --- .../MissingAnnotations.expected | 0 .../evaluation-order/MissingAnnotations.ql | 15 + .../evaluation-order/TimerUtils.qll | 297 ++++++++++++++++++ .../evaluation-order/test_assert_raise.py | 56 ++++ .../evaluation-order/test_async.py | 97 ++++++ .../evaluation-order/test_augassign.py | 53 ++++ .../evaluation-order/test_basic.py | 223 +++++++++++++ .../evaluation-order/test_boolean.py | 76 +++++ .../evaluation-order/test_classes.py | 74 +++++ .../evaluation-order/test_comprehensions.py | 46 +++ .../evaluation-order/test_conditional.py | 44 +++ .../evaluation-order/test_fstring.py | 34 ++ .../evaluation-order/test_functions.py | 85 +++++ .../ControlFlow/evaluation-order/test_if.py | 108 +++++++ .../evaluation-order/test_lambda.py | 46 +++ .../evaluation-order/test_loops.py | 146 +++++++++ .../evaluation-order/test_match.py | 173 ++++++++++ .../ControlFlow/evaluation-order/test_try.py | 182 +++++++++++ .../evaluation-order/test_unpacking.py | 48 +++ .../ControlFlow/evaluation-order/test_with.py | 58 ++++ .../evaluation-order/test_yield.py | 105 +++++++ .../ControlFlow/evaluation-order/timer.py | 185 +++++++++++ 22 files changed, 2151 insertions(+) create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/MissingAnnotations.expected create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/MissingAnnotations.ql create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/TimerUtils.qll create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/test_assert_raise.py create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/test_async.py create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/test_augassign.py create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/test_basic.py create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/test_boolean.py create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/test_classes.py create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/test_comprehensions.py create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/test_conditional.py create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/test_fstring.py create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/test_functions.py create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/test_if.py create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/test_lambda.py create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/test_loops.py create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/test_match.py create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/test_try.py create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/test_unpacking.py create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/test_with.py create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/test_yield.py create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/timer.py diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/MissingAnnotations.expected b/python/ql/test/library-tests/ControlFlow/evaluation-order/MissingAnnotations.expected new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/MissingAnnotations.ql b/python/ql/test/library-tests/ControlFlow/evaluation-order/MissingAnnotations.ql new file mode 100644 index 000000000000..51f324e9399c --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/MissingAnnotations.ql @@ -0,0 +1,15 @@ +/** + * Finds expressions in test functions that lack a timer annotation + * and are not part of the timer mechanism or otherwise excluded. + * An empty result means every annotatable expression is covered. + */ + +import python +import TimerUtils + +from TestFunction f, Expr e +where + e.getScope().getEnclosingScope*() = f and + not isTimerMechanism(e, f) and + not isUnannotatable(e) +select e, "Missing annotation in $@", f, f.getName() diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/TimerUtils.qll b/python/ql/test/library-tests/ControlFlow/evaluation-order/TimerUtils.qll new file mode 100644 index 000000000000..6ad4ef1ef19e --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/TimerUtils.qll @@ -0,0 +1,297 @@ +/** + * Utility library for identifying timer annotations in evaluation-order tests. + * + * Identifies `expr @ t[n]` (matmul), `t(expr, n)` (call), and + * `expr @ t.dead[n]` (dead-code) patterns, extracts timestamp values, + * and provides predicates for traversing consecutive annotated CFG nodes. + */ + +import python + +/** + * A function decorated with `@test` from the timer module. + * The first parameter is the timer object. + */ +class TestFunction extends Function { + TestFunction() { + this.getADecorator().(Name).getId() = "test" and + this.getPositionalParameterCount() >= 1 + } + + /** Gets the name of the timer parameter (first parameter). */ + string getTimerParamName() { result = this.getArgName(0) } +} + +/** Gets an IntegerLiteral from a timestamp expression (single int or tuple of ints). */ +private IntegerLiteral timestampLiteral(Expr timestamps) { + result = timestamps + or + result = timestamps.(Tuple).getAnElt() +} + +/** A timer annotation in the AST. */ +private newtype TTimerAnnotation = + /** `expr @ t[n]` or `expr @ t[n, m, ...]` */ + TMatmulAnnotation(TestFunction func, Expr annotated, Expr timestamps) { + exists(BinaryExpr be | + be.getOp() instanceof MatMult and + be.getRight().(Subscript).getObject().(Name).getId() = func.getTimerParamName() and + be.getScope().getEnclosingScope*() = func and + annotated = be.getLeft() and + timestamps = be.getRight().(Subscript).getIndex() + ) + } or + /** `t(expr, n)` */ + TCallAnnotation(TestFunction func, Expr annotated, Expr timestamps) { + exists(Call call | + call.getFunc().(Name).getId() = func.getTimerParamName() and + call.getScope().getEnclosingScope*() = func and + annotated = call.getArg(0) and + timestamps = call.getArg(1) + ) + } or + /** `expr @ t.dead[n]` — dead-code annotation */ + TDeadAnnotation(TestFunction func, Expr annotated, Expr timestamps) { + exists(BinaryExpr be | + be.getOp() instanceof MatMult and + be.getRight().(Subscript).getObject().(Attribute).getObject("dead").(Name).getId() = + func.getTimerParamName() and + be.getScope().getEnclosingScope*() = func and + annotated = be.getLeft() and + timestamps = be.getRight().(Subscript).getIndex() + ) + } or + /** `expr @ t.never` — annotation for code that should never be evaluated */ + TNeverAnnotation(TestFunction func, Expr annotated) { + exists(BinaryExpr be | + be.getOp() instanceof MatMult and + be.getRight().(Attribute).getObject("never").(Name).getId() = func.getTimerParamName() and + be.getScope().getEnclosingScope*() = func and + annotated = be.getLeft() + ) + } + +/** A timer annotation (wrapping the newtype for a clean API). */ +class TimerAnnotation extends TTimerAnnotation { + /** Gets a timestamp value from this annotation. */ + int getATimestamp() { exists(this.getTimestampExpr(result)) } + + /** Gets the source expression for timestamp value `ts`. */ + IntegerLiteral getTimestampExpr(int ts) { + result = timestampLiteral(this.getTimestampsExpr()) and + result.getValue() = ts + } + + /** Gets the raw timestamp expression (single int or tuple). */ + abstract Expr getTimestampsExpr(); + + /** Gets the test function this annotation belongs to. */ + abstract TestFunction getTestFunction(); + + /** Gets the annotated expression (the LHS of `@` or the first arg of `t(...)`). */ + abstract Expr getAnnotatedExpr(); + + /** Gets the enclosing annotation expression (the `BinaryExpr` or `Call`). */ + abstract Expr getExpr(); + + /** Holds if this is a dead-code annotation (`t.dead[n]`). */ + predicate isDead() { this instanceof DeadTimerAnnotation } + + /** Holds if this is a never-evaluated annotation (`t.never`). */ + predicate isNever() { this instanceof NeverTimerAnnotation } + + string toString() { result = this.getExpr().toString() } + + Location getLocation() { result = this.getExpr().getLocation() } +} + +/** A matmul-based timer annotation: `expr @ t[n]`. */ +class MatmulTimerAnnotation extends TMatmulAnnotation, TimerAnnotation { + TestFunction func; + Expr annotated; + Expr timestamps; + + MatmulTimerAnnotation() { this = TMatmulAnnotation(func, annotated, timestamps) } + + override Expr getTimestampsExpr() { result = timestamps } + + override TestFunction getTestFunction() { result = func } + + override Expr getAnnotatedExpr() { result = annotated } + + override BinaryExpr getExpr() { result.getLeft() = annotated } +} + +/** A call-based timer annotation: `t(expr, n)`. */ +class CallTimerAnnotation extends TCallAnnotation, TimerAnnotation { + TestFunction func; + Expr annotated; + Expr timestamps; + + CallTimerAnnotation() { this = TCallAnnotation(func, annotated, timestamps) } + + override Expr getTimestampsExpr() { result = timestamps } + + override TestFunction getTestFunction() { result = func } + + override Expr getAnnotatedExpr() { result = annotated } + + override Call getExpr() { result.getArg(0) = annotated } +} + +/** A dead-code timer annotation: `expr @ t.dead[n]`. */ +class DeadTimerAnnotation extends TDeadAnnotation, TimerAnnotation { + TestFunction func; + Expr annotated; + Expr timestamps; + + DeadTimerAnnotation() { this = TDeadAnnotation(func, annotated, timestamps) } + + override Expr getTimestampsExpr() { result = timestamps } + + override TestFunction getTestFunction() { result = func } + + override Expr getAnnotatedExpr() { result = annotated } + + override BinaryExpr getExpr() { result.getLeft() = annotated } +} + +/** A never-evaluated annotation: `expr @ t.never`. */ +class NeverTimerAnnotation extends TNeverAnnotation, TimerAnnotation { + TestFunction func; + Expr annotated; + + NeverTimerAnnotation() { this = TNeverAnnotation(func, annotated) } + + override Expr getTimestampsExpr() { none() } + + override TestFunction getTestFunction() { result = func } + + override Expr getAnnotatedExpr() { result = annotated } + + override BinaryExpr getExpr() { result.getLeft() = annotated } +} + +/** + * A CFG node corresponding to a timer annotation. + */ +class TimerCfgNode extends ControlFlowNode { + private TimerAnnotation annot; + + TimerCfgNode() { annot.getExpr() = this.getNode() } + + /** Gets a timestamp value from this annotation. */ + int getATimestamp() { result = annot.getATimestamp() } + + /** Gets the source expression for timestamp value `ts`. */ + IntegerLiteral getTimestampExpr(int ts) { result = annot.getTimestampExpr(ts) } + + /** Gets the test function this annotation belongs to. */ + TestFunction getTestFunction() { result = annot.getTestFunction() } + + /** Holds if this is a dead-code annotation. */ + predicate isDead() { annot.isDead() } + + /** Holds if this is a never-evaluated annotation. */ + predicate isNever() { annot.isNever() } +} + +/** + * Holds if `next` is the next timer annotation reachable from `n` via + * CFG successors (both normal and exceptional), skipping non-annotated + * intermediaries within the same scope. + */ +predicate nextTimerAnnotation(ControlFlowNode n, TimerCfgNode next) { + next = n.getASuccessor() and + next.getScope() = n.getScope() + or + exists(ControlFlowNode mid | + mid = n.getASuccessor() and + not mid instanceof TimerCfgNode and + mid.getScope() = n.getScope() and + nextTimerAnnotation(mid, next) + ) +} + +/** + * Holds if `e` is part of the timer mechanism: a top-level timer + * expression or a (transitive) sub-expression of one. + */ +predicate isTimerMechanism(Expr e, TestFunction f) { + exists(TimerAnnotation a | + a.getTestFunction() = f and + e = a.getExpr().getASubExpression*() + ) +} + +/** + * Holds if expression `e` cannot be annotated due to Python syntax + * limitations (e.g., it is a definition target, a pattern, or part + * of a decorator application). + */ +predicate isUnannotatable(Expr e) { + // Function/class definitions + e instanceof FunctionExpr + or + e instanceof ClassExpr + or + // Docstrings are string literals used as expression statements + e instanceof StringLiteral and e.getParent() instanceof ExprStmt + or + // Function parameters are bound by the call, not evaluated in the body + e instanceof Parameter + or + // Name nodes that are definitions or deletions (assignment targets, def/class + // name bindings, augmented assignment targets, for-loop targets, del targets) + e.(Name).isDefinition() + or + e.(Name).isDeletion() + or + // Tuple/List/Starred nodes in assignment or for-loop targets are + // structural unpack patterns, not evaluations + (e instanceof Tuple or e instanceof List or e instanceof Starred) and + e = any(AssignStmt a).getATarget().getASubExpression*() + or + (e instanceof Tuple or e instanceof List or e instanceof Starred) and + e = any(For f).getTarget().getASubExpression*() + or + // The decorator call node wrapping a function/class definition, + // and its sub-expressions (the decorator name itself) + e = any(FunctionExpr func).getADecoratorCall().getASubExpression*() + or + e = any(ClassExpr cls).getADecoratorCall().getASubExpression*() + or + // Augmented assignment (x += e): the implicit BinaryExpr for the operation + e = any(AugAssign aug).getOperation() + or + // with-statement `as` variables are bindings + (e instanceof Name or e instanceof Tuple or e instanceof List) and + e = any(With w).getOptionalVars().getASubExpression*() + or + // except-clause exception type and `as` variable are part of except syntax + exists(ExceptStmt ex | e = ex.getType() or e = ex.getName()) + or + // match/case pattern expressions are part of pattern syntax + e.getParent+() instanceof Pattern + or + // Subscript/Attribute nodes on the LHS of an assignment are store + // operations, not value expressions (including nested ones like d["a"][1]) + (e instanceof Subscript or e instanceof Attribute) and + e = any(AssignStmt a).getATarget().getASubExpression*() + or + // Match/case guard nodes are part of case syntax + e instanceof Guard + or + // Yield/YieldFrom in statement position — the return value is + // discarded and cannot be meaningfully annotated + (e instanceof Yield or e instanceof YieldFrom) and + e.getParent() instanceof ExprStmt + or + // Synthetic nodes inside desugared comprehensions + e.getScope() = any(Comp c).getFunction() and + ( + e.(Name).getId() = ".0" + or + e instanceof Tuple and e.getParent() instanceof Yield + ) +} diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/test_assert_raise.py b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_assert_raise.py new file mode 100644 index 000000000000..9958d922ec8f --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_assert_raise.py @@ -0,0 +1,56 @@ +"""Assert and raise statement evaluation order.""" + +from timer import test + + +@test +def test_assert_true(t): + x = True @ t[0] + assert x @ t[1] + y = 1 @ t[2] + + +@test +def test_assert_true_with_message(t): + x = True @ t[0] + assert x @ t[1], "msg" @ t.dead[2] + y = 1 @ t[2] + + +@test +def test_assert_false_caught(t): + try: + x = False @ t[0] + assert x @ t[1], "fail" @ t[2] + except AssertionError: + y = 1 @ t[3] + + +@test +def test_raise_caught(t): + try: + x = 1 @ t[0] + raise ((ValueError @ t[1])("test" @ t[2]) @ t[3]) + except ValueError: + y = 2 @ t[4] + + +@test +def test_raise_from_caught(t): + try: + x = 1 @ t[0] + raise ((ValueError @ t[1])("test" @ t[2]) @ t[3]) from ((RuntimeError @ t[4])("cause" @ t[5]) @ t[6]) + except ValueError: + y = 2 @ t[7] + + +@test +def test_bare_reraise(t): + try: + try: + raise ((ValueError @ t[0])("test" @ t[1]) @ t[2]) + except ValueError: + x = 1 @ t[3] + raise + except ValueError: + y = 2 @ t[4] diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/test_async.py b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_async.py new file mode 100644 index 000000000000..0c9b08e3e9eb --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_async.py @@ -0,0 +1,97 @@ +"""Async/await evaluation order tests. + +Coroutine bodies are lazy — like generators, the body runs only when +awaited (or driven by the event loop). asyncio.run() drives the +coroutine to completion synchronously from the caller's perspective. +""" + +import asyncio +from contextlib import asynccontextmanager +from timer import test + + +@test +def test_simple_async(t): + """Simple async function: body runs inside asyncio.run().""" + async def coro(): + x = 1 @ t[4] + return x @ t[5] + + result = ((asyncio @ t[0]).run @ t[1])((coro @ t[2])() @ t[3]) @ t[6] + + +@test +def test_await_expression(t): + """await suspends the caller until the inner coroutine completes.""" + async def helper(): + return 1 @ t[4] + + async def main(): + x = await helper() @ t[5] + return x @ t[6] + + result = ((asyncio @ t[0]).run @ t[1])((main @ t[2])() @ t[3]) @ t[7] + + +@test +def test_async_for(t): + """async for iterates an async generator.""" + async def agen(): + yield 1 @ t[5] + yield 2 @ t[7] + + async def main(): + async for val in agen() @ t[4]: + val @ t[6, 8] + + ((asyncio @ t[0]).run @ t[1])((main @ t[2])() @ t[3]) @ t[9] + + +@test +def test_async_with(t): + """async with enters/exits an async context manager.""" + @asynccontextmanager + async def ctx(): + yield 1 @ t[5] + + async def main(): + async with ctx() @ t[4] as val: + val @ t[6] + + ((asyncio @ t[0]).run @ t[1])((main @ t[2])() @ t[3]) @ t[7] + + +@test +def test_multiple_awaits(t): + """Sequential awaits in one coroutine.""" + async def task_a(): + return 10 @ t[4] + + async def task_b(): + return 20 @ t[6] + + async def main(): + a = await task_a() @ t[5] + b = await task_b() @ t[7] + return (a @ t[8] + b @ t[9]) @ t[10] + + result = ((asyncio @ t[0]).run @ t[1])((main @ t[2])() @ t[3]) @ t[11] + + +@test +def test_gather(t): + """asyncio.gather schedules coroutines as concurrent tasks.""" + async def task_a(): + return 1 @ t[6] + + async def task_b(): + return 2 @ t[7] + + async def main(): + results = await asyncio.gather( + task_a() @ t[4], + task_b() @ t[5], + ) @ t[8] + return results @ t[9] + + result = ((asyncio @ t[0]).run @ t[1])((main @ t[2])() @ t[3]) @ t[10] diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/test_augassign.py b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_augassign.py new file mode 100644 index 000000000000..2f1d5eb5c3e6 --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_augassign.py @@ -0,0 +1,53 @@ +"""Augmented assignment evaluation order.""" + +from timer import test + + +@test +def test_plus_equals(t): + x = 1 @ t[0] + x += 2 @ t[1] + y = x @ t[2] + + +@test +def test_sub_mul_div(t): + x = 20 @ t[0] + x -= 5 @ t[1] + x *= 2 @ t[2] + x /= 6 @ t[3] + x = 17 @ t[4] + x //= 3 @ t[5] + x %= 3 @ t[6] + y = x @ t[7] + + +@test +def test_power_equals(t): + x = 2 @ t[0] + x **= 3 @ t[1] + y = x @ t[2] + + +@test +def test_bitwise_equals(t): + x = 0b1111 @ t[0] + x &= 0b1010 @ t[1] + x |= 0b0101 @ t[2] + x ^= 0b0011 @ t[3] + y = x @ t[4] + + +@test +def test_shift_equals(t): + x = 1 @ t[0] + x <<= 4 @ t[1] + x >>= 2 @ t[2] + y = x @ t[3] + + +@test +def test_list_extend(t): + x = [1 @ t[0], 2 @ t[1]] @ t[2] + x += [3 @ t[3], 4 @ t[4]] @ t[5] + y = x @ t[6] diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/test_basic.py b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_basic.py new file mode 100644 index 000000000000..f2ece3a0820d --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_basic.py @@ -0,0 +1,223 @@ +"""Basic expression evaluation order. + +These tests verify that sub-expressions within a single expression +are evaluated in the expected order (typically left to right for +operands of binary operators, elements of collection literals, etc.) + +Every evaluated expression has a timestamp annotation, except the +timer mechanism itself (t[n], t.dead[n]). +""" + +from timer import test + + +@test +def test_sequential_statements(t): + """Statements execute top to bottom.""" + x = 1 @ t[0] + y = 2 @ t[1] + z = 3 @ t[2] + + +@test +def test_binary_add(t): + """In a + b, left operand evaluates before right.""" + x = (1 @ t[0] + 2 @ t[1]) @ t[2] + + +@test +def test_binary_subtract(t): + """In a - b, left operand evaluates before right.""" + x = (10 @ t[0] - 3 @ t[1]) @ t[2] + + +@test +def test_binary_multiply(t): + """In a * b, left operand evaluates before right.""" + x = ((3 @ t[0]) * (4 @ t[1])) @ t[2] + + +@test +def test_nested_binary(t): + """Sub-expressions evaluate before their containing expression.""" + x = ((1 @ t[0] + 2 @ t[1]) @ t[2] + (3 @ t[3] + 4 @ t[4]) @ t[5]) @ t[6] + + +@test +def test_chained_add(t): + """a + b + c is (a + b) + c: left to right.""" + x = ((1 @ t[0] + 2 @ t[1]) @ t[2] + 3 @ t[3]) @ t[4] + + +@test +def test_mixed_precedence(t): + """In a + b * c, all operands still evaluate left to right.""" + x = (1 @ t[0] + ((2 @ t[1]) * (3 @ t[2])) @ t[3]) @ t[4] + + +@test +def test_string_concat(t): + """String concatenation operands: left to right.""" + x = (("hello" @ t[0] + " " @ t[1]) @ t[2] + "world" @ t[3]) @ t[4] + + +@test +def test_comparison(t): + """In a < b, left operand evaluates before right.""" + x = (1 @ t[0] < 2 @ t[1]) @ t[2] + + +@test +def test_chained_comparison(t): + """Chained a < b < c: all evaluated left to right (b only once).""" + x = (1 @ t[0] < 2 @ t[1] < 3 @ t[2]) @ t[3] + + +@test +def test_list_elements(t): + """List elements evaluate left to right.""" + x = [1 @ t[0], 2 @ t[1], 3 @ t[2]] @ t[3] + + +@test +def test_dict_entries(t): + """Dict: key before value, entries left to right.""" + d = {1 @ t[0]: "a" @ t[1], 2 @ t[2]: "b" @ t[3]} @ t[4] + + +@test +def test_tuple_elements(t): + """Tuple elements evaluate left to right.""" + x = (1 @ t[0], 2 @ t[1], 3 @ t[2]) @ t[3] + + +@test +def test_set_elements(t): + """Set elements evaluate left to right.""" + x = {1 @ t[0], 2 @ t[1], 3 @ t[2]} @ t[3] + + +@test +def test_subscript(t): + """In obj[idx], object evaluates before index.""" + x = ([10 @ t[0], 20 @ t[1], 30 @ t[2]] @ t[3])[1 @ t[4]] @ t[5] + + +@test +def test_slice(t): + """Slice parameters: object, then start, then stop.""" + x = ([1 @ t[0], 2 @ t[1], 3 @ t[2], 4 @ t[3], 5 @ t[4]] @ t[5])[1 @ t[6]:3 @ t[7]] @ t[8] + + +@test +def test_method_call(t): + """Object evaluated, then attribute lookup, then arguments left to right, then call.""" + x = (("hello world" @ t[0]).replace @ t[1])("world" @ t[2], "there" @ t[3]) @ t[4] + + +@test +def test_method_chaining(t): + """Chained method calls: left to right.""" + x = ((((" hello " @ t[0]).strip @ t[1])() @ t[2]).upper @ t[3])() @ t[4] + + +@test +def test_unary_not(t): + """Unary not: operand evaluated first.""" + x = (not True @ t[0]) @ t[1] + + +@test +def test_unary_neg(t): + """Unary negation: operand evaluated first.""" + x = (-(3 @ t[0])) @ t[1] + + +@test +def test_multiple_assignment(t): + """RHS evaluated once in x = y = expr.""" + x = y = (1 @ t[0] + 2 @ t[1]) @ t[2] + + +@test +def test_callable_syntax(t): + """t(value, n) is equivalent to value @ t[n].""" + x = (1 @ t[0] + 2 @ t[1]) @ t[2] + y = (x @ t[3] * 3 @ t[4]) @ t[5] + + +@test +def test_subscript_assign(t): + """In obj[idx] = val, value is evaluated before target sub-expressions.""" + lst = [0 @ t[0], 0 @ t[1], 0 @ t[2]] @ t[3] + (lst @ t[5])[1 @ t[6]] = 42 @ t[4] + x = lst @ t[7] + + +@test +def test_attribute_assign(t): + """In obj.attr = val, value is evaluated before the object.""" + class Obj: + pass + o = (Obj @ t[0])() @ t[1] + (o @ t[3]).x = 42 @ t[2] + y = (o @ t[4]).x @ t[5] + + +@test +def test_nested_subscript_assign(t): + """Nested subscript assignment: val, then outer obj, then keys.""" + d = {"a" @ t[0]: [0 @ t[1], 0 @ t[2]] @ t[3]} @ t[4] + (d @ t[6])["a" @ t[7]][1 @ t[8]] = 99 @ t[5] + x = d @ t[9] + + +@test +def test_unreachable_after_return(t): + """Code after return has no CFG node.""" + def f(): + x = 1 @ t[1] + return x @ t[2] + y = 2 @ t.never + result = (f @ t[0])() @ t[3] + + +@test +def test_none_literal(t): + """None is a name constant.""" + x = None @ t[0] + y = (x @ t[1] is None @ t[2]) @ t[3] + + +@test +def test_delete(t): + """del statement removes a variable binding.""" + x = 1 @ t[0] + del x + y = 2 @ t[1] + + +@test +def test_global(t): + """global statement allows writing to module-level variable.""" + global _test_global_var + _test_global_var = 1 @ t[0] + x = _test_global_var @ t[1] + + +@test +def test_nonlocal(t): + """nonlocal statement allows inner function to rebind outer variable.""" + x = 0 @ t[0] + def inner(): + nonlocal x + x = 1 @ t[2] + (inner @ t[1])() @ t[3] + y = x @ t[4] + + +@test +def test_walrus(t): + """Walrus operator := evaluates the RHS and binds it.""" + if (y := 1 @ t[0]) @ t[1]: + z = y @ t[2] diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/test_boolean.py b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_boolean.py new file mode 100644 index 000000000000..d8183cb64842 --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_boolean.py @@ -0,0 +1,76 @@ +"""Short-circuit boolean operators and evaluation order.""" + +from timer import test + + +@test +def test_and_both_sides(t): + # True and X — both operands evaluated, result is X + x = (True @ t[0] and 42 @ t[1]) @ t[2] + + +@test +def test_and_short_circuit(t): + # False and ... — right side never evaluated + x = (False @ t[0] and True @ t.dead[1]) @ t[1] + + +@test +def test_or_short_circuit(t): + # True or ... — right side never evaluated + x = (True @ t[0] or False @ t.dead[1]) @ t[1] + + +@test +def test_or_both_sides(t): + # False or X — both operands evaluated, result is X + x = (False @ t[0] or 42 @ t[1]) @ t[2] + + +@test +def test_not(t): + # not evaluates its operand, then negates + x = (not True @ t[0]) @ t[1] + y = (not False @ t[2]) @ t[3] + + +@test +def test_chained_and(t): + # 1 and 2 and 3 — all truthy, all evaluated left-to-right + x = (1 @ t[0] and 2 @ t[1] and 3 @ t[2]) @ t[3] + + +@test +def test_chained_or(t): + # 0 or "" or 42 — first two falsy, all evaluated until truthy found + x = (0 @ t[0] or "" @ t[1] or 42 @ t[2]) @ t[3] + + +@test +def test_mixed_and_or(t): + # True and False or 42 => (True and False) or 42 => False or 42 => 42 + x = ((True @ t[0] and False @ t[1]) @ t[2] or 42 @ t[3]) @ t[4] + + +@test +def test_and_side_effects(t): + # Both functions called when left side is truthy + def f(): + return 10 @ t[1] + + def g(): + return 20 @ t[4] + + x = ((f @ t[0])() @ t[2] and (g @ t[3])() @ t[5]) @ t[6] + + +@test +def test_or_side_effects(t): + # Both functions called when left side is falsy + def f(): + return 0 @ t[1] + + def g(): + return 20 @ t[4] + + x = ((f @ t[0])() @ t[2] or (g @ t[3])() @ t[5]) @ t[6] diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/test_classes.py b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_classes.py new file mode 100644 index 000000000000..92313b5073c3 --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_classes.py @@ -0,0 +1,74 @@ +"""Class definitions — evaluation order.""" + +from timer import test + + +@test +def test_simple_class(t): + """Simple class definition and instantiation.""" + class Foo: + pass + obj = (Foo @ t[0])() @ t[1] + + +@test +def test_class_with_bases(t): + """Base class expressions evaluated at class definition time.""" + class Base: + pass + class Derived(Base @ t[0]): + pass + obj = (Derived @ t[1])() @ t[2] + + +@test +def test_class_with_methods(t): + """Object evaluated before method is called.""" + class Foo: + def greet(self, name): + return ("hello " @ t[5] + name @ t[6]) @ t[7] + obj = (Foo @ t[0])() @ t[1] + msg = ((obj @ t[2]).greet @ t[3])("world" @ t[4]) @ t[8] + + +@test +def test_class_instantiation(t): + """Arguments to __init__ evaluate before instantiation completes.""" + class Foo: + def __init__(self, x): + (self @ t[3]).x = x @ t[2] + obj = (Foo @ t[0])(42 @ t[1]) @ t[4] + val = (obj @ t[5]).x @ t[6] + + +@test +def test_method_call(t): + """Method arguments evaluate left-to-right before the call.""" + class Calculator: + def __init__(self, value): + (self @ t[3]).value = value @ t[2] + def add(self, x): + return ((self @ t[8]).value @ t[9] + x @ t[10]) @ t[11] + calc = (Calculator @ t[0])(10 @ t[1]) @ t[4] + result = ((calc @ t[5]).add @ t[6])(5 @ t[7]) @ t[12] + + +@test +def test_class_level_attribute(t): + """Multiple attribute accesses in a single expression.""" + class Config: + debug = True @ t[0] + version = 1 @ t[1] + x = ((Config @ t[2]).debug @ t[3], (Config @ t[4]).version @ t[5]) @ t[6] + + +@test +def test_class_decorator(t): + """Decorator expression evaluated, class defined, then decorator called.""" + def add_marker(cls): + (cls @ t[2]).marked = True @ t[1] + return cls @ t[3] + @(add_marker @ t[0]) + class Foo: + pass + result = (Foo @ t[4]).marked @ t[5] diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/test_comprehensions.py b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_comprehensions.py new file mode 100644 index 000000000000..8ce8ca6e4c46 --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_comprehensions.py @@ -0,0 +1,46 @@ +"""Evaluation order tests for comprehensions and generator expressions.""" + +from timer import test + + +@test +def test_list_comprehension(t): + items = [1 @ t[0], 2 @ t[1], 3 @ t[2]] @ t[3] + result = [x @ t[5, 6, 7] for x in items @ t[4]] @ t[8] + + +@test +def test_filtered_comprehension(t): + items = [1 @ t[0], 2 @ t[1], 3 @ t[2], 4 @ t[3]] @ t[4] + result = [x @ t[14, 23] for x in items @ t[5] if (x @ t[6, 10, 15, 19] % 2 @ t[7, 11, 16, 20] == 0 @ t[8, 12, 17, 21]) @ t[9, 13, 18, 22]] @ t[24] + + +@test +def test_dict_comprehension(t): + items = [("a" @ t[0], 1 @ t[1]) @ t[2], ("b" @ t[3], 2 @ t[4]) @ t[5]] @ t[6] + result = {k @ t[8, 10]: v @ t[9, 11] for k, v in items @ t[7]} @ t[12] + + +@test +def test_set_comprehension(t): + items = [1 @ t[0], 2 @ t[1], 3 @ t[2]] @ t[3] + result = {x @ t[5, 6, 7] for x in items @ t[4]} @ t[8] + + +@test +def test_generator_expression(t): + items = [1 @ t[0], 2 @ t[1], 3 @ t[2]] @ t[3] + gen = (x @ t[8, 9, 10] for x in items @ t[4]) @ t[5] + result = (list @ t[6])(gen @ t[7]) @ t[11] + + +@test +def test_nested_comprehension(t): + matrix = [[1 @ t[0], 2 @ t[1]] @ t[2], [3 @ t[3], 4 @ t[4]] @ t[5]] @ t[6] + result = [x @ t[9, 10, 12, 13] for row in matrix @ t[7] for x in row @ t[8, 11]] @ t[14] + + +@test +def test_comprehension_with_call(t): + items = [1 @ t[0], 2 @ t[1], 3 @ t[2]] @ t[3] + result = [(str @ t[5, 8, 11])(x @ t[6, 9, 12]) @ t[7, 10, 13] for x in items @ t[4]] @ t[14] diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/test_conditional.py b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_conditional.py new file mode 100644 index 000000000000..2c543e913e4d --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_conditional.py @@ -0,0 +1,44 @@ +"""Ternary conditional expressions and evaluation order.""" + +from timer import test + + +@test +def test_ternary_true(t): + # Condition is True — consequent evaluated, alternative skipped + x = (1 @ t[1] if True @ t[0] else 2 @ t.dead[1]) @ t[2] + + +@test +def test_ternary_false(t): + # Condition is False — alternative evaluated, consequent skipped + x = (1 @ t.dead[1] if False @ t[0] else 2 @ t[1]) @ t[2] + + +@test +def test_ternary_nested(t): + # Nested: outer condition True, inner condition True + # ((10 if C1 else 20) if C2 else 30) — C2 first, then C1, then 10 + x = ((10 @ t[2] if True @ t[1] else 20 @ t.dead[2]) @ t[3] if True @ t[0] else 30 @ t.dead[1]) @ t[4] + + +@test +def test_ternary_assignment(t): + # Ternary result assigned, then used in later expression + value = (100 @ t[1] if True @ t[0] else 200 @ t.dead[1]) @ t[2] + result = (value @ t[3] + 1 @ t[4]) @ t[5] + + +@test +def test_ternary_complex_expressions(t): + # Complex sub-expressions in condition and consequent + x = ((1 @ t[3] + 2 @ t[4]) @ t[5] if (3 @ t[0] > 2 @ t[1]) @ t[2] else (4 @ t.dead[3] + 5 @ t.dead[4]) @ t.dead[5]) @ t[6] + + +@test +def test_ternary_as_argument(t): + # Ternary used as a function argument + def f(a): + return a @ t[4] + + result = (f @ t[0])((1 @ t[2] if True @ t[1] else 2 @ t.dead[2]) @ t[3]) @ t[5] diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/test_fstring.py b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_fstring.py new file mode 100644 index 000000000000..2dd36f6ef36a --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_fstring.py @@ -0,0 +1,34 @@ +"""F-string evaluation order.""" + +from timer import test + + +@test +def test_simple_fstring(t): + name = "world" @ t[0] + s = f"hello {name @ t[1]}" @ t[2] + + +@test +def test_multi_expr_fstring(t): + a = "hello" @ t[0] + b = "world" @ t[1] + s = f"{a @ t[2]} {b @ t[3]}" @ t[4] + + +@test +def test_nested_fstring(t): + inner = "world" @ t[0] + s = f"hello {f'dear {inner @ t[1]}' @ t[2]}" @ t[3] + + +@test +def test_format_spec(t): + x = 3.14159 @ t[0] + s = f"{x @ t[1]:.2f}" @ t[2] + + +@test +def test_method_in_fstring(t): + name = "world" @ t[0] + s = f"hello {((name @ t[1]).upper @ t[2])() @ t[3]}" @ t[4] diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/test_functions.py b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_functions.py new file mode 100644 index 000000000000..e19b944c4cef --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_functions.py @@ -0,0 +1,85 @@ +"""Function calls and definitions — evaluation order.""" + +from timer import test + + +@test +def test_argument_order(t): + """Arguments evaluate left-to-right before the call.""" + def add(a, b): + return (a @ t[3] + b @ t[4]) @ t[5] + result = (add @ t[0])(1 @ t[1], 2 @ t[2]) @ t[6] + + +@test +def test_multiple_arguments(t): + """All arguments left-to-right, then the call.""" + def f(a, b, c): + return ((a @ t[4] + b @ t[5]) @ t[6] + c @ t[7]) @ t[8] + result = (f @ t[0])(1 @ t[1], 2 @ t[2], 3 @ t[3]) @ t[9] + + +@test +def test_default_arguments(t): + """Default expressions are evaluated at definition time.""" + val = 5 @ t[0] + def f(a, b=val @ t[1]): + return (a @ t[4] + b @ t[5]) @ t[6] + result = (f @ t[2])(10 @ t[3]) @ t[7] + + +@test +def test_args_kwargs(t): + """*args and **kwargs — expressions evaluated before the call.""" + def f(*args, **kwargs): + return ((sum @ t[9])(args @ t[10]) @ t[11] + (sum @ t[12])(((kwargs @ t[13]).values @ t[14])() @ t[15]) @ t[16]) @ t[17] + args = [1 @ t[0], 2 @ t[1]] @ t[2] + kwargs = {"c" @ t[3]: 3 @ t[4]} @ t[5] + result = (f @ t[6])(*args @ t[7], **kwargs @ t[8]) @ t[18] + + +@test +def test_nested_calls(t): + """Inner call completes before becoming an argument to outer call.""" + def f(x): + return (x @ t[7] + 1 @ t[8]) @ t[9] + def g(x): + return (x @ t[3] * 2 @ t[4]) @ t[5] + result = (f @ t[0])((g @ t[1])(1 @ t[2]) @ t[6]) @ t[10] + + +@test +def test_function_as_argument(t): + """Function object is just another argument, evaluated left-to-right.""" + def apply(fn, x): + return (fn @ t[3])(x @ t[4]) @ t[8] + def double(x): + return (x @ t[5] * 2 @ t[6]) @ t[7] + result = (apply @ t[0])(double @ t[1], 5 @ t[2]) @ t[9] + + +@test +def test_decorator(t): + """Decorator: expression evaluated, function defined, decorator called.""" + def my_decorator(fn): + return fn @ t[1] + @(my_decorator @ t[0]) + def f(): + return 42 @ t[3] + result = (f @ t[2])() @ t[4] + + +@test +def test_keyword_arguments(t): + """Keyword argument values evaluate left-to-right.""" + def f(a, b): + return (a @ t[3] + b @ t[4]) @ t[5] + result = (f @ t[0])(a=1 @ t[1], b=2 @ t[2]) @ t[6] + + +@test +def test_return_value(t): + """The return value is just the result of the call expression.""" + def f(x): + return (x @ t[2] * 2 @ t[3]) @ t[4] + result = (f @ t[0])(3 @ t[1]) @ t[5] diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/test_if.py b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_if.py new file mode 100644 index 000000000000..3190e94c6eba --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_if.py @@ -0,0 +1,108 @@ +"""If/elif/else control flow evaluation order.""" + +from timer import test + + +@test +def test_if_true(t): + x = True @ t[0] + if x @ t[1]: + y = 1 @ t[2] + z = 0 @ t[3] + + +@test +def test_if_false(t): + x = False @ t[0] + if x @ t[1]: + y = 1 @ t.dead[2] + z = 0 @ t[2] + + +@test +def test_if_else_true(t): + x = True @ t[0] + if x @ t[1]: + y = 1 @ t[2] + else: + y = 2 @ t.dead[2] + z = 0 @ t[3] + + +@test +def test_if_else_false(t): + x = False @ t[0] + if x @ t[1]: + y = 1 @ t.dead[2] + else: + y = 2 @ t[2] + z = 0 @ t[3] + + +@test +def test_if_elif_else_first(t): + x = 1 @ t[0] + if (x @ t[1] == 1 @ t[2]) @ t[3]: + y = "first" @ t[4] + elif (x @ t.dead[4] == 2 @ t.dead[5]) @ t.dead[6]: + y = "second" @ t.dead[4] + else: + y = "third" @ t.dead[4] + z = 0 @ t[5] + + +@test +def test_if_elif_else_second(t): + x = 2 @ t[0] + if (x @ t[1] == 1 @ t[2]) @ t[3]: + y = "first" @ t.dead[7] + elif (x @ t[4] == 2 @ t[5]) @ t[6]: + y = "second" @ t[7] + else: + y = "third" @ t.dead[7] + z = 0 @ t[8] + + +@test +def test_if_elif_else_third(t): + x = 3 @ t[0] + if (x @ t[1] == 1 @ t[2]) @ t[3]: + y = "first" @ t.dead[7] + elif (x @ t[4] == 2 @ t[5]) @ t[6]: + y = "second" @ t.dead[7] + else: + y = "third" @ t[7] + z = 0 @ t[8] + + +@test +def test_nested_if_else(t): + x = True @ t[0] + y = True @ t[1] + if x @ t[2]: + if y @ t[3]: + z = 1 @ t[4] + else: + z = 2 @ t.dead[4] + else: + z = 3 @ t.dead[4] + w = 0 @ t[5] + + +@test +def test_if_compound_condition(t): + x = True @ t[0] + y = False @ t[1] + if (x @ t[2] and y @ t[3]) @ t[4]: + z = 1 @ t.dead[5] + else: + z = 2 @ t[5] + w = 0 @ t[6] + + +@test +def test_if_pass(t): + x = True @ t[0] + if x @ t[1]: + pass + z = 0 @ t[2] diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/test_lambda.py b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_lambda.py new file mode 100644 index 000000000000..c60cbb5b3172 --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_lambda.py @@ -0,0 +1,46 @@ +"""Lambda expressions — evaluation order.""" + +from timer import test + + +@test +def test_simple_lambda(t): + """Lambda creates a function object in one step.""" + f = (lambda x: (x @ t[3] + 1 @ t[4]) @ t[5]) @ t[0] + result = (f @ t[1])(10 @ t[2]) @ t[6] + + +@test +def test_lambda_multiple_args(t): + """Lambda call: arguments evaluate left to right.""" + f = (lambda a, b, c: ((a @ t[5] + b @ t[6]) @ t[7] + c @ t[8]) @ t[9]) @ t[0] + result = (f @ t[1])(1 @ t[2], 2 @ t[3], 3 @ t[4]) @ t[10] + + +@test +def test_lambda_default(t): + """Default argument evaluated at lambda creation time.""" + val = 5 @ t[0] + f = (lambda x, y=val @ t[1]: (x @ t[5] + y @ t[6]) @ t[7]) @ t[2] + result = (f @ t[3])(10 @ t[4]) @ t[8] + + +@test +def test_lambda_map(t): + """Lambda body runs once per element when consumed by list(map(...)).""" + f = (lambda x: (x @ t[9, 12, 15] * 2 @ t[10, 13, 16]) @ t[11, 14, 17]) @ t[0] + result = (list @ t[1])((map @ t[2])(f @ t[3], [1 @ t[4], 2 @ t[5], 3 @ t[6]] @ t[7]) @ t[8]) @ t[18] + + +@test +def test_immediately_invoked(t): + """Arguments evaluated, then immediately-invoked lambda called.""" + result = ((lambda x: (x @ t[2] + 1 @ t[3]) @ t[4]) @ t[0])(10 @ t[1]) @ t[5] + + +@test +def test_lambda_closure(t): + """Lambda captures enclosing scope; body runs at call time.""" + x = 10 @ t[0] + f = (lambda: x @ t[3]) @ t[1] + result = (f @ t[2])() @ t[4] diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/test_loops.py b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_loops.py new file mode 100644 index 000000000000..e81c31acde5c --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_loops.py @@ -0,0 +1,146 @@ +"""Loop control flow evaluation order tests.""" + +from timer import test + + +# 1. Simple while loop (fixed iterations) +@test +def test_while_loop(t): + i = 0 @ t[0] + while (i @ t[1, 7, 13, 19] < 3 @ t[2, 8, 14, 20]) @ t[3, 9, 15, 21]: # 4 checks: 3 true + 1 false + i = (i @ t[4, 10, 16] + 1 @ t[5, 11, 17]) @ t[6, 12, 18] + done = True @ t[22] + + +# 2. While loop with break +@test +def test_while_break(t): + i = 0 @ t[0] + while (i @ t[1, 10, 19] < 5 @ t[2, 11, 20]) @ t[3, 12, 21]: + if (i @ t[4, 13, 22] == 2 @ t[5, 14, 23]) @ t[6, 15, 24]: + break + i = (i @ t[7, 16] + 1 @ t[8, 17]) @ t[9, 18] + done = True @ t[25] + + +# 3. While loop with continue +@test +def test_while_continue(t): + i = 0 @ t[0] + total = 0 @ t[1] + while (i @ t[2, 14, 23, 35] < 3 @ t[3, 15, 24, 36]) @ t[4, 16, 25, 37]: + i = (i @ t[5, 17, 26] + 1 @ t[6, 18, 27]) @ t[7, 19, 28] + if (i @ t[8, 20, 29] == 2 @ t[9, 21, 30]) @ t[10, 22, 31]: + continue + total = (total @ t[11, 32] + i @ t[12, 33]) @ t[13, 34] + done = True @ t[38] + + +# 4. While/else (no break — else executes) +@test +def test_while_else(t): + i = 0 @ t[0] + while (i @ t[1, 7, 13] < 2 @ t[2, 8, 14]) @ t[3, 9, 15]: + i = (i @ t[4, 10] + 1 @ t[5, 11]) @ t[6, 12] + else: + done = True @ t[16] + + +# 5. While/else (with break — else skipped) +@test +def test_while_else_break(t): + i = 0 @ t[0] + while (i @ t[1, 10] < 5 @ t[2, 11]) @ t[3, 12]: + if (i @ t[4, 13] == 1 @ t[5, 14]) @ t[6, 15]: + break + i = (i @ t[7] + 1 @ t[8]) @ t[9] + else: + never = True @ t.dead[16] + after = True @ t[16] + + +# 6. Simple for loop over a list +@test +def test_for_list(t): + for x in [1 @ t[0], 2 @ t[1], 3 @ t[2]] @ t[3]: + x @ t[4, 5, 6] + done = True @ t[7] + + +# 7. For loop with range +@test +def test_for_range(t): + for i in (range @ t[0])(3 @ t[1]) @ t[2]: + i @ t[3, 4, 5] + done = True @ t[6] + + +# 8. For loop with break +@test +def test_for_break(t): + for x in [1 @ t[0], 2 @ t[1], 3 @ t[2], 4 @ t[3]] @ t[4]: + if (x @ t[5, 9, 13] == 3 @ t[6, 10, 14]) @ t[7, 11, 15]: + break + x @ t[8, 12] + done = True @ t[16] + + +# 9. For loop with continue +@test +def test_for_continue(t): + total = 0 @ t[0] + for x in [1 @ t[1], 2 @ t[2], 3 @ t[3]] @ t[4]: + if (x @ t[5, 11, 14] == 2 @ t[6, 12, 15]) @ t[7, 13, 16]: + continue + total = (total @ t[8, 17] + x @ t[9, 18]) @ t[10, 19] + done = True @ t[20] + + +# 10. For/else (no break — else executes) +@test +def test_for_else(t): + for x in [1 @ t[0], 2 @ t[1]] @ t[2]: + x @ t[3, 4] + else: + done = True @ t[5] + + +# 11. For/else (with break — else skipped) +@test +def test_for_else_break(t): + for x in [1 @ t[0], 2 @ t[1], 3 @ t[2]] @ t[3]: + if (x @ t[4, 8] == 2 @ t[5, 9]) @ t[6, 10]: + break + x @ t[7] + else: + never = True @ t.dead[11] + after = True @ t[11] + + +# 12. Nested loops +@test +def test_nested_loops(t): + for i in [1 @ t[0], 2 @ t[1]] @ t[2]: + for j in [10 @ t[3, 12], 20 @ t[4, 13]] @ t[5, 14]: + (i @ t[6, 9, 15, 18] + j @ t[7, 10, 16, 19]) @ t[8, 11, 17, 20] + done = True @ t[21] + + +# 13. While True with conditional break +@test +def test_while_true_break(t): + i = 0 @ t[0] + while True @ t[1, 8, 15]: + i = (i @ t[2, 9, 16] + 1 @ t[3, 10, 17]) @ t[4, 11, 18] + if (i @ t[5, 12, 19] == 3 @ t[6, 13, 20]) @ t[7, 14, 21]: + break + done = True @ t[22] + + +# 14. For with enumerate +@test +def test_for_enumerate(t): + for idx, val in (enumerate @ t[0])(["a" @ t[1], "b" @ t[2], "c" @ t[3]] @ t[4]) @ t[5]: + idx @ t[6, 8, 10] + val @ t[7, 9, 11] + done = True @ t[12] diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/test_match.py b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_match.py new file mode 100644 index 000000000000..1dac5b0985c9 --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_match.py @@ -0,0 +1,173 @@ +"""Evaluation order for match/case (structural pattern matching, Python 3.10+).""" + +import sys +if sys.version_info < (3, 10): + print("Skipping match/case tests (requires Python 3.10+)") + print("---") + print("0/0 tests passed") + sys.exit(0) + +from timer import test + + +@test +def test_match_literal(t): + x = 1 @ t[0] + match x @ t[1]: + case 1: + y = "one" @ t[2] + case 2: + y = "two" @ t.dead[2] + z = y @ t[3] + + +@test +def test_match_literal_fallthrough(t): + x = 3 @ t[0] + match x @ t[1]: + case 1: + y = "one" @ t.dead[2] + case 2: + y = "two" @ t.dead[2] + case 3: + y = "three" @ t[2] + z = y @ t[3] + + +@test +def test_match_wildcard(t): + x = 42 @ t[0] + match x @ t[1]: + case 1: + y = "one" @ t.dead[2] + case _: + y = "other" @ t[2] + z = y @ t[3] + + +@test +def test_match_capture(t): + x = 42 @ t[0] + match x @ t[1]: + case n: + y = n @ t[2] + z = y @ t[3] + + +@test +def test_match_or_pattern(t): + x = 2 @ t[0] + match x @ t[1]: + case 1 | 2: + y = "low" @ t[2] + case _: + y = "other" @ t.dead[2] + z = y @ t[3] + + +@test +def test_match_guard(t): + x = 5 @ t[0] + match x @ t[1]: + case n if (n @ t[2] > 3 @ t[3]) @ t[4]: + y = n @ t[5] + case _: + y = 0 @ t.dead[5] + z = y @ t[6] + + +@test +def test_match_class_pattern(t): + x = 42 @ t[0] + match x @ t[1]: + case int(): + y = "integer" @ t[2] + case str(): + y = "string" @ t.dead[2] + z = y @ t[3] + + +@test +def test_match_sequence(t): + x = [1 @ t[0], 2 @ t[1]] @ t[2] + match x @ t[3]: + case [a, b]: + y = (a @ t[4] + b @ t[5]) @ t[6] + case _: + y = 0 @ t.dead[6] + z = y @ t[7] + + +@test +def test_match_mapping(t): + x = {"key" @ t[0]: 42 @ t[1]} @ t[2] + match x @ t[3]: + case {"key": value}: + y = value @ t[4] + case _: + y = 0 @ t.dead[4] + z = y @ t[5] + + +@test +def test_match_nested(t): + x = {"users" @ t[0]: [{"name" @ t[1]: "Alice" @ t[2]} @ t[3]] @ t[4]} @ t[5] + match x @ t[6]: + case {"users": [{"name": name}]}: + y = name @ t[7] + case _: + y = "unknown" @ t.dead[7] + z = y @ t[8] + + +@test +def test_match_or_pattern_with_as(t): + """OR pattern with `as` binding and method call on the result.""" + clause = "foo@bar" @ t[0] + match clause @ t[1]: + case (str() as uses) | {"uses": uses}: + result = ((uses @ t[2]).partition @ t[3])("@" @ t[4]) @ t[5] + x = (result @ t[6])[0 @ t[7]] @ t[8] + case _: + raise ((ValueError @ t.dead[2])(clause @ t.dead[3]) @ t.dead[4]) + y = x @ t[9] + + +@test +def test_match_wildcard_raise(t): + """Wildcard case that raises, with OR pattern on the other branch.""" + clause = 42 @ t[0] + try: + match clause @ t[1]: + case (str() as uses) | {"uses": uses}: + result = uses @ t.dead[2] + case _: + raise ((ValueError @ t[2])(f"Invalid: {clause @ t[3]}" @ t[4]) @ t[5]) + except ValueError: + y = 0 @ t[6] + + +@test +def test_match_exhaustive_return_first(t): + """Every case returns; code after match is unreachable (first case taken).""" + def f(x): + match x @ t[2]: + case 1: + return "one" @ t[3] + case _: + return "other" @ t.dead[3] + y = 0 @ t.never + result = (f @ t[0])(1 @ t[1]) @ t[4] + + +@test +def test_match_exhaustive_return_wildcard(t): + """Every case returns; code after match is unreachable (wildcard taken).""" + def f(x): + match x @ t[2]: + case 1: + return "one" @ t.dead[3] + case _: + return "other" @ t[3] + y = 0 @ t.never + result = (f @ t[0])(99 @ t[1]) @ t[4] diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/test_try.py b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_try.py new file mode 100644 index 000000000000..d54730478b11 --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_try.py @@ -0,0 +1,182 @@ +"""Exception handling control flow: try/except/else/finally evaluation order.""" + +from timer import test + + +# 1. try/except — no exception raised (except block skipped) +@test +def test_try_no_exception(t): + try: + x = 1 @ t[0] + y = 2 @ t[1] + except ValueError: + z = 3 @ t.dead[2] + after = 0 @ t[2] + + +# 2. try/except — exception raised and caught +@test +def test_try_with_exception(t): + try: + x = 1 @ t[0] + raise ((ValueError @ t[1])() @ t[2]) + y = 2 @ t.never + except ValueError: + z = 3 @ t[3] + after = 0 @ t[4] + + +# 3. try/except/else — no exception (else runs) +@test +def test_try_except_else_no_exception(t): + try: + x = 1 @ t[0] + except ValueError: + y = 2 @ t.dead[1] + else: + z = 3 @ t[1] + after = 0 @ t[2] + + +# 4. try/except/else — exception raised (else skipped) +@test +def test_try_except_else_with_exception(t): + try: + x = 1 @ t[0] + raise ((ValueError @ t[1])() @ t[2]) + except ValueError: + y = 2 @ t[3] + else: + z = 3 @ t.dead[3] + after = 0 @ t[4] + + +# 5. try/finally — no exception +@test +def test_try_finally_no_exception(t): + try: + x = 1 @ t[0] + y = 2 @ t[1] + finally: + z = 3 @ t[2] + after = 0 @ t[3] + + +# 6. try/finally — exception raised (finally runs, then exception propagates) +@test +def test_try_finally_exception(t): + try: + try: + x = 1 @ t[0] + raise ((ValueError @ t[1])() @ t[2]) + finally: + y = 2 @ t[3] + except ValueError: + z = 3 @ t[4] + + +# 7. try/except/finally — no exception +@test +def test_try_except_finally_no_exception(t): + try: + x = 1 @ t[0] + except ValueError: + y = 2 @ t.dead[1] + finally: + z = 3 @ t[1] + after = 0 @ t[2] + + +# 8. try/except/finally — exception caught +@test +def test_try_except_finally_exception(t): + try: + x = 1 @ t[0] + raise ((ValueError @ t[1])() @ t[2]) + except ValueError: + y = 2 @ t[3] + finally: + z = 3 @ t[4] + after = 0 @ t[5] + + +# 9. Multiple except clauses — first matching +@test +def test_multiple_except_first(t): + try: + x = 1 @ t[0] + raise ((ValueError @ t[1])() @ t[2]) + except ValueError: + y = 2 @ t[3] + except TypeError: + z = 3 @ t.dead[3] + after = 0 @ t[4] + + +# 10. Multiple except clauses — second matching +@test +def test_multiple_except_second(t): + try: + x = 1 @ t[0] + raise ((TypeError @ t[1])() @ t[2]) + except ValueError: + y = 2 @ t.dead[3] + except TypeError: + z = 3 @ t[3] + after = 0 @ t[4] + + +# 11. except with `as` binding +@test +def test_except_as_binding(t): + try: + x = 1 @ t[0] + raise ((ValueError @ t[1])("msg" @ t[2]) @ t[3]) + except ValueError as e: + y = (str @ t[4])(e @ t[5]) @ t[6] + after = 0 @ t[7] + + +# 12. Nested try/except +@test +def test_nested_try_except(t): + try: + x = 1 @ t[0] + try: + y = 2 @ t[1] + raise ((ValueError @ t[2])() @ t[3]) + except ValueError: + z = 3 @ t[4] + w = 4 @ t[5] + except TypeError: + v = 5 @ t.dead[6] + after = 0 @ t[6] + + +# 13. try/except in a loop +@test +def test_try_in_loop(t): + total = 0 @ t[0] + for i in (range @ t[1])(3 @ t[2]) @ t[3]: + try: + if (i @ t[4, 11, 20] == 1 @ t[5, 12, 21]) @ t[6, 13, 22]: + raise ((ValueError @ t[14])() @ t[15]) + total = (total @ t[7, 23] + 1 @ t[8, 24]) @ t[9, 25] + except ValueError: + total = (total @ t[16] + 10 @ t[17]) @ t[18] + r = 0 @ t[10, 19, 26] + + +# 14. Re-raise with bare `raise` +@test +def test_reraise(t): + try: + try: + x = 1 @ t[0] + raise ((ValueError @ t[1])() @ t[2]) + except ValueError: + y = 2 @ t[3] + raise + except ValueError: + z = 3 @ t[4] + after = 0 @ t[5] diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/test_unpacking.py b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_unpacking.py new file mode 100644 index 000000000000..45f292cb0b7d --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_unpacking.py @@ -0,0 +1,48 @@ +"""Unpacking and star expressions evaluation order.""" + +from timer import test + + +@test +def test_tuple_unpack(t): + """RHS expression evaluates, then unpacking assigns targets.""" + a, b = (1 @ t[0], 2 @ t[1]) @ t[2] + x = (a @ t[3] + b @ t[4]) @ t[5] + + +@test +def test_list_unpack(t): + """List unpacking: RHS elements left to right, then unpack.""" + [a, b] = [1 @ t[0], 2 @ t[1]] @ t[2] + x = (a @ t[3] + b @ t[4]) @ t[5] + + +@test +def test_star_unpack(t): + """Star unpacking: RHS evaluates first.""" + a, *b = [1 @ t[0], 2 @ t[1], 3 @ t[2], 4 @ t[3]] @ t[4] + x = (a @ t[5], b @ t[6]) @ t[7] + + +@test +def test_nested_unpack(t): + """Nested unpacking: RHS evaluates first.""" + (a, b), c = ((1 @ t[0], 2 @ t[1]) @ t[2], 3 @ t[3]) @ t[4] + x = ((a @ t[5] + b @ t[6]) @ t[7] + c @ t[8]) @ t[9] + + +@test +def test_swap(t): + a = 1 @ t[0] + b = 2 @ t[1] + a, b = (b @ t[2], a @ t[3]) @ t[4] + x = a @ t[5] + y = b @ t[6] + + +@test +def test_unpack_for(t): + pairs = [(1 @ t[0], 2 @ t[1]) @ t[2], (3 @ t[3], 4 @ t[4]) @ t[5]] @ t[6] + for a, b in pairs @ t[7]: + x = a @ t[8, 10] + y = b @ t[9, 11] diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/test_with.py b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_with.py new file mode 100644 index 000000000000..1dcc7169092b --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_with.py @@ -0,0 +1,58 @@ +"""Evaluation order tests for with statements.""" + +from contextlib import contextmanager +from timer import test + + +@contextmanager +def ctx(value=None): + yield value + + +@test +def test_simple_with(t): + x = 1 @ t[0] + with (ctx @ t[1])() @ t[2]: + y = 2 @ t[3] + z = 3 @ t[4] + + +@test +def test_with_as(t): + with (ctx @ t[0])(42 @ t[1]) @ t[2] as v: + x = v @ t[3] + y = 0 @ t[4] + + +@test +def test_nested_with(t): + with (ctx @ t[0])() @ t[1]: + with (ctx @ t[2])() @ t[3]: + x = 1 @ t[4] + y = 2 @ t[5] + + +@test +def test_multiple_context_managers(t): + with (ctx @ t[0])(1 @ t[1]) @ t[2] as a, (ctx @ t[3])(2 @ t[4]) @ t[5] as b: + x = (a @ t[6], b @ t[7]) @ t[8] + y = 0 @ t[9] + + +@test +def test_with_exception_handling(t): + try: + with (ctx @ t[0])() @ t[1]: + x = 1 @ t[2] + raise ((ValueError @ t[3])() @ t[4]) + except ValueError: + y = 2 @ t[5] + z = 3 @ t[6] + + +@test +def test_with_in_loop(t): + for i in [1 @ t[0], 2 @ t[1]] @ t[2]: + with (ctx @ t[3, 6])() @ t[4, 7]: + x = i @ t[5, 8] + y = 0 @ t[9] diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/test_yield.py b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_yield.py new file mode 100644 index 000000000000..b2a28d793bc6 --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_yield.py @@ -0,0 +1,105 @@ +"""Generator and yield evaluation order tests. + +Generator bodies are lazy — code runs only when iterated. The timer +annotations inside generator bodies fire interleaved with the caller's +annotations, reflecting the suspend/resume semantics of yield. +""" + +from timer import test + + +@test +def test_simple_generator(t): + """Basic generator: body runs on next(), not on gen().""" + def gen(): + yield 1 @ t[4] + yield 2 @ t[8] + + g = (gen @ t[0])() @ t[1] + x = (next @ t[2])(g @ t[3]) @ t[5] + y = (next @ t[6])(g @ t[7]) @ t[9] + + +@test +def test_multiple_yields(t): + """Three yields interleave with three next() calls.""" + def gen(): + yield 1 @ t[4] + yield 2 @ t[8] + yield 3 @ t[12] + + g = (gen @ t[0])() @ t[1] + a = (next @ t[2])(g @ t[3]) @ t[5] + b = (next @ t[6])(g @ t[7]) @ t[9] + c = (next @ t[10])(g @ t[11]) @ t[13] + + +@test +def test_generator_for_loop(t): + """for-loop consumes generator, interleaving body and loop.""" + def gen(): + yield 1 @ t[2] + yield 2 @ t[4] + + for val in (gen @ t[0])() @ t[1]: + val @ t[3, 5] + + +@test +def test_generator_list(t): + """list() consumes the entire generator without interleaving.""" + def gen(): + yield 10 @ t[3] + yield 20 @ t[4] + yield 30 @ t[5] + + result = (list @ t[0])((gen @ t[1])() @ t[2]) @ t[6] + + +@test +def test_yield_from(t): + """yield from delegates to an inner generator transparently.""" + def inner(): + yield 1 @ t[6] + yield 2 @ t[10] + + def outer(): + yield from (inner @ t[4])() @ t[5] + + g = (outer @ t[0])() @ t[1] + x = (next @ t[2])(g @ t[3]) @ t[7] + y = (next @ t[8])(g @ t[9]) @ t[11] + + +@test +def test_generator_return(t): + """Generator return value accessed via yield from.""" + def gen(): + yield 1 @ t[6] + return 42 @ t[10] + + def wrapper(): + result = (yield from (gen @ t[4])() @ t[5]) @ t[11] + yield result @ t[12] + + g = (wrapper @ t[0])() @ t[1] + x = (next @ t[2])(g @ t[3]) @ t[7] + y = (next @ t[8])(g @ t[9]) @ t[13] + + +@test +def test_generator_send(t): + """send() passes a value into the generator at the yield point.""" + def gen(): + x = (yield 1 @ t[4]) @ t[9] + yield (x @ t[10] + 10 @ t[11]) @ t[12] + + g = (gen @ t[0])() @ t[1] + first = (next @ t[2])(g @ t[3]) @ t[5] + second = ((g @ t[6]).send @ t[7])(42 @ t[8]) @ t[13] + + +@test +def test_generator_expression(t): + """Inline generator expression consumed by list().""" + result = (list @ t[0])(x @ t[5, 6, 7] for x in [10 @ t[1], 20 @ t[2], 30 @ t[3]] @ t[4]) @ t[8] diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/timer.py b/python/ql/test/library-tests/ControlFlow/evaluation-order/timer.py new file mode 100644 index 000000000000..6cec3fd50cba --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/timer.py @@ -0,0 +1,185 @@ +"""Abstract timer for self-validating CFG evaluation-order tests. + +Provides a Timer context manager and a @test decorator for writing tests +that verify the order in which Python evaluates expressions. + +Usage with @test decorator (preferred): + + from timer import test + + @test + def test_sequential(t): + x = 1 @ t[0] + y = 2 @ t[1] + z = (x + y) @ t[2] + +Usage with context manager (manual): + + from timer import Timer + + with Timer("my_test") as t: + x = 1 @ t[0] + +Timer API: + t[n] - assert current timestamp is n, return marker + t[n, m, ...] - assert current timestamp is one of {n, m, ...} + t["label"] - record current timestamp under label (development aid) + t(value, n) - equivalent to: value @ t[n] + +Run a test file directly to self-validate: python test_file.py +""" + +import atexit +import sys + +_results = [] + + +class _Check: + """Marker returned by t[n] — asserts the current timestamp.""" + + __slots__ = ("_timer", "_expected") + + def __init__(self, timer, expected): + self._timer = timer + self._expected = expected + + def __rmatmul__(self, value): + ts = self._timer._tick() + if ts not in self._expected: + self._timer._error( + f"expected {sorted(self._expected)}, got {ts}" + ) + return value + + +class _Label: + """Marker returned by t["name"] — records the timestamp under a label.""" + + __slots__ = ("_timer", "_name") + + def __init__(self, timer, name): + self._timer = timer + self._name = name + + def __rmatmul__(self, value): + ts = self._timer._tick() + self._timer._labels.setdefault(self._name, []).append(ts) + return value + + +class _NeverCheck: + """Marker returned by t.never — fails if the expression is ever evaluated.""" + + def __init__(self, timer): + self._timer = timer + + def __rmatmul__(self, value): + self._timer._error("expression annotated with t.never was evaluated") + return value + + +class _DeadCheck: + """Marker returned by t.dead[n] — fails if the expression is ever evaluated.""" + + def __init__(self, timer): + self._timer = timer + + def __rmatmul__(self, value): + self._timer._error("expression annotated with t.dead was evaluated") + return value + + +class _DeadSubscript: + """Subscriptable returned by t.dead — produces _DeadCheck markers.""" + + def __init__(self, timer): + self._timer = timer + + def __getitem__(self, key): + return _DeadCheck(self._timer) + + +class Timer: + """Context manager tracking abstract evaluation timestamps. + + Each Timer instance maintains a counter starting at 0. Every time an + annotation (@ t[n] or t(value, n)) is encountered, the counter is + compared against the expected value and then incremented. + """ + + def __init__(self, name=""): + self._name = name + self._counter = 0 + self._errors = [] + self._labels = {} + self.dead = _DeadSubscript(self) + self.never = _NeverCheck(self) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if self._labels: + for name, timestamps in sorted(self._labels.items()): + print(f" {name}: {', '.join(map(str, timestamps))}") + _results.append((self._name, list(self._errors))) + if self._errors: + print(f"{self._name}: FAIL") + for err in self._errors: + print(f" {err}") + else: + print(f"{self._name}: ok") + return False + + def _tick(self): + ts = self._counter + self._counter += 1 + return ts + + def _error(self, msg): + self._errors.append(msg) + + def __getitem__(self, key): + if isinstance(key, str): + return _Label(self, key) + elif isinstance(key, tuple): + return _Check(self, list(key)) + else: + return _Check(self, [key]) + + def __call__(self, value, key): + """Alternative to @ operator: t(value, 4) or t(value, [1, 2, 3]).""" + if isinstance(key, list): + key = tuple(key) + marker = self[key] + return marker.__rmatmul__(value) + + +def test(fn): + """Decorator that creates a Timer and runs the test function immediately. + + The function receives a fresh Timer as its sole argument. Errors are + collected (not raised) and reported after the function completes. + """ + with Timer(fn.__name__) as t: + try: + fn(t) + except Exception as e: + t._error(f"exception: {type(e).__name__}: {e}") + return fn + + +def _report(): + """Print summary at interpreter exit.""" + if not _results: + return + total = len(_results) + passed = sum(1 for _, errors in _results if not errors) + print("---") + print(f"{passed}/{total} tests passed") + if passed < total: + sys.exit(1) + + +atexit.register(_report) From 710a43ac7f3f3f3026dc4a2dbaf2a63eab97c096 Mon Sep 17 00:00:00 2001 From: Taus Date: Thu, 16 Apr 2026 15:59:21 +0000 Subject: [PATCH 02/44] Python: Add some CFG-validation queries These use the annotated, self-verifying test files to check various consistency requirements. Some of these may be expressing the same thing in different ways, but it's fairly cheap to keep them around, so I have not attempted to produce a minimal set of queries for this. --- .../AllLiveReachable.expected | 0 .../evaluation-order/AllLiveReachable.ql | 17 ++++++++++++ .../BasicBlockAnnotationGap.expected | 0 .../BasicBlockAnnotationGap.ql | 26 +++++++++++++++++++ .../ContiguousTimestamps.expected | 0 .../evaluation-order/ContiguousTimestamps.ql | 18 +++++++++++++ .../evaluation-order/NoBackwardFlow.expected | 0 .../evaluation-order/NoBackwardFlow.ql | 19 ++++++++++++++ .../NoSharedReachable.expected | 0 .../evaluation-order/NoSharedReachable.ql | 23 ++++++++++++++++ .../evaluation-order/StrictForward.expected | 0 .../evaluation-order/StrictForward.ql | 25 ++++++++++++++++++ 12 files changed, 128 insertions(+) create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/AllLiveReachable.expected create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/AllLiveReachable.ql create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/BasicBlockAnnotationGap.expected create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/BasicBlockAnnotationGap.ql create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/ContiguousTimestamps.expected create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/ContiguousTimestamps.ql create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/NoBackwardFlow.expected create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/NoBackwardFlow.ql create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/NoSharedReachable.expected create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/NoSharedReachable.ql create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/StrictForward.expected create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/StrictForward.ql diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/AllLiveReachable.expected b/python/ql/test/library-tests/ControlFlow/evaluation-order/AllLiveReachable.expected new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/AllLiveReachable.ql b/python/ql/test/library-tests/ControlFlow/evaluation-order/AllLiveReachable.ql new file mode 100644 index 000000000000..946930f29d19 --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/AllLiveReachable.ql @@ -0,0 +1,17 @@ +/** + * Checks that every live (non-dead) annotation in the test function's + * own scope is reachable from the function entry in the CFG. + * Annotations in nested scopes (generators, async, lambdas, comprehensions) + * have separate CFGs and are excluded from this check. + */ + +import python +import TimerUtils + +from TimerCfgNode a, TestFunction f +where + not a.isDead() and + f = a.getTestFunction() and + a.getScope() = f and + not f.getEntryNode().getBasicBlock().reaches(a.getBasicBlock()) +select a, "Unreachable live annotation; entry of $@ does not reach this node", f, f.getName() diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/BasicBlockAnnotationGap.expected b/python/ql/test/library-tests/ControlFlow/evaluation-order/BasicBlockAnnotationGap.expected new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/BasicBlockAnnotationGap.ql b/python/ql/test/library-tests/ControlFlow/evaluation-order/BasicBlockAnnotationGap.ql new file mode 100644 index 000000000000..8f84e2062181 --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/BasicBlockAnnotationGap.ql @@ -0,0 +1,26 @@ +/** + * 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 TimerUtils + +from TimerCfgNode a, ControlFlowNode succ +where + exists(BasicBlock bb, int i | + a = bb.getNode(i) and + succ = bb.getNode(i + 1) + ) and + not succ instanceof TimerCfgNode and + not isUnannotatable(succ.getNode()) and + not isTimerMechanism(succ.getNode(), a.getTestFunction()) and + not exists(a.getAnExceptionalSuccessor()) and + succ.getNode() instanceof Expr +select a, "Annotated node followed by unannotated $@ in the same basic block", succ, + succ.getNode().toString() diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/ContiguousTimestamps.expected b/python/ql/test/library-tests/ControlFlow/evaluation-order/ContiguousTimestamps.expected new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/ContiguousTimestamps.ql b/python/ql/test/library-tests/ControlFlow/evaluation-order/ContiguousTimestamps.ql new file mode 100644 index 000000000000..456ebf447dad --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/ContiguousTimestamps.ql @@ -0,0 +1,18 @@ +/** + * Checks that timestamps form a contiguous sequence {0, 1, ..., max} + * within each test function. Every integer in the range must appear + * in at least one annotation (live or dead). + */ + +import python +import TimerUtils + +from TestFunction f, int missing, int maxTs, TimerAnnotation maxAnn +where + maxTs = max(TimerAnnotation a | a.getTestFunction() = f | a.getATimestamp()) and + maxAnn.getTestFunction() = f and + maxAnn.getATimestamp() = maxTs and + missing = [0 .. maxTs] and + not exists(TimerAnnotation a | a.getTestFunction() = f and a.getATimestamp() = missing) +select f, "Missing timestamp " + missing + " (max is $@)", maxAnn.getTimestampExpr(maxTs), + maxTs.toString() diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NoBackwardFlow.expected b/python/ql/test/library-tests/ControlFlow/evaluation-order/NoBackwardFlow.expected new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NoBackwardFlow.ql b/python/ql/test/library-tests/ControlFlow/evaluation-order/NoBackwardFlow.ql new file mode 100644 index 000000000000..64f0c3ba1862 --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/NoBackwardFlow.ql @@ -0,0 +1,19 @@ +/** + * 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 TimerUtils + +from TimerCfgNode a, TimerCfgNode b, int minA, int maxB +where + nextTimerAnnotation(a, b) and + not a.isDead() and + not b.isDead() and + minA = min(a.getATimestamp()) and + maxB = max(b.getATimestamp()) and + minA >= maxB +select a, "Backward flow: $@ flows to $@ (max timestamp $@)", a.getTimestampExpr(minA), + minA.toString(), b, b.getNode().toString(), b.getTimestampExpr(maxB), maxB.toString() diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NoSharedReachable.expected b/python/ql/test/library-tests/ControlFlow/evaluation-order/NoSharedReachable.expected new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NoSharedReachable.ql b/python/ql/test/library-tests/ControlFlow/evaluation-order/NoSharedReachable.ql new file mode 100644 index 000000000000..59b680206387 --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/NoSharedReachable.ql @@ -0,0 +1,23 @@ +/** + * Checks that two annotations sharing a timestamp value are on + * mutually exclusive CFG paths (neither can reach the other). + */ + +import python +import TimerUtils + +from TimerCfgNode a, TimerCfgNode b, int ts +where + a != b and + not a.isDead() and + not b.isDead() and + a.getTestFunction() = b.getTestFunction() and + ts = a.getATimestamp() and + ts = b.getATimestamp() and + ( + a.getBasicBlock().strictlyReaches(b.getBasicBlock()) + or + exists(BasicBlock bb, int i, int j | a = bb.getNode(i) and b = bb.getNode(j) and i < j) + ) +select a, "Shared timestamp $@ but this node reaches $@", a.getTimestampExpr(ts), ts.toString(), b, + b.getNode().toString() diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/StrictForward.expected b/python/ql/test/library-tests/ControlFlow/evaluation-order/StrictForward.expected new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/StrictForward.ql b/python/ql/test/library-tests/ControlFlow/evaluation-order/StrictForward.ql new file mode 100644 index 000000000000..8147062664fc --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/StrictForward.ql @@ -0,0 +1,25 @@ +/** + * 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 TimerUtils + +from TimerCfgNode a, TimerCfgNode b, int maxA, int minB +where + nextTimerAnnotation(a, b) and + not a.isDead() and + not b.isDead() and + // Only apply to non-loop code (single timestamps on both sides) + strictcount(a.getATimestamp()) = 1 and + strictcount(b.getATimestamp()) = 1 and + // Forward edge: B does not strictly dominate A (excludes loop back-edges + // but still checks same-basic-block pairs) + not b.getBasicBlock().strictlyDominates(a.getBasicBlock()) and + maxA = max(a.getATimestamp()) and + minB = min(b.getATimestamp()) and + maxA >= minB +select a, "Strict forward violation: $@ flows to $@", a.getTimestampExpr(maxA), "timestamp " + maxA, + b.getTimestampExpr(minB), "timestamp " + minB From a8d136d3d64b1db862c2cafa5e12fcc00d5cc47c Mon Sep 17 00:00:00 2001 From: Taus Date: Thu, 16 Apr 2026 16:10:03 +0000 Subject: [PATCH 03/44] Python: Add BasicBlockOrdering test This one demonstrates a bug in the current CFG. In a dictionary comprehension `{k: v for k, v in d.items()}`, we evaluate the value before the key, which is incorrect. (A fix for this bug has been implemented in a separate PR.) --- .../evaluation-order/BasicBlockOrdering.expected | 1 + .../evaluation-order/BasicBlockOrdering.ql | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/BasicBlockOrdering.expected create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/BasicBlockOrdering.ql diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/BasicBlockOrdering.expected b/python/ql/test/library-tests/ControlFlow/evaluation-order/BasicBlockOrdering.expected new file mode 100644 index 000000000000..573094ddf734 --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/BasicBlockOrdering.expected @@ -0,0 +1 @@ +| test_comprehensions.py:21:29:21:40 | ControlFlowNode for BinaryExpr | Basic block ordering: $@ appears before $@ | test_comprehensions.py:21:35:21:35 | IntegerLiteral | timestamp 9 | test_comprehensions.py:21:21:21:21 | IntegerLiteral | timestamp 8 | diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/BasicBlockOrdering.ql b/python/ql/test/library-tests/ControlFlow/evaluation-order/BasicBlockOrdering.ql new file mode 100644 index 000000000000..772781e367eb --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/BasicBlockOrdering.ql @@ -0,0 +1,16 @@ +/** + * Checks that within a single basic block, annotations appear in + * increasing minimum-timestamp order. + */ + +import python +import TimerUtils + +from TimerCfgNode a, TimerCfgNode b, int minA, int minB +where + exists(BasicBlock bb, int i, int j | a = bb.getNode(i) and b = bb.getNode(j) and i < j) and + minA = min(a.getATimestamp()) and + minB = min(b.getATimestamp()) and + minA >= minB +select a, "Basic block ordering: $@ appears before $@", a.getTimestampExpr(minA), + "timestamp " + minA, b.getTimestampExpr(minB), "timestamp " + minB From f97bf38f3be468461f2f1d585a1d43072e455586 Mon Sep 17 00:00:00 2001 From: Taus Date: Thu, 16 Apr 2026 16:12:31 +0000 Subject: [PATCH 04/44] Python: Add NeverReachable test This looks for nodes annotated with `t.never` in the test that are reachable in the CFG. This should not happen (it messes with various queries, e.g. the "mixed returns" query), but the test shows that in a few particular cases (involving the `match` statement where all cases contain `return`s), we _do_ have reachable nodes that shouldn't be. --- .../evaluation-order/NeverReachable.expected | 2 ++ .../evaluation-order/NeverReachable.ql | 26 +++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/NeverReachable.expected create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/NeverReachable.ql diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NeverReachable.expected b/python/ql/test/library-tests/ControlFlow/evaluation-order/NeverReachable.expected new file mode 100644 index 000000000000..200ebdbc6a74 --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/NeverReachable.expected @@ -0,0 +1,2 @@ +| test_match.py:159:13:159:23 | BinaryExpr | Node annotated with t.never is reachable in $@ | test_match.py:151:1:151:42 | Function test_match_exhaustive_return_first | test_match_exhaustive_return_first | +| test_match.py:172:13:172:23 | BinaryExpr | Node annotated with t.never is reachable in $@ | test_match.py:164:1:164:45 | Function test_match_exhaustive_return_wildcard | test_match_exhaustive_return_wildcard | diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NeverReachable.ql b/python/ql/test/library-tests/ControlFlow/evaluation-order/NeverReachable.ql new file mode 100644 index 000000000000..adc347527539 --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/NeverReachable.ql @@ -0,0 +1,26 @@ +/** + * 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 TimerUtils + +from NeverTimerAnnotation ann +where + exists(ControlFlowNode n, Scope s | + n.getNode() = ann.getExpr() and + s = n.getScope() and + ( + // Reachable via inter-block path (includes same block) + s.getEntryNode().getBasicBlock().reaches(n.getBasicBlock()) + or + // In same block as entry but at a later index + exists(BasicBlock bb, int i, int j | + bb.getNode(i) = s.getEntryNode() and bb.getNode(j) = n and i < j + ) + ) + ) +select ann, "Node annotated with t.never is reachable in $@", ann.getTestFunction(), + ann.getTestFunction().getName() From ba29e7e34dd4bfbf6279af5f59e134a8cf345531 Mon Sep 17 00:00:00 2001 From: Taus Date: Thu, 16 Apr 2026 16:14:56 +0000 Subject: [PATCH 05/44] Python: Add ConsecutiveTimestamps test This one is potentially a bit iffy -- it checks for a very powerful propetry (that implies many of the other queries), but as the test results show, it can produce false positives when there is in fact no problem. We may want to get rid of it entirely, if it becomes too noisy. --- .../ConsecutiveTimestamps.expected | 1 + .../evaluation-order/ConsecutiveTimestamps.ql | 46 +++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/ConsecutiveTimestamps.expected create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/ConsecutiveTimestamps.ql diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/ConsecutiveTimestamps.expected b/python/ql/test/library-tests/ControlFlow/evaluation-order/ConsecutiveTimestamps.expected new file mode 100644 index 000000000000..e20e20c464d4 --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/ConsecutiveTimestamps.expected @@ -0,0 +1 @@ +| test_if.py:51:9:51:16 | BinaryExpr | $@ in $@ has no consecutive successor (expected 6) | test_if.py:51:15:51:15 | IntegerLiteral | Timestamp 5 | test_if.py:43:1:43:31 | Function test_if_elif_else_first | test_if_elif_else_first | diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/ConsecutiveTimestamps.ql b/python/ql/test/library-tests/ControlFlow/evaluation-order/ConsecutiveTimestamps.ql new file mode 100644 index 000000000000..8c7a49b74fbf --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/ConsecutiveTimestamps.ql @@ -0,0 +1,46 @@ +/** + * 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 TimerUtils + +/** + * Holds if function `f` has an annotation in a nested scope + * (generator, async function, comprehension, lambda). + */ +private predicate hasNestedScopeAnnotation(TestFunction f) { + exists(TimerAnnotation a | + a.getTestFunction() = f and + a.getExpr().getScope() != f + ) +} + +from TimerAnnotation ann, int a +where + not hasNestedScopeAnnotation(ann.getTestFunction()) and + not ann.isDead() and + a = ann.getATimestamp() and + not exists(TimerCfgNode x, TimerCfgNode y | + ann.getExpr() = x.getNode() and + nextTimerAnnotation(x, y) and + (a + 1) = y.getATimestamp() + ) and + // Exclude the maximum timestamp in the function (it has no successor) + not a = + max(TimerAnnotation other | + other.getTestFunction() = ann.getTestFunction() + | + other.getATimestamp() + ) +select ann, "$@ in $@ has no consecutive successor (expected " + (a + 1) + ")", + ann.getTimestampExpr(a), "Timestamp " + a, ann.getTestFunction(), ann.getTestFunction().getName() From 4582855de1aac431b3d8d7ab55745d3b4c0a4d1c Mon Sep 17 00:00:00 2001 From: Taus Date: Mon, 20 Apr 2026 16:12:25 +0000 Subject: [PATCH 06/44] Python: Make CFG tests parameterised Currently we only instantiate them with the old CFG library, but in the future we'll want to do this with the new library as well. Co-authored-by: yoff --- .../evaluation-order/AllLiveReachable.ql | 12 +- .../BasicBlockAnnotationGap.ql | 19 +- .../evaluation-order/BasicBlockOrdering.ql | 12 +- .../evaluation-order/ConsecutiveTimestamps.ql | 32 +-- .../evaluation-order/NeverReachable.ql | 20 +- .../evaluation-order/NoBackwardFlow.ql | 14 +- .../evaluation-order/NoSharedReachable.ql | 19 +- .../evaluation-order/OldCfgImpl.qll | 16 ++ .../evaluation-order/StrictForward.ql | 20 +- .../evaluation-order/TimerUtils.qll | 261 ++++++++++++++++-- 10 files changed, 305 insertions(+), 120 deletions(-) create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/OldCfgImpl.qll diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/AllLiveReachable.ql b/python/ql/test/library-tests/ControlFlow/evaluation-order/AllLiveReachable.ql index 946930f29d19..de44daa3e2c2 100644 --- a/python/ql/test/library-tests/ControlFlow/evaluation-order/AllLiveReachable.ql +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/AllLiveReachable.ql @@ -7,11 +7,13 @@ import python import TimerUtils +import OldCfgImpl + +private module Utils = EvalOrderCfgUtils; + +private import Utils +private import Utils::CfgTests from TimerCfgNode a, TestFunction f -where - not a.isDead() and - f = a.getTestFunction() and - a.getScope() = f and - not f.getEntryNode().getBasicBlock().reaches(a.getBasicBlock()) +where allLiveReachable(a, f) select a, "Unreachable live annotation; entry of $@ does not reach this node", f, f.getName() diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/BasicBlockAnnotationGap.ql b/python/ql/test/library-tests/ControlFlow/evaluation-order/BasicBlockAnnotationGap.ql index 8f84e2062181..0a2b08ff3fdd 100644 --- a/python/ql/test/library-tests/ControlFlow/evaluation-order/BasicBlockAnnotationGap.ql +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/BasicBlockAnnotationGap.ql @@ -10,17 +10,14 @@ import python import TimerUtils +import OldCfgImpl -from TimerCfgNode a, ControlFlowNode succ -where - exists(BasicBlock bb, int i | - a = bb.getNode(i) and - succ = bb.getNode(i + 1) - ) and - not succ instanceof TimerCfgNode and - not isUnannotatable(succ.getNode()) and - not isTimerMechanism(succ.getNode(), a.getTestFunction()) and - not exists(a.getAnExceptionalSuccessor()) and - succ.getNode() instanceof Expr +private module Utils = EvalOrderCfgUtils; + +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() diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/BasicBlockOrdering.ql b/python/ql/test/library-tests/ControlFlow/evaluation-order/BasicBlockOrdering.ql index 772781e367eb..30697f1403e2 100644 --- a/python/ql/test/library-tests/ControlFlow/evaluation-order/BasicBlockOrdering.ql +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/BasicBlockOrdering.ql @@ -5,12 +5,14 @@ import python import TimerUtils +import OldCfgImpl + +private module Utils = EvalOrderCfgUtils; + +private import Utils +private import Utils::CfgTests from TimerCfgNode a, TimerCfgNode b, int minA, int minB -where - exists(BasicBlock bb, int i, int j | a = bb.getNode(i) and b = bb.getNode(j) and i < j) and - minA = min(a.getATimestamp()) and - minB = min(b.getATimestamp()) and - minA >= minB +where basicBlockOrdering(a, b, minA, minB) select a, "Basic block ordering: $@ appears before $@", a.getTimestampExpr(minA), "timestamp " + minA, b.getTimestampExpr(minB), "timestamp " + minB diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/ConsecutiveTimestamps.ql b/python/ql/test/library-tests/ControlFlow/evaluation-order/ConsecutiveTimestamps.ql index 8c7a49b74fbf..709fd5665ea4 100644 --- a/python/ql/test/library-tests/ControlFlow/evaluation-order/ConsecutiveTimestamps.ql +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/ConsecutiveTimestamps.ql @@ -13,34 +13,14 @@ import python import TimerUtils +import OldCfgImpl -/** - * Holds if function `f` has an annotation in a nested scope - * (generator, async function, comprehension, lambda). - */ -private predicate hasNestedScopeAnnotation(TestFunction f) { - exists(TimerAnnotation a | - a.getTestFunction() = f and - a.getExpr().getScope() != f - ) -} +private module Utils = EvalOrderCfgUtils; + +private import Utils +private import Utils::CfgTests from TimerAnnotation ann, int a -where - not hasNestedScopeAnnotation(ann.getTestFunction()) and - not ann.isDead() and - a = ann.getATimestamp() and - not exists(TimerCfgNode x, TimerCfgNode y | - ann.getExpr() = x.getNode() and - nextTimerAnnotation(x, y) and - (a + 1) = y.getATimestamp() - ) and - // Exclude the maximum timestamp in the function (it has no successor) - not a = - max(TimerAnnotation other | - other.getTestFunction() = ann.getTestFunction() - | - other.getATimestamp() - ) +where consecutiveTimestamps(ann, a) select ann, "$@ in $@ has no consecutive successor (expected " + (a + 1) + ")", ann.getTimestampExpr(a), "Timestamp " + a, ann.getTestFunction(), ann.getTestFunction().getName() diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NeverReachable.ql b/python/ql/test/library-tests/ControlFlow/evaluation-order/NeverReachable.ql index adc347527539..db55c1d92e4b 100644 --- a/python/ql/test/library-tests/ControlFlow/evaluation-order/NeverReachable.ql +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/NeverReachable.ql @@ -6,21 +6,13 @@ import python import TimerUtils +import OldCfgImpl + +private module Utils = EvalOrderCfgUtils; + +private import Utils::CfgTests from NeverTimerAnnotation ann -where - exists(ControlFlowNode n, Scope s | - n.getNode() = ann.getExpr() and - s = n.getScope() and - ( - // Reachable via inter-block path (includes same block) - s.getEntryNode().getBasicBlock().reaches(n.getBasicBlock()) - or - // In same block as entry but at a later index - exists(BasicBlock bb, int i, int j | - bb.getNode(i) = s.getEntryNode() and bb.getNode(j) = n and i < j - ) - ) - ) +where neverReachable(ann) select ann, "Node annotated with t.never is reachable in $@", ann.getTestFunction(), ann.getTestFunction().getName() diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NoBackwardFlow.ql b/python/ql/test/library-tests/ControlFlow/evaluation-order/NoBackwardFlow.ql index 64f0c3ba1862..4acf45db3cda 100644 --- a/python/ql/test/library-tests/ControlFlow/evaluation-order/NoBackwardFlow.ql +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/NoBackwardFlow.ql @@ -6,14 +6,14 @@ import python import TimerUtils +import OldCfgImpl + +private module Utils = EvalOrderCfgUtils; + +private import Utils +private import Utils::CfgTests from TimerCfgNode a, TimerCfgNode b, int minA, int maxB -where - nextTimerAnnotation(a, b) and - not a.isDead() and - not b.isDead() and - minA = min(a.getATimestamp()) and - maxB = max(b.getATimestamp()) and - minA >= 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() diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NoSharedReachable.ql b/python/ql/test/library-tests/ControlFlow/evaluation-order/NoSharedReachable.ql index 59b680206387..1fcceb2aca98 100644 --- a/python/ql/test/library-tests/ControlFlow/evaluation-order/NoSharedReachable.ql +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/NoSharedReachable.ql @@ -5,19 +5,14 @@ import python import TimerUtils +import OldCfgImpl + +private module Utils = EvalOrderCfgUtils; + +private import Utils +private import Utils::CfgTests from TimerCfgNode a, TimerCfgNode b, int ts -where - a != b and - not a.isDead() and - not b.isDead() and - a.getTestFunction() = b.getTestFunction() and - ts = a.getATimestamp() and - ts = b.getATimestamp() and - ( - a.getBasicBlock().strictlyReaches(b.getBasicBlock()) - or - exists(BasicBlock bb, int i, int j | a = bb.getNode(i) and b = bb.getNode(j) and i < j) - ) +where noSharedReachable(a, b, ts) select a, "Shared timestamp $@ but this node reaches $@", a.getTimestampExpr(ts), ts.toString(), b, b.getNode().toString() diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/OldCfgImpl.qll b/python/ql/test/library-tests/ControlFlow/evaluation-order/OldCfgImpl.qll new file mode 100644 index 000000000000..6ddfe672de75 --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/OldCfgImpl.qll @@ -0,0 +1,16 @@ +/** + * Implementation of the evaluation-order CFG signature using the existing + * Python control flow graph. + */ + +private import python as Py +import TimerUtils + +/** Existing Python CFG implementation of the evaluation-order signature. */ +module OldCfg implements EvalOrderCfgSig { + class CfgNode = Py::ControlFlowNode; + + class BasicBlock = Py::BasicBlock; + + CfgNode scopeGetEntryNode(Scope s) { result = s.getEntryNode() } +} diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/StrictForward.ql b/python/ql/test/library-tests/ControlFlow/evaluation-order/StrictForward.ql index 8147062664fc..9e64770bab4d 100644 --- a/python/ql/test/library-tests/ControlFlow/evaluation-order/StrictForward.ql +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/StrictForward.ql @@ -6,20 +6,14 @@ import python import TimerUtils +import OldCfgImpl + +private module Utils = EvalOrderCfgUtils; + +private import Utils +private import Utils::CfgTests from TimerCfgNode a, TimerCfgNode b, int maxA, int minB -where - nextTimerAnnotation(a, b) and - not a.isDead() and - not b.isDead() and - // Only apply to non-loop code (single timestamps on both sides) - strictcount(a.getATimestamp()) = 1 and - strictcount(b.getATimestamp()) = 1 and - // Forward edge: B does not strictly dominate A (excludes loop back-edges - // but still checks same-basic-block pairs) - not b.getBasicBlock().strictlyDominates(a.getBasicBlock()) and - maxA = max(a.getATimestamp()) and - minB = min(b.getATimestamp()) and - maxA >= minB +where strictForward(a, b, maxA, minB) select a, "Strict forward violation: $@ flows to $@", a.getTimestampExpr(maxA), "timestamp " + maxA, b.getTimestampExpr(minB), "timestamp " + minB diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/TimerUtils.qll b/python/ql/test/library-tests/ControlFlow/evaluation-order/TimerUtils.qll index 6ad4ef1ef19e..7d9329155b5f 100644 --- a/python/ql/test/library-tests/ControlFlow/evaluation-order/TimerUtils.qll +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/TimerUtils.qll @@ -173,44 +173,251 @@ class NeverTimerAnnotation extends TNeverAnnotation, TimerAnnotation { } /** - * A CFG node corresponding to a timer annotation. + * Signature module defining the CFG interface needed by evaluation-order tests. + * This allows the test utilities to be instantiated with different CFG implementations. */ -class TimerCfgNode extends ControlFlowNode { - private TimerAnnotation annot; +signature module EvalOrderCfgSig { + /** A control flow node. */ + class CfgNode { + /** Gets a textual representation of this node. */ + string toString(); - TimerCfgNode() { annot.getExpr() = this.getNode() } + /** Gets the location of this node. */ + Location getLocation(); - /** Gets a timestamp value from this annotation. */ - int getATimestamp() { result = annot.getATimestamp() } + /** Gets the AST node corresponding to this CFG node, if any. */ + AstNode getNode(); - /** Gets the source expression for timestamp value `ts`. */ - IntegerLiteral getTimestampExpr(int ts) { result = annot.getTimestampExpr(ts) } + /** Gets a successor of this CFG node (including exceptional). */ + CfgNode getASuccessor(); - /** Gets the test function this annotation belongs to. */ - TestFunction getTestFunction() { result = annot.getTestFunction() } + /** Gets an exceptional successor of this CFG node. */ + CfgNode getAnExceptionalSuccessor(); + + /** Gets the scope containing this CFG node. */ + Scope getScope(); + + /** Gets the basic block containing this CFG node. */ + BasicBlock getBasicBlock(); + } + + /** A basic block in the control flow graph. */ + class BasicBlock { + /** Gets the CFG node at position `n` in this basic block. */ + CfgNode getNode(int n); - /** Holds if this is a dead-code annotation. */ - predicate isDead() { annot.isDead() } + /** Holds if this basic block reaches `bb` (reflexive). */ + predicate reaches(BasicBlock bb); - /** Holds if this is a never-evaluated annotation. */ - predicate isNever() { annot.isNever() } + /** Holds if this basic block strictly reaches `bb` (non-reflexive). */ + predicate strictlyReaches(BasicBlock bb); + + /** Holds if this basic block strictly dominates `bb`. */ + predicate strictlyDominates(BasicBlock bb); + } + + /** Gets the entry CFG node for scope `s`. */ + CfgNode scopeGetEntryNode(Scope s); } /** - * Holds if `next` is the next timer annotation reachable from `n` via - * CFG successors (both normal and exceptional), skipping non-annotated - * intermediaries within the same scope. + * Parameterised module providing CFG-dependent utilities for evaluation-order tests. + * Instantiate with a specific CFG implementation to get `TimerCfgNode` and related predicates. */ -predicate nextTimerAnnotation(ControlFlowNode n, TimerCfgNode next) { - next = n.getASuccessor() and - next.getScope() = n.getScope() - or - exists(ControlFlowNode mid | - mid = n.getASuccessor() and - not mid instanceof TimerCfgNode and - mid.getScope() = n.getScope() and - nextTimerAnnotation(mid, next) - ) +module EvalOrderCfgUtils { + /** The CFG node type from the underlying implementation. */ + final class CfgNode = Input::CfgNode; + + /** The basic block type from the underlying implementation (named to avoid clash with `python::BasicBlock`). */ + final class CfgBasicBlock = Input::BasicBlock; + + /** Gets the entry CFG node for scope `s`. */ + CfgNode scopeGetEntryNode(Scope s) { result = Input::scopeGetEntryNode(s) } + + /** + * A CFG node corresponding to a timer annotation. + */ + class TimerCfgNode extends CfgNode { + private TimerAnnotation annot; + + TimerCfgNode() { annot.getExpr() = this.getNode() } + + /** Gets a timestamp value from this annotation. */ + int getATimestamp() { result = annot.getATimestamp() } + + /** Gets the source expression for timestamp value `ts`. */ + IntegerLiteral getTimestampExpr(int ts) { result = annot.getTimestampExpr(ts) } + + /** Gets the test function this annotation belongs to. */ + TestFunction getTestFunction() { result = annot.getTestFunction() } + + /** Holds if this is a dead-code annotation. */ + predicate isDead() { annot.isDead() } + + /** Holds if this is a never-evaluated annotation. */ + predicate isNever() { annot.isNever() } + } + + /** + * Holds if `next` is the next timer annotation reachable from `n` via + * CFG successors (both normal and exceptional), skipping non-annotated + * intermediaries within the same scope. + */ + predicate nextTimerAnnotation(CfgNode n, TimerCfgNode next) { + next = n.getASuccessor() and + next.getScope() = n.getScope() + or + exists(CfgNode mid | + mid = n.getASuccessor() and + not mid instanceof TimerCfgNode and + mid.getScope() = n.getScope() and + nextTimerAnnotation(mid, next) + ) + } + + /** CFG-dependent test predicates, one per evaluation-order query. */ + module CfgTests { + /** + * Holds if live annotation `a` in function `f` is unreachable from + * the function entry in the CFG. + */ + predicate allLiveReachable(TimerCfgNode a, TestFunction f) { + not a.isDead() and + f = a.getTestFunction() and + a.getScope() = f and + not scopeGetEntryNode(f).getBasicBlock().reaches(a.getBasicBlock()) + } + + /** + * Holds if annotated node `a` is followed by unannotated `succ` in the + * same basic block. + */ + predicate basicBlockAnnotationGap(TimerCfgNode a, CfgNode succ) { + exists(CfgBasicBlock bb, int i | + a = bb.getNode(i) and + succ = bb.getNode(i + 1) + ) and + not succ instanceof TimerCfgNode and + not isUnannotatable(succ.getNode()) and + not isTimerMechanism(succ.getNode(), a.getTestFunction()) and + not exists(a.getAnExceptionalSuccessor()) and + succ.getNode() instanceof Expr + } + + /** + * Holds if annotations `a` and `b` appear in the same basic block with + * `a` before `b`, but `a`'s minimum timestamp is not less than `b`'s. + */ + predicate basicBlockOrdering(TimerCfgNode a, TimerCfgNode b, int minA, int minB) { + exists(CfgBasicBlock bb, int i, int j | a = bb.getNode(i) and b = bb.getNode(j) and i < j) and + minA = min(a.getATimestamp()) and + minB = min(b.getATimestamp()) and + minA >= minB + } + + /** + * Holds if function `f` has an annotation in a nested scope + * (generator, async function, comprehension, lambda). + */ + private predicate hasNestedScopeAnnotation(TestFunction f) { + exists(TimerAnnotation a | + a.getTestFunction() = f and + a.getExpr().getScope() != f + ) + } + + /** + * Holds if annotation `ann` with timestamp `a` has no consecutive + * successor (expected `a + 1`) in the CFG. + */ + predicate consecutiveTimestamps(TimerAnnotation ann, int a) { + not hasNestedScopeAnnotation(ann.getTestFunction()) and + not ann.isDead() and + a = ann.getATimestamp() and + not exists(TimerCfgNode x, TimerCfgNode y | + ann.getExpr() = x.getNode() and + nextTimerAnnotation(x, y) and + (a + 1) = y.getATimestamp() + ) and + // Exclude the maximum timestamp in the function (it has no successor) + not a = + max(TimerAnnotation other | + other.getTestFunction() = ann.getTestFunction() + | + other.getATimestamp() + ) + } + + /** + * Holds if the expression annotated with `t.never` is reachable from + * its scope's entry. + */ + predicate neverReachable(NeverTimerAnnotation ann) { + exists(CfgNode n, Scope s | + n.getNode() = ann.getExpr() and + s = n.getScope() and + ( + // Reachable via inter-block path (includes same block) + scopeGetEntryNode(s).getBasicBlock().reaches(n.getBasicBlock()) + or + // In same block as entry but at a later index + exists(CfgBasicBlock bb, int i, int j | + bb.getNode(i) = scopeGetEntryNode(s) and bb.getNode(j) = n and i < j + ) + ) + ) + } + + /** + * Holds if consecutive annotated nodes `a` -> `b` have backward time + * flow (`minA >= maxB`). + */ + predicate noBackwardFlow(TimerCfgNode a, TimerCfgNode b, int minA, int maxB) { + nextTimerAnnotation(a, b) and + not a.isDead() and + not b.isDead() and + minA = min(a.getATimestamp()) and + maxB = max(b.getATimestamp()) and + minA >= maxB + } + + /** + * Holds if annotations `a` and `b` share timestamp `ts` but `a` + * can reach `b` in the CFG. + */ + predicate noSharedReachable(TimerCfgNode a, TimerCfgNode b, int ts) { + a != b and + not a.isDead() and + not b.isDead() and + a.getTestFunction() = b.getTestFunction() and + ts = a.getATimestamp() and + ts = b.getATimestamp() and + ( + a.getBasicBlock().strictlyReaches(b.getBasicBlock()) + or + exists(CfgBasicBlock bb, int i, int j | a = bb.getNode(i) and b = bb.getNode(j) and i < j) + ) + } + + /** + * Holds if consecutive single-timestamp annotations `a` -> `b` on a + * forward edge have `maxA >= minB`. + */ + predicate strictForward(TimerCfgNode a, TimerCfgNode b, int maxA, int minB) { + nextTimerAnnotation(a, b) and + not a.isDead() and + not b.isDead() and + // Only apply to non-loop code (single timestamps on both sides) + strictcount(a.getATimestamp()) = 1 and + strictcount(b.getATimestamp()) = 1 and + // Forward edge: B does not strictly dominate A (excludes loop back-edges + // but still checks same-basic-block pairs) + not b.getBasicBlock().strictlyDominates(a.getBasicBlock()) and + maxA = max(a.getATimestamp()) and + minB = min(b.getATimestamp()) and + maxA >= minB + } + } } /** From d62e116fc290a4e613b6d7fe222f02b09b2ad2b3 Mon Sep 17 00:00:00 2001 From: Taus Date: Thu, 16 Apr 2026 14:31:51 +0000 Subject: [PATCH 07/44] Python: First stab at shared control-flow --- python/ql/lib/qlpack.yml | 1 + .../controlflow/internal/AstNodeImpl.qll | 376 ++++++++++++++++++ 2 files changed, 377 insertions(+) create mode 100644 python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll diff --git a/python/ql/lib/qlpack.yml b/python/ql/lib/qlpack.yml index 8564a098594b..f7335c8f36b1 100644 --- a/python/ql/lib/qlpack.yml +++ b/python/ql/lib/qlpack.yml @@ -7,6 +7,7 @@ library: true upgrades: upgrades dependencies: codeql/concepts: ${workspace} + codeql/controlflow: ${workspace} codeql/dataflow: ${workspace} codeql/mad: ${workspace} codeql/regex: ${workspace} diff --git a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll new file mode 100644 index 000000000000..fd99d05c0edc --- /dev/null +++ b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll @@ -0,0 +1,376 @@ +/** + * Provides a newtype-based interface layer that mediates between the existing + * Python AST classes and the shared control-flow library's `AstSig` signature. + * + * The newtype unifies Python's `Stmt`, `Expr`, `Scope`, and `StmtList` into a + * single `AstNode` type. Notably, `StmtList` (which is not an `AstNode` in the + * existing Python AST) is wrapped as a `BlockStmt` (a subtype of `Stmt`), + * since the shared CFG library expects statement blocks to be statements. + */ + +private import python as Py +private import codeql.controlflow.ControlFlowGraph + +private module Ast { + /** The newtype representing AST nodes for the shared CFG library. */ + private newtype TAstNode = + TStmtNode(Py::Stmt s) or + TExprNode(Py::Expr e) or + TScopeNode(Py::Scope sc) or + TStmtListNode(Py::StmtList sl) + + /** + * An AST node for the shared CFG. Each branch of the newtype gets a + * subclass that overrides `toString` and `getLocation`. + */ + class Node extends TAstNode { + string toString() { none() } + + Py::Location getLocation() { none() } + + /** Gets the enclosing scope of this node, if any. */ + ScopeNode getEnclosingScope() { none() } + } + + class StmtNode extends Node, TStmtNode { + /** Gets the underlying Python statement. */ + Py::Stmt asStmt() { this = TStmtNode(result) } + + override string toString() { result = this.asStmt().toString() } + + override Py::Location getLocation() { result = this.asStmt().getLocation() } + + /** Gets the enclosing scope of this statement. */ + override ScopeNode getEnclosingScope() { result.asScope() = this.asStmt().getScope() } + } + + class ExprNode extends Node, TExprNode { + /** Gets the underlying Python expression. */ + Py::Expr asExpr() { this = TExprNode(result) } + + override string toString() { result = this.asExpr().toString() } + + override Py::Location getLocation() { result = this.asExpr().getLocation() } + + /** Gets the enclosing scope of this expression. */ + override ScopeNode getEnclosingScope() { result.asScope() = this.asExpr().getScope() } + } + + class ScopeNode extends Node, TScopeNode { + /** Gets the underlying Python scope. */ + Py::Scope asScope() { this = TScopeNode(result) } + + override string toString() { result = this.asScope().toString() } + + override Py::Location getLocation() { result = this.asScope().getLocation() } + + /** Gets the body of this scope. */ + StmtListNode getBody() { result.asStmtList() = this.asScope().getBody() } + + /** Gets the enclosing scope of this scope, if any. */ + override ScopeNode getEnclosingScope() { result.asScope() = this.asScope().getEnclosingScope() } + } + + class StmtListNode extends Node, TStmtListNode { + /** Gets the underlying Python statement list. */ + Py::StmtList asStmtList() { this = TStmtListNode(result) } + + override string toString() { result = this.asStmtList().toString() } + + // StmtList has no native location; approximate with first item's location. + override Py::Location getLocation() { result = this.asStmtList().getItem(0).getLocation() } + + /** Gets the `n`th (zero-based) statement in this block. */ + StmtNode getItem(int n) { result.asStmt() = this.asStmtList().getItem(n) } + + /** Gets the last statement in this block. */ + StmtNode getLastItem() { result.asStmt() = this.asStmtList().getLastItem() } + + /** Gets the enclosing scope of this statement list. */ + override ScopeNode getEnclosingScope() { + result.asScope() = this.asStmtList().getParent().(Py::Scope) + or + result.asScope() = this.asStmtList().getParent().(Py::Stmt).getScope() + } + } + + /** An `if` statement. */ + class IfNode extends StmtNode { + private Py::If ifStmt; + + IfNode() { ifStmt = this.asStmt() } + + /** Gets the condition of this `if` statement. */ + ExprNode getTest() { result.asExpr() = ifStmt.getTest() } + + /** Gets the if-true branch. */ + StmtListNode getBody() { result.asStmtList() = ifStmt.getBody() } + + /** Gets the if-false branch, if any. */ + StmtListNode getOrelse() { result.asStmtList() = ifStmt.getOrelse() } + } + + /** An expression statement. */ + class ExprStmtNode extends StmtNode { + private Py::ExprStmt exprStmt; + + ExprStmtNode() { exprStmt = this.asStmt() } + + /** Gets the expression in this statement. */ + ExprNode getValue() { result.asExpr() = exprStmt.getValue() } + } +} + +/** Provides an implementation of the AST signature for Python. */ +module AstSigImpl implements AstSig { + class AstNode = Ast::Node; + + /** Gets the child of `n` at the specified (zero-based) index. */ + AstNode getChild(AstNode n, int index) { + exists(Ast::IfNode ifNode | ifNode = n | + index = 0 and result = ifNode.getTest() + or + index = 1 and result = ifNode.getBody() + or + index = 2 and result = ifNode.getOrelse() + ) + or + result = n.(Ast::StmtListNode).getItem(index) + or + index = 0 and result = n.(Ast::ExprStmtNode).getValue() + } + + Callable getEnclosingCallable(AstNode node) { result = node.getEnclosingScope() } + + /** + * A callable: a function, class, or module scope. + * + * In Python, all three are executable scopes with statement bodies. + */ + class Callable extends Ast::ScopeNode { } + + /** Gets the body of callable `c`. */ + AstNode callableGetBody(Callable c) { result = c.getBody() } + + /** A statement. Includes both wrapped `Stmt` nodes and `StmtList` blocks. */ + class Stmt extends AstNode { + Stmt() { this instanceof Ast::StmtNode or this instanceof Ast::StmtListNode } + } + + /** An expression. */ + class Expr extends Ast::ExprNode { } + + /** A block of statements, wrapping Python's `StmtList`. */ + class BlockStmt extends Stmt, Ast::StmtListNode { + /** Gets the `n`th (zero-based) statement in this block. */ + Stmt getStmt(int n) { result = Ast::StmtListNode.super.getItem(n) } + + /** Gets the last statement in this block. */ + Stmt getLastStmt() { result = Ast::StmtListNode.super.getLastItem() } + } + + /** An expression statement. */ + class ExprStmt extends Stmt, Ast::ExprStmtNode { + /** Gets the expression in this expression statement. */ + Expr getExpr() { result = this.getValue() } + } + + /** + * An `if` statement. + * + * Python's `elif` chains are represented as nested `If` nodes in the + * else branch's `StmtList`. The shared CFG library handles this naturally: + * `getElse()` returns the `BlockStmt` wrapping the else branch, and if that + * block contains a single `If`, the result is a chained conditional. + */ + class IfStmt extends Stmt, Ast::IfNode { + /** Gets the condition of this `if` statement. */ + Expr getCondition() { result = this.getTest() } + + /** Gets the `then` (true) branch of this `if` statement. */ + Stmt getThen() { result = Ast::IfNode.super.getBody() } + + /** Gets the `else` (false) branch of this `if` statement, if any. */ + Stmt getElse() { result = this.getOrelse() } + } + + // ===== Stub types for constructs not yet implemented ===== + /** A loop statement. Not yet implemented for Python. */ + class LoopStmt extends Stmt { + LoopStmt() { none() } + + /** Gets the body of this loop statement. */ + Stmt getBody() { none() } + } + + /** A `while` loop statement. Not yet implemented for Python. */ + class WhileStmt extends LoopStmt { + /** Gets the boolean condition of this `while` loop. */ + Expr getCondition() { none() } + } + + /** A `do-while` loop statement. Python has no do-while construct. */ + class DoStmt extends LoopStmt { + /** Gets the boolean condition of this `do-while` loop. */ + Expr getCondition() { none() } + } + + /** A C-style `for` loop. Python has no C-style for loop. */ + class ForStmt extends LoopStmt { + /** Gets the initializer expression at the specified position. */ + Expr getInit(int index) { none() } + + /** Gets the boolean condition of this `for` loop. */ + Expr getCondition() { none() } + + /** Gets the update expression at the specified position. */ + Expr getUpdate(int index) { none() } + } + + /** A for-each loop. Not yet implemented for Python. */ + class ForeachStmt extends LoopStmt { + /** Gets the loop variable. */ + Expr getVariable() { none() } + + /** Gets the collection being iterated. */ + Expr getCollection() { none() } + } + + /** A `break` statement. Not yet implemented for Python. */ + class BreakStmt extends Stmt { + BreakStmt() { none() } + } + + /** A `continue` statement. Not yet implemented for Python. */ + class ContinueStmt extends Stmt { + ContinueStmt() { none() } + } + + /** A `return` statement. Not yet implemented for Python. */ + class ReturnStmt extends Stmt { + ReturnStmt() { none() } + + /** Gets the expression being returned, if any. */ + Expr getExpr() { none() } + } + + /** A `throw`/`raise` statement. Not yet implemented for Python. */ + class ThrowStmt extends Stmt { + ThrowStmt() { none() } + + /** Gets the expression being thrown. */ + Expr getExpr() { none() } + } + + /** A `try` statement. Not yet implemented for Python. */ + class TryStmt extends Stmt { + TryStmt() { none() } + + /** Gets the body of this `try` statement. */ + Stmt getBody() { none() } + + /** Gets the `catch` clause at the specified position. */ + CatchClause getCatch(int index) { none() } + + /** Gets the `finally` block of this `try` statement, if any. */ + Stmt getFinally() { none() } + } + + /** A catch clause. Not yet implemented for Python. */ + class CatchClause extends AstNode { + CatchClause() { none() } + + /** Gets the variable declared by this catch clause. */ + AstNode getVariable() { none() } + + /** Gets the guard condition, if any. */ + Expr getCondition() { none() } + + /** Gets the body of this catch clause. */ + Stmt getBody() { none() } + } + + /** A switch/match statement. Not yet implemented for Python. */ + class Switch extends AstNode { + Switch() { none() } + + /** Gets the expression being switched on. */ + Expr getExpr() { none() } + + /** Gets the case at the specified position. */ + Case getCase(int index) { none() } + + /** Gets the statement at the specified position. */ + Stmt getStmt(int index) { none() } + } + + /** A case in a switch/match. Not yet implemented for Python. */ + class Case extends AstNode { + Case() { none() } + + /** Gets a pattern being matched. */ + AstNode getAPattern() { none() } + + /** Gets the guard expression, if any. */ + Expr getGuard() { none() } + + /** Gets the body of this case. */ + AstNode getBody() { none() } + } + + /** A default case. Not yet implemented for Python. */ + class DefaultCase extends Case { } + + /** A ternary conditional expression. Not yet implemented for Python. */ + class ConditionalExpr extends Expr { + ConditionalExpr() { none() } + + /** Gets the condition of this expression. */ + Expr getCondition() { none() } + + /** Gets the true branch of this expression. */ + Expr getThen() { none() } + + /** Gets the false branch of this expression. */ + Expr getElse() { none() } + } + + /** A binary expression. Not yet implemented for Python. */ + class BinaryExpr extends Expr { + BinaryExpr() { none() } + + /** Gets the left operand. */ + Expr getLeftOperand() { none() } + + /** Gets the right operand. */ + Expr getRightOperand() { none() } + } + + /** A short-circuiting logical AND expression. Not yet implemented for Python. */ + class LogicalAndExpr extends BinaryExpr { } + + /** A short-circuiting logical OR expression. Not yet implemented for Python. */ + class LogicalOrExpr extends BinaryExpr { } + + /** A null-coalescing expression. Python has no null-coalescing operator. */ + class NullCoalescingExpr extends BinaryExpr { } + + /** A unary expression. Not yet implemented for Python. */ + class UnaryExpr extends Expr { + UnaryExpr() { none() } + + /** Gets the operand. */ + Expr getOperand() { none() } + } + + /** A logical NOT expression. Not yet implemented for Python. */ + class LogicalNotExpr extends UnaryExpr { } + + /** A boolean literal expression. Not yet implemented for Python. */ + class BooleanLiteral extends Expr { + BooleanLiteral() { none() } + + /** Gets the boolean value of this literal. */ + boolean getValue() { none() } + } +} From 6086b999f6c3793477ae5e5945173d90ec830810 Mon Sep 17 00:00:00 2001 From: Taus Date: Mon, 20 Apr 2026 11:34:05 +0000 Subject: [PATCH 08/44] Python: Use fields everywhere in new AST classes Co-authored-by: yoff --- .../controlflow/internal/AstNodeImpl.qll | 56 ++++++++++++------- 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll index fd99d05c0edc..6fb36ffc115e 100644 --- a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll +++ b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll @@ -33,64 +33,80 @@ private module Ast { } class StmtNode extends Node, TStmtNode { + private Py::Stmt stmt; + + StmtNode() { this = TStmtNode(stmt) } + /** Gets the underlying Python statement. */ - Py::Stmt asStmt() { this = TStmtNode(result) } + Py::Stmt asStmt() { result = stmt } - override string toString() { result = this.asStmt().toString() } + override string toString() { result = stmt.toString() } - override Py::Location getLocation() { result = this.asStmt().getLocation() } + override Py::Location getLocation() { result = stmt.getLocation() } /** Gets the enclosing scope of this statement. */ - override ScopeNode getEnclosingScope() { result.asScope() = this.asStmt().getScope() } + override ScopeNode getEnclosingScope() { result.asScope() = stmt.getScope() } } class ExprNode extends Node, TExprNode { + private Py::Expr expr; + + ExprNode() { this = TExprNode(expr) } + /** Gets the underlying Python expression. */ - Py::Expr asExpr() { this = TExprNode(result) } + Py::Expr asExpr() { result = expr } - override string toString() { result = this.asExpr().toString() } + override string toString() { result = expr.toString() } - override Py::Location getLocation() { result = this.asExpr().getLocation() } + override Py::Location getLocation() { result = expr.getLocation() } /** Gets the enclosing scope of this expression. */ - override ScopeNode getEnclosingScope() { result.asScope() = this.asExpr().getScope() } + override ScopeNode getEnclosingScope() { result.asScope() = expr.getScope() } } class ScopeNode extends Node, TScopeNode { + private Py::Scope scope; + + ScopeNode() { this = TScopeNode(scope) } + /** Gets the underlying Python scope. */ - Py::Scope asScope() { this = TScopeNode(result) } + Py::Scope asScope() { result = scope } - override string toString() { result = this.asScope().toString() } + override string toString() { result = scope.toString() } - override Py::Location getLocation() { result = this.asScope().getLocation() } + override Py::Location getLocation() { result = scope.getLocation() } /** Gets the body of this scope. */ - StmtListNode getBody() { result.asStmtList() = this.asScope().getBody() } + StmtListNode getBody() { result.asStmtList() = scope.getBody() } /** Gets the enclosing scope of this scope, if any. */ - override ScopeNode getEnclosingScope() { result.asScope() = this.asScope().getEnclosingScope() } + override ScopeNode getEnclosingScope() { result.asScope() = scope.getEnclosingScope() } } class StmtListNode extends Node, TStmtListNode { + private Py::StmtList stmtList; + + StmtListNode() { this = TStmtListNode(stmtList) } + /** Gets the underlying Python statement list. */ - Py::StmtList asStmtList() { this = TStmtListNode(result) } + Py::StmtList asStmtList() { result = stmtList } - override string toString() { result = this.asStmtList().toString() } + override string toString() { result = stmtList.toString() } // StmtList has no native location; approximate with first item's location. - override Py::Location getLocation() { result = this.asStmtList().getItem(0).getLocation() } + override Py::Location getLocation() { result = stmtList.getItem(0).getLocation() } /** Gets the `n`th (zero-based) statement in this block. */ - StmtNode getItem(int n) { result.asStmt() = this.asStmtList().getItem(n) } + StmtNode getItem(int n) { result.asStmt() = stmtList.getItem(n) } /** Gets the last statement in this block. */ - StmtNode getLastItem() { result.asStmt() = this.asStmtList().getLastItem() } + StmtNode getLastItem() { result.asStmt() = stmtList.getLastItem() } /** Gets the enclosing scope of this statement list. */ override ScopeNode getEnclosingScope() { - result.asScope() = this.asStmtList().getParent().(Py::Scope) + result.asScope() = stmtList.getParent().(Py::Scope) or - result.asScope() = this.asStmtList().getParent().(Py::Stmt).getScope() + result.asScope() = stmtList.getParent().(Py::Stmt).getScope() } } From 99745841020da7aadf0a245dd4609d01cdb301ff Mon Sep 17 00:00:00 2001 From: Taus Date: Mon, 20 Apr 2026 11:50:51 +0000 Subject: [PATCH 09/44] Python: Instantiate CFG module fully Co-authored-by: yoff --- .../controlflow/internal/AstNodeImpl.qll | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll index 6fb36ffc115e..526733a340a9 100644 --- a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll +++ b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll @@ -390,3 +390,40 @@ module AstSigImpl implements AstSig { boolean getValue() { none() } } } + +private module Cfg0 = Make0; + +private import Cfg0 + +private module Cfg1 = Make1; + +private import Cfg1 + +private module Cfg2 = Make2; + +private import Cfg2 + +private module Input implements InputSig1, InputSig2 { + predicate cfgCachedStageRef() { CfgCachedStage::ref() } + + private newtype TLabel = TNone() + + class Label extends TLabel { + string toString() { result = "label" } + } + + predicate beginAbruptCompletion( + AstSigImpl::AstNode ast, PreControlFlowNode n, AbruptCompletion c, boolean always + ) { + none() + } + + predicate endAbruptCompletion(AstSigImpl::AstNode ast, PreControlFlowNode n, AbruptCompletion c) { + none() + } + + predicate step(PreControlFlowNode n1, PreControlFlowNode n2) { none() } +} + +import CfgCachedStage +import Public From 2ed75e7ca7fc4d46abe9eb6c9df8e3f69860314b Mon Sep 17 00:00:00 2001 From: Taus Date: Mon, 20 Apr 2026 16:13:44 +0000 Subject: [PATCH 10/44] Python: Instantiate CFG tests with new CFG library Co-authored-by: yoff --- .../NewCfgAllLiveReachable.expected | 0 .../NewCfgAllLiveReachable.ql | 14 +++++ .../NewCfgBasicBlockAnnotationGap.expected | 0 .../NewCfgBasicBlockAnnotationGap.ql | 26 ++++++++++ .../NewCfgBasicBlockOrdering.expected | 0 .../NewCfgBasicBlockOrdering.ql | 21 ++++++++ .../NewCfgConsecutiveTimestamps.expected | 0 .../NewCfgConsecutiveTimestamps.ql | 29 +++++++++++ .../evaluation-order/NewCfgImpl.qll | 52 +++++++++++++++++++ .../NewCfgNeverReachable.expected | 0 .../evaluation-order/NewCfgNeverReachable.ql | 21 ++++++++ .../NewCfgNoBackwardFlow.expected | 0 .../evaluation-order/NewCfgNoBackwardFlow.ql | 22 ++++++++ .../NewCfgNoSharedReachable.expected | 0 .../NewCfgNoSharedReachable.ql | 21 ++++++++ .../NewCfgStrictForward.expected | 0 .../evaluation-order/NewCfgStrictForward.ql | 22 ++++++++ 17 files changed, 228 insertions(+) create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgAllLiveReachable.expected create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgAllLiveReachable.ql create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgBasicBlockAnnotationGap.expected create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgBasicBlockAnnotationGap.ql create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgBasicBlockOrdering.expected create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgBasicBlockOrdering.ql create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgConsecutiveTimestamps.expected create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgConsecutiveTimestamps.ql create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgImpl.qll create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgNeverReachable.expected create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgNeverReachable.ql create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgNoBackwardFlow.expected create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgNoBackwardFlow.ql create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgNoSharedReachable.expected create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgNoSharedReachable.ql create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgStrictForward.expected create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgStrictForward.ql diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgAllLiveReachable.expected b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgAllLiveReachable.expected new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgAllLiveReachable.ql b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgAllLiveReachable.ql new file mode 100644 index 000000000000..75f02d14a9cb --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgAllLiveReachable.ql @@ -0,0 +1,14 @@ +/** New-CFG version of AllLiveReachable. */ + +import python +import TimerUtils +import NewCfgImpl + +private module Utils = EvalOrderCfgUtils; + +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() diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgBasicBlockAnnotationGap.expected b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgBasicBlockAnnotationGap.expected new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgBasicBlockAnnotationGap.ql b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgBasicBlockAnnotationGap.ql new file mode 100644 index 000000000000..80dd759a3651 --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgBasicBlockAnnotationGap.ql @@ -0,0 +1,26 @@ +/** + * New-CFG version of BasicBlockAnnotationGap. + * + * Original: + * 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 TimerUtils +import NewCfgImpl + +private module Utils = EvalOrderCfgUtils; + +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() diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgBasicBlockOrdering.expected b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgBasicBlockOrdering.expected new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgBasicBlockOrdering.ql b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgBasicBlockOrdering.ql new file mode 100644 index 000000000000..f06d08d937e3 --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgBasicBlockOrdering.ql @@ -0,0 +1,21 @@ +/** + * New-CFG version of BasicBlockOrdering. + * + * Original: + * Checks that within a single basic block, annotations appear in + * increasing minimum-timestamp order. + */ + +import python +import TimerUtils +import NewCfgImpl + +private module Utils = EvalOrderCfgUtils; + +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 diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgConsecutiveTimestamps.expected b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgConsecutiveTimestamps.expected new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgConsecutiveTimestamps.ql b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgConsecutiveTimestamps.ql new file mode 100644 index 000000000000..8e52663d6eaf --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgConsecutiveTimestamps.ql @@ -0,0 +1,29 @@ +/** + * New-CFG version of ConsecutiveTimestamps. + * + * Original: + * 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 TimerUtils +import NewCfgImpl + +private module Utils = EvalOrderCfgUtils; + +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() diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgImpl.qll b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgImpl.qll new file mode 100644 index 000000000000..8549ca1b2060 --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgImpl.qll @@ -0,0 +1,52 @@ +/** + * 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 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 { + string toString() { result = NewControlFlowNode.super.toString() } + + Py::Location getLocation() { result = NewControlFlowNode.super.getLocation() } + + Py::AstNode getNode() { + result = CfgImpl::astNodeToPyNode(NewControlFlowNode.super.getAstNode()) + } + + CfgNode getASuccessor() { result = NewControlFlowNode.super.getASuccessor() } + + CfgNode getAnExceptionalSuccessor() { + result = NewControlFlowNode.super.getAnExceptionSuccessor() + } + + Py::Scope getScope() { result = NewControlFlowNode.super.getEnclosingCallable().asScope() } + + BasicBlock getBasicBlock() { result = NewControlFlowNode.super.getBasicBlock() } + } + + 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) { + result instanceof CfgImpl::ControlFlow::EntryNode and + result.getScope() = s + } +} diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgNeverReachable.expected b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgNeverReachable.expected new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgNeverReachable.ql b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgNeverReachable.ql new file mode 100644 index 000000000000..3430d49b57ef --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgNeverReachable.ql @@ -0,0 +1,21 @@ +/** + * New-CFG version of NeverReachable. + * + * Original: + * 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 TimerUtils +import NewCfgImpl + +private module Utils = EvalOrderCfgUtils; + +private import Utils::CfgTests + +from NeverTimerAnnotation ann +where neverReachable(ann) +select ann, "Node annotated with t.never is reachable in $@", ann.getTestFunction(), + ann.getTestFunction().getName() diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgNoBackwardFlow.expected b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgNoBackwardFlow.expected new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgNoBackwardFlow.ql b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgNoBackwardFlow.ql new file mode 100644 index 000000000000..442ca5f5456c --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgNoBackwardFlow.ql @@ -0,0 +1,22 @@ +/** + * New-CFG version of NoBackwardFlow. + * + * Original: + * 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 TimerUtils +import NewCfgImpl + +private module Utils = EvalOrderCfgUtils; + +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() diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgNoSharedReachable.expected b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgNoSharedReachable.expected new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgNoSharedReachable.ql b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgNoSharedReachable.ql new file mode 100644 index 000000000000..5a1a1aba2a7a --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgNoSharedReachable.ql @@ -0,0 +1,21 @@ +/** + * New-CFG version of NoSharedReachable. + * + * Original: + * Checks that two annotations sharing a timestamp value are on + * mutually exclusive CFG paths (neither can reach the other). + */ + +import python +import TimerUtils +import NewCfgImpl + +private module Utils = EvalOrderCfgUtils; + +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() diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgStrictForward.expected b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgStrictForward.expected new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgStrictForward.ql b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgStrictForward.ql new file mode 100644 index 000000000000..ebbc60346db0 --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgStrictForward.ql @@ -0,0 +1,22 @@ +/** + * New-CFG version of StrictForward. + * + * Original: + * 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 TimerUtils +import NewCfgImpl + +private module Utils = EvalOrderCfgUtils; + +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 From dc0344e2fc199a719aa939081c1faad7be9c8e88 Mon Sep 17 00:00:00 2001 From: Taus Date: Tue, 21 Apr 2026 13:54:26 +0000 Subject: [PATCH 11/44] Python: More AstNodeImpl improvements Co-authored-by: yoff --- .../controlflow/internal/AstNodeImpl.qll | 521 +++++++++++++++--- 1 file changed, 439 insertions(+), 82 deletions(-) diff --git a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll index 526733a340a9..47df5c0f619a 100644 --- a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll +++ b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll @@ -17,7 +17,17 @@ private module Ast { TStmtNode(Py::Stmt s) or TExprNode(Py::Expr e) or TScopeNode(Py::Scope sc) or - TStmtListNode(Py::StmtList sl) + TStmtListNode(Py::StmtList sl) or + /** + * A synthetic node representing an intermediate pair in a multi-operand + * `and`/`or` expression. For `a and b and c` (values 0,1,2), we + * synthesize a right-nested tree: the pair at index 1 represents + * `b and c`, which becomes the right operand of the outermost pair. + * + * Only created for inner pairs (index >= 1); the outermost pair (index 0) + * is represented by the original `BoolExpr` node via `TExprNode`. + */ + TBoolExprPair(Py::BoolExpr be, int index) { index = [1 .. count(be.getAValue()) - 2] } /** * An AST node for the shared CFG. Each branch of the newtype gets a @@ -135,6 +145,226 @@ private module Ast { /** Gets the expression in this statement. */ ExprNode getValue() { result.asExpr() = exprStmt.getValue() } } + + /** A `while` statement. */ + class WhileNode extends StmtNode { + private Py::While whileStmt; + + WhileNode() { whileStmt = this.asStmt() } + + ExprNode getTest() { result.asExpr() = whileStmt.getTest() } + + StmtListNode getBody() { result.asStmtList() = whileStmt.getBody() } + + StmtListNode getOrelse() { result.asStmtList() = whileStmt.getOrelse() } + } + + /** A `for` statement. */ + class ForNode extends StmtNode { + private Py::For forStmt; + + ForNode() { forStmt = this.asStmt() } + + ExprNode getTarget() { result.asExpr() = forStmt.getTarget() } + + ExprNode getIter() { result.asExpr() = forStmt.getIter() } + + StmtListNode getBody() { result.asStmtList() = forStmt.getBody() } + + StmtListNode getOrelse() { result.asStmtList() = forStmt.getOrelse() } + } + + /** A `return` statement. */ + class ReturnNode extends StmtNode { + private Py::Return ret; + + ReturnNode() { ret = this.asStmt() } + + ExprNode getValue() { result.asExpr() = ret.getValue() } + } + + /** A `raise` statement. */ + class RaiseNode extends StmtNode { + private Py::Raise raise; + + RaiseNode() { raise = this.asStmt() } + + ExprNode getException() { result.asExpr() = raise.getException() } + + ExprNode getCause() { result.asExpr() = raise.getCause() } + } + + /** A `break` statement. */ + class BreakNode extends StmtNode { + BreakNode() { this.asStmt() instanceof Py::Break } + } + + /** A `continue` statement. */ + class ContinueNode extends StmtNode { + ContinueNode() { this.asStmt() instanceof Py::Continue } + } + + /** A `try` statement. */ + class TryNode extends StmtNode { + private Py::Try tryStmt; + + TryNode() { tryStmt = this.asStmt() } + + StmtListNode getBody() { result.asStmtList() = tryStmt.getBody() } + + StmtListNode getOrelse() { result.asStmtList() = tryStmt.getOrelse() } + + StmtListNode getFinalbody() { result.asStmtList() = tryStmt.getFinalbody() } + + ExceptionHandlerNode getHandler(int i) { result.asStmt() = tryStmt.getHandler(i) } + } + + /** An exception handler (`except` or `except*`). */ + class ExceptionHandlerNode extends StmtNode { + private Py::ExceptionHandler handler; + + ExceptionHandlerNode() { handler = this.asStmt() } + + ExprNode getType() { result.asExpr() = handler.getType() } + + ExprNode getName() { result.asExpr() = handler.getName() } + + StmtListNode getBody() { + result.asStmtList() = handler.(Py::ExceptStmt).getBody() or + result.asStmtList() = handler.(Py::ExceptGroupStmt).getBody() + } + } + + /** A conditional expression (`x if cond else y`). */ + class IfExpNode extends ExprNode { + private Py::IfExp ifExp; + + IfExpNode() { ifExp = this.asExpr() } + + ExprNode getTest() { result.asExpr() = ifExp.getTest() } + + ExprNode getBody() { result.asExpr() = ifExp.getBody() } + + ExprNode getOrelse() { result.asExpr() = ifExp.getOrelse() } + } + + /** A Python binary expression (arithmetic, bitwise, matmul, etc.). */ + class BinaryExprNode extends ExprNode { + private Py::BinaryExpr binExpr; + + BinaryExprNode() { binExpr = this.asExpr() } + + ExprNode getLeft() { result.asExpr() = binExpr.getLeft() } + + ExprNode getRight() { result.asExpr() = binExpr.getRight() } + } + + /** A subscript expression (`obj[index]`). */ + class SubscriptNode extends ExprNode { + private Py::Subscript sub; + + SubscriptNode() { sub = this.asExpr() } + + ExprNode getObject() { result.asExpr() = sub.getObject() } + + ExprNode getIndex() { result.asExpr() = sub.getIndex() } + } + + /** + * A `not` expression. This is a `UnaryExpr` whose operator is `Not`. + */ + class NotExprNode extends ExprNode { + private Py::UnaryExpr notExpr; + + NotExprNode() { notExpr = this.asExpr() and notExpr.getOp() instanceof Py::Not } + + ExprNode getOperand() { result.asExpr() = notExpr.getOperand() } + } + + /** + * A boolean expression (`and`/`or`) with exactly 2 operands. + * For 2-operand BoolExprs, the `TExprNode` itself serves as the + * logical and/or expression. + */ + class BoolExpr2Node extends ExprNode { + private Py::BoolExpr boolExpr; + + BoolExpr2Node() { boolExpr = this.asExpr() and count(boolExpr.getAValue()) = 2 } + + predicate isAnd() { boolExpr.getOp() instanceof Py::And } + + predicate isOr() { boolExpr.getOp() instanceof Py::Or } + + ExprNode getLeftOperand() { result.asExpr() = boolExpr.getValue(0) } + + ExprNode getRightOperand() { result.asExpr() = boolExpr.getValue(1) } + } + + /** + * The outermost pair of a multi-operand (3+) boolean expression. + * Represented by the original `BoolExpr` node (`TExprNode`). + * Left operand is `getValue(0)`, right operand is `TBoolExprPair(be, 1)`. + */ + class BoolExprOuterNode extends ExprNode { + private Py::BoolExpr boolExpr; + + BoolExprOuterNode() { boolExpr = this.asExpr() and count(boolExpr.getAValue()) > 2 } + + predicate isAnd() { boolExpr.getOp() instanceof Py::And } + + predicate isOr() { boolExpr.getOp() instanceof Py::Or } + + Node getLeftOperand() { result = TExprNode(boolExpr.getValue(0)) } + + Node getRightOperand() { result = TBoolExprPair(boolExpr, 1) } + } + + /** + * A synthetic intermediate node in a multi-operand boolean expression. + * Pair at index `i` has left=`getValue(i)` and right=pair at `i+1` + * (or `getValue(n-1)` for the last pair). + */ + class BoolExprPairNode extends Node, TBoolExprPair { + private Py::BoolExpr boolExpr; + private int index; + + BoolExprPairNode() { this = TBoolExprPair(boolExpr, index) } + + override string toString() { result = boolExpr.getOperator() } + + override Py::Location getLocation() { result = boolExpr.getValue(index).getLocation() } + + override ScopeNode getEnclosingScope() { + result.asScope() = boolExpr.getValue(index).getScope() + } + + predicate isAnd() { boolExpr.getOp() instanceof Py::And } + + predicate isOr() { boolExpr.getOp() instanceof Py::Or } + + Node getLeftOperand() { result = TExprNode(boolExpr.getValue(index)) } + + Node getRightOperand() { + // Last pair: right operand is the final value + index = count(boolExpr.getAValue()) - 2 and + result = TExprNode(boolExpr.getValue(index + 1)) + or + // Not last pair: right operand is the next synthetic pair + index < count(boolExpr.getAValue()) - 2 and + result = TBoolExprPair(boolExpr, index + 1) + } + } + + /** A `True` or `False` literal. */ + class BoolLiteralNode extends ExprNode { + BoolLiteralNode() { this.asExpr() instanceof Py::True or this.asExpr() instanceof Py::False } + + boolean getBoolValue() { + this.asExpr() instanceof Py::True and result = true + or + this.asExpr() instanceof Py::False and result = false + } + } } /** Provides an implementation of the AST signature for Python. */ @@ -143,6 +373,7 @@ module AstSigImpl implements AstSig { /** Gets the child of `n` at the specified (zero-based) index. */ AstNode getChild(AstNode n, int index) { + // IfStmt: condition (0), then branch (1), else branch (2) exists(Ast::IfNode ifNode | ifNode = n | index = 0 and result = ifNode.getTest() or @@ -151,9 +382,101 @@ module AstSigImpl implements AstSig { index = 2 and result = ifNode.getOrelse() ) or + // BlockStmt (StmtList): indexed statements result = n.(Ast::StmtListNode).getItem(index) or + // ExprStmt: the expression (0) index = 0 and result = n.(Ast::ExprStmtNode).getValue() + or + // WhileStmt: condition (0), body (1) + // Note: Python while/else is not directly supported by the shared library. + exists(Ast::WhileNode w | w = n | + index = 0 and result = w.getTest() + or + index = 1 and result = w.getBody() + ) + or + // ForStmt (mapped as ForeachStmt): collection (0), variable (1), body (2) + exists(Ast::ForNode f | f = n | + index = 0 and result = f.getIter() + or + index = 1 and result = f.getTarget() + or + index = 2 and result = f.getBody() + ) + or + // ReturnStmt: the value (0) + index = 0 and result = n.(Ast::ReturnNode).getValue() + or + // ThrowStmt (raise): the exception (0), the cause (1) + exists(Ast::RaiseNode r | r = n | + index = 0 and result = r.getException() + or + index = 1 and result = r.getCause() + ) + or + // TryStmt: body (0), handlers (1..n), finally (-1) + exists(Ast::TryNode t | t = n | + index = 0 and result = t.getBody() + or + result = t.getHandler(index - 1) and index >= 1 + ) + or + // CatchClause (except handler): type (0), name (1), body (2) + exists(Ast::ExceptionHandlerNode h | h = n | + index = 0 and result = h.getType() + or + index = 1 and result = h.getName() + or + index = 2 and result = h.getBody() + ) + or + // ConditionalExpr (IfExp): condition (0), then (1), else (2) + exists(Ast::IfExpNode ie | ie = n | + index = 0 and result = ie.getTest() + or + index = 1 and result = ie.getBody() + or + index = 2 and result = ie.getOrelse() + ) + or + // Python BinaryExpr (arithmetic, bitwise, matmul, etc.): left (0), right (1) + exists(Ast::BinaryExprNode be | be = n | + index = 0 and result = be.getLeft() + or + index = 1 and result = be.getRight() + ) + or + // Subscript (obj[index]): object (0), index (1) + exists(Ast::SubscriptNode sub | sub = n | + index = 0 and result = sub.getObject() + or + index = 1 and result = sub.getIndex() + ) + or + // LogicalNotExpr: operand (0) + index = 0 and result = n.(Ast::NotExprNode).getOperand() + or + // 2-operand BoolExpr: left (0), right (1) + exists(Ast::BoolExpr2Node be | be = n | + index = 0 and result = be.getLeftOperand() + or + index = 1 and result = be.getRightOperand() + ) + or + // Multi-operand BoolExpr (outermost): left (0), right (1) + exists(Ast::BoolExprOuterNode be | be = n | + index = 0 and result = be.getLeftOperand() + or + index = 1 and result = be.getRightOperand() + ) + or + // Synthetic BoolExpr pair: left (0), right (1) + exists(Ast::BoolExprPairNode bp | bp = n | + index = 0 and result = bp.getLeftOperand() + or + index = 1 and result = bp.getRightOperand() + ) } Callable getEnclosingCallable(AstNode node) { result = node.getEnclosingScope() } @@ -173,8 +496,10 @@ module AstSigImpl implements AstSig { Stmt() { this instanceof Ast::StmtNode or this instanceof Ast::StmtListNode } } - /** An expression. */ - class Expr extends Ast::ExprNode { } + /** An expression. Includes `TExprNode` and synthetic `TBoolExprPair` nodes. */ + class Expr extends AstNode { + Expr() { this instanceof Ast::ExprNode or this instanceof Ast::BoolExprPairNode } + } /** A block of statements, wrapping Python's `StmtList`. */ class BlockStmt extends Stmt, Ast::StmtListNode { @@ -210,113 +535,107 @@ module AstSigImpl implements AstSig { Stmt getElse() { result = this.getOrelse() } } - // ===== Stub types for constructs not yet implemented ===== - /** A loop statement. Not yet implemented for Python. */ + // ===== Loop statements ===== + /** A loop statement. */ class LoopStmt extends Stmt { - LoopStmt() { none() } + LoopStmt() { this instanceof Ast::WhileNode or this instanceof Ast::ForNode } /** Gets the body of this loop statement. */ Stmt getBody() { none() } } - /** A `while` loop statement. Not yet implemented for Python. */ - class WhileStmt extends LoopStmt { + /** A `while` loop statement. */ + class WhileStmt extends LoopStmt instanceof Ast::WhileNode { /** Gets the boolean condition of this `while` loop. */ - Expr getCondition() { none() } + Expr getCondition() { result = this.(Ast::WhileNode).getTest() } + + override Stmt getBody() { result = this.(Ast::WhileNode).getBody() } } /** A `do-while` loop statement. Python has no do-while construct. */ class DoStmt extends LoopStmt { - /** Gets the boolean condition of this `do-while` loop. */ + DoStmt() { none() } + Expr getCondition() { none() } } /** A C-style `for` loop. Python has no C-style for loop. */ class ForStmt extends LoopStmt { - /** Gets the initializer expression at the specified position. */ + ForStmt() { none() } + Expr getInit(int index) { none() } - /** Gets the boolean condition of this `for` loop. */ Expr getCondition() { none() } - /** Gets the update expression at the specified position. */ Expr getUpdate(int index) { none() } } - /** A for-each loop. Not yet implemented for Python. */ + /** A for-each loop (`for x in iterable:`). */ class ForeachStmt extends LoopStmt { + ForeachStmt() { this instanceof Ast::ForNode } + /** Gets the loop variable. */ - Expr getVariable() { none() } + Expr getVariable() { result = this.(Ast::ForNode).getTarget() } /** Gets the collection being iterated. */ - Expr getCollection() { none() } - } + Expr getCollection() { result = this.(Ast::ForNode).getIter() } - /** A `break` statement. Not yet implemented for Python. */ - class BreakStmt extends Stmt { - BreakStmt() { none() } + override Stmt getBody() { result = this.(Ast::ForNode).getBody() } } - /** A `continue` statement. Not yet implemented for Python. */ - class ContinueStmt extends Stmt { - ContinueStmt() { none() } - } + // ===== Abrupt completion statements ===== + /** A `break` statement. */ + class BreakStmt extends Stmt, Ast::BreakNode { } - /** A `return` statement. Not yet implemented for Python. */ - class ReturnStmt extends Stmt { - ReturnStmt() { none() } + /** A `continue` statement. */ + class ContinueStmt extends Stmt, Ast::ContinueNode { } + /** A `return` statement. */ + class ReturnStmt extends Stmt, Ast::ReturnNode { /** Gets the expression being returned, if any. */ - Expr getExpr() { none() } + Expr getExpr() { result = this.getValue() } } - /** A `throw`/`raise` statement. Not yet implemented for Python. */ - class ThrowStmt extends Stmt { - ThrowStmt() { none() } - - /** Gets the expression being thrown. */ - Expr getExpr() { none() } + /** A `raise` statement (mapped to `ThrowStmt`). */ + class ThrowStmt extends Stmt, Ast::RaiseNode { + /** Gets the expression being raised. */ + Expr getExpr() { result = this.getException() } } - /** A `try` statement. Not yet implemented for Python. */ + // ===== Try/except ===== + /** A `try` statement. */ class TryStmt extends Stmt { - TryStmt() { none() } + TryStmt() { this instanceof Ast::TryNode } - /** Gets the body of this `try` statement. */ - Stmt getBody() { none() } + Stmt getBody() { result = this.(Ast::TryNode).getBody() } - /** Gets the `catch` clause at the specified position. */ - CatchClause getCatch(int index) { none() } + CatchClause getCatch(int index) { result = this.(Ast::TryNode).getHandler(index) } - /** Gets the `finally` block of this `try` statement, if any. */ - Stmt getFinally() { none() } + Stmt getFinally() { result = this.(Ast::TryNode).getFinalbody() } } - /** A catch clause. Not yet implemented for Python. */ - class CatchClause extends AstNode { - CatchClause() { none() } + AstNode getTryElse(TryStmt try) { result = try.(Ast::TryNode).getOrelse() } - /** Gets the variable declared by this catch clause. */ - AstNode getVariable() { none() } + /** An except clause in a try statement. */ + class CatchClause extends Stmt { + CatchClause() { this instanceof Ast::ExceptionHandlerNode } + + AstNode getVariable() { result = this.(Ast::ExceptionHandlerNode).getName() } - /** Gets the guard condition, if any. */ Expr getCondition() { none() } - /** Gets the body of this catch clause. */ - Stmt getBody() { none() } + Stmt getBody() { result = this.(Ast::ExceptionHandlerNode).getBody() } } + // ===== Switch/match — stubs for now ===== /** A switch/match statement. Not yet implemented for Python. */ class Switch extends AstNode { Switch() { none() } - /** Gets the expression being switched on. */ Expr getExpr() { none() } - /** Gets the case at the specified position. */ Case getCase(int index) { none() } - /** Gets the statement at the specified position. */ Stmt getStmt(int index) { none() } } @@ -324,70 +643,96 @@ module AstSigImpl implements AstSig { class Case extends AstNode { Case() { none() } - /** Gets a pattern being matched. */ AstNode getAPattern() { none() } - /** Gets the guard expression, if any. */ Expr getGuard() { none() } - /** Gets the body of this case. */ AstNode getBody() { none() } } /** A default case. Not yet implemented for Python. */ class DefaultCase extends Case { } - /** A ternary conditional expression. Not yet implemented for Python. */ - class ConditionalExpr extends Expr { - ConditionalExpr() { none() } - + // ===== Expression types ===== + /** A conditional expression (`x if cond else y`). */ + class ConditionalExpr extends Expr, Ast::IfExpNode { /** Gets the condition of this expression. */ - Expr getCondition() { none() } + Expr getCondition() { result = this.getTest() } /** Gets the true branch of this expression. */ - Expr getThen() { none() } + Expr getThen() { result = Ast::IfExpNode.super.getBody() } /** Gets the false branch of this expression. */ - Expr getElse() { none() } + Expr getElse() { result = this.getOrelse() } } - /** A binary expression. Not yet implemented for Python. */ + /** + * A binary expression for the shared CFG. In Python, this covers + * `and`/`or` expressions (both real 2-operand and synthetic pairs). + */ class BinaryExpr extends Expr { - BinaryExpr() { none() } + BinaryExpr() { + this instanceof Ast::BoolExpr2Node or + this instanceof Ast::BoolExprOuterNode or + this instanceof Ast::BoolExprPairNode + } /** Gets the left operand. */ - Expr getLeftOperand() { none() } + Expr getLeftOperand() { + result = this.(Ast::BoolExpr2Node).getLeftOperand() + or + result = this.(Ast::BoolExprOuterNode).getLeftOperand() + or + result = this.(Ast::BoolExprPairNode).getLeftOperand() + } /** Gets the right operand. */ - Expr getRightOperand() { none() } + Expr getRightOperand() { + result = this.(Ast::BoolExpr2Node).getRightOperand() + or + result = this.(Ast::BoolExprOuterNode).getRightOperand() + or + result = this.(Ast::BoolExprPairNode).getRightOperand() + } } - /** A short-circuiting logical AND expression. Not yet implemented for Python. */ - class LogicalAndExpr extends BinaryExpr { } + /** A short-circuiting logical `and` expression. */ + class LogicalAndExpr extends BinaryExpr { + LogicalAndExpr() { + this.(Ast::BoolExpr2Node).isAnd() or + this.(Ast::BoolExprOuterNode).isAnd() or + this.(Ast::BoolExprPairNode).isAnd() + } + } - /** A short-circuiting logical OR expression. Not yet implemented for Python. */ - class LogicalOrExpr extends BinaryExpr { } + /** A short-circuiting logical `or` expression. */ + class LogicalOrExpr extends BinaryExpr { + LogicalOrExpr() { + this.(Ast::BoolExpr2Node).isOr() or + this.(Ast::BoolExprOuterNode).isOr() or + this.(Ast::BoolExprPairNode).isOr() + } + } /** A null-coalescing expression. Python has no null-coalescing operator. */ - class NullCoalescingExpr extends BinaryExpr { } + class NullCoalescingExpr extends BinaryExpr { + NullCoalescingExpr() { none() } + } - /** A unary expression. Not yet implemented for Python. */ + /** A unary expression. Exists for the `not` subclass. */ class UnaryExpr extends Expr { - UnaryExpr() { none() } + UnaryExpr() { this instanceof Ast::NotExprNode } - /** Gets the operand. */ - Expr getOperand() { none() } + Expr getOperand() { result = this.(Ast::NotExprNode).getOperand() } } - /** A logical NOT expression. Not yet implemented for Python. */ + /** A logical `not` expression. */ class LogicalNotExpr extends UnaryExpr { } - /** A boolean literal expression. Not yet implemented for Python. */ - class BooleanLiteral extends Expr { - BooleanLiteral() { none() } - + /** A boolean literal expression (`True` or `False`). */ + class BooleanLiteral extends Expr, Ast::BoolLiteralNode { /** Gets the boolean value of this literal. */ - boolean getValue() { none() } + boolean getValue() { result = this.getBoolValue() } } } @@ -427,3 +772,15 @@ private module Input implements InputSig1, InputSig2 { import CfgCachedStage import Public + +/** + * Maps a new-CFG AST wrapper node to the corresponding Python AST node, if any. + * Entry, exit, and synthetic nodes have no corresponding Python AST node. + */ +Py::AstNode astNodeToPyNode(AstSigImpl::AstNode n) { + result = n.(Ast::ExprNode).asExpr() + or + result = n.(Ast::StmtNode).asStmt() + or + result = n.(Ast::ScopeNode).asScope() +} From 3be562929a5bba5d2815ba739e2c610ce28842a5 Mon Sep 17 00:00:00 2001 From: Taus Date: Tue, 21 Apr 2026 13:55:57 +0000 Subject: [PATCH 12/44] Python: Ignore synthetic CFG nodes We can only annotate the ones that correspond directly to AST nodes anyway. Co-authored-by: yoff --- .../evaluation-order/NewCfgImpl.qll | 41 ++++++++++++++++--- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgImpl.qll b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgImpl.qll index 8549ca1b2060..cb968c6fb603 100644 --- a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgImpl.qll +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgImpl.qll @@ -14,6 +14,10 @@ private class NewBasicBlock = CfgImpl::BasicBlock; /** New (shared) CFG implementation of the evaluation-order signature. */ module NewCfg implements EvalOrderCfgSig { class CfgNode instanceof NewControlFlowNode { + // Only include the unique representative node for each AST node, + // filtering out synthetic before/after/entry/exit/additional nodes. + CfgNode() { NewControlFlowNode.super.injects(_) } + string toString() { result = NewControlFlowNode.super.toString() } Py::Location getLocation() { result = NewControlFlowNode.super.getLocation() } @@ -22,17 +26,42 @@ module NewCfg implements EvalOrderCfgSig { result = CfgImpl::astNodeToPyNode(NewControlFlowNode.super.getAstNode()) } - CfgNode getASuccessor() { result = NewControlFlowNode.super.getASuccessor() } + CfgNode getASuccessor() { nextCfgNode(this, result) } CfgNode getAnExceptionalSuccessor() { - result = NewControlFlowNode.super.getAnExceptionSuccessor() + exists(NewControlFlowNode mid | + mid = NewControlFlowNode.super.getAnExceptionSuccessor() and + nextCfgNodeFrom(mid, result) + ) } Py::Scope getScope() { result = NewControlFlowNode.super.getEnclosingCallable().asScope() } - BasicBlock getBasicBlock() { result = NewControlFlowNode.super.getBasicBlock() } + BasicBlock getBasicBlock() { + exists(NewBasicBlock bb, int i | bb.getNode(i) = 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() } @@ -46,7 +75,9 @@ module NewCfg implements EvalOrderCfgSig { } CfgNode scopeGetEntryNode(Py::Scope s) { - result instanceof CfgImpl::ControlFlow::EntryNode and - result.getScope() = s + exists(CfgImpl::ControlFlow::EntryNode entry | + entry.getEnclosingCallable().asScope() = s and + nextCfgNodeFrom(entry, result) + ) } } From ea204ac75fcf440022ecf03985fbbdd6468877c3 Mon Sep 17 00:00:00 2001 From: Taus Date: Tue, 21 Apr 2026 14:28:04 +0000 Subject: [PATCH 13/44] Python: Support various literals Co-authored-by: yoff --- .../controlflow/internal/AstNodeImpl.qll | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll index 47df5c0f619a..2d68b2cb69b3 100644 --- a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll +++ b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll @@ -270,6 +270,59 @@ private module Ast { ExprNode getIndex() { result.asExpr() = sub.getIndex() } } + /** A tuple literal. */ + class TupleNode extends ExprNode { + private Py::Tuple tuple; + + TupleNode() { tuple = this.asExpr() } + + ExprNode getElt(int n) { result.asExpr() = tuple.getElt(n) } + } + + /** A list literal. */ + class ListNode extends ExprNode { + private Py::List list; + + ListNode() { list = this.asExpr() } + + ExprNode getElt(int n) { result.asExpr() = list.getElt(n) } + } + + /** A set literal. */ + class SetNode extends ExprNode { + private Py::Set set; + + SetNode() { set = this.asExpr() } + + ExprNode getElt(int n) { result.asExpr() = set.getElt(n) } + } + + /** A dict literal. */ + class DictNode extends ExprNode { + private Py::Dict dict; + + DictNode() { dict = this.asExpr() } + + /** + * Gets the key of the `n`th item (at child index `2*n`), and the + * value at child index `2*n + 1`. + */ + ExprNode getKey(int n) { result.asExpr() = dict.getItem(n).(Py::KeyValuePair).getKey() } + + ExprNode getValue(int n) { result.asExpr() = dict.getItem(n).(Py::KeyValuePair).getValue() } + + int getNumberOfItems() { result = count(dict.getAnItem()) } + } + + /** A unary expression other than `not` (e.g., `-x`, `+x`, `~x`). */ + class ArithmeticUnaryNode extends ExprNode { + private Py::UnaryExpr unaryExpr; + + ArithmeticUnaryNode() { unaryExpr = this.asExpr() and not unaryExpr.getOp() instanceof Py::Not } + + ExprNode getOperand() { result.asExpr() = unaryExpr.getOperand() } + } + /** * A `not` expression. This is a `UnaryExpr` whose operator is `Not`. */ @@ -454,6 +507,23 @@ module AstSigImpl implements AstSig { index = 1 and result = sub.getIndex() ) or + // Tuple, List, Set: elements left to right + result = n.(Ast::TupleNode).getElt(index) + or + result = n.(Ast::ListNode).getElt(index) + or + result = n.(Ast::SetNode).getElt(index) + or + // Dict: key(0), value(0), key(1), value(1), ... + exists(Ast::DictNode d, int item | d = n | + index = 2 * item and result = d.getKey(item) + or + index = 2 * item + 1 and result = d.getValue(item) + ) + or + // Arithmetic unary (-x, +x, ~x): operand (0) + index = 0 and result = n.(Ast::ArithmeticUnaryNode).getOperand() + or // LogicalNotExpr: operand (0) index = 0 and result = n.(Ast::NotExprNode).getOperand() or From 971beb2d89a7b8ac6a53cfbaf62429d5a3740945 Mon Sep 17 00:00:00 2001 From: Taus Date: Tue, 21 Apr 2026 14:40:24 +0000 Subject: [PATCH 14/44] Python: Assert statements Co-authored-by: yoff --- .../controlflow/internal/AstNodeImpl.qll | 56 ++++++++++++++++++- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll index 2d68b2cb69b3..a68a01f50a92 100644 --- a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll +++ b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll @@ -10,6 +10,7 @@ private import python as Py private import codeql.controlflow.ControlFlowGraph +private import codeql.controlflow.SuccessorType private module Ast { /** The newtype representing AST nodes for the shared CFG library. */ @@ -204,6 +205,17 @@ private module Ast { ContinueNode() { this.asStmt() instanceof Py::Continue } } + /** An `assert` statement. */ + class AssertNode extends StmtNode { + private Py::Assert assertStmt; + + AssertNode() { assertStmt = this.asStmt() } + + ExprNode getTest() { result.asExpr() = assertStmt.getTest() } + + ExprNode getMsg() { result.asExpr() = assertStmt.getMsg() } + } + /** A `try` statement. */ class TryNode extends StmtNode { private Py::Try tryStmt; @@ -461,6 +473,13 @@ module AstSigImpl implements AstSig { // ReturnStmt: the value (0) index = 0 and result = n.(Ast::ReturnNode).getValue() or + // Assert: test (0), message (1) + exists(Ast::AssertNode a | a = n | + index = 0 and result = a.getTest() + or + index = 1 and result = a.getMsg() + ) + or // ThrowStmt (raise): the exception (0), the cause (1) exists(Ast::RaiseNode r | r = n | index = 0 and result = r.getException() @@ -827,17 +846,50 @@ private module Input implements InputSig1, InputSig2 { string toString() { result = "label" } } + predicate inConditionalContext(AstSigImpl::AstNode n, ConditionKind kind) { + kind.isBoolean() and + n = any(Ast::AssertNode a).getTest() + } + + private string assertThrowTag() { result = "[assert-throw]" } + + predicate additionalNode(AstSigImpl::AstNode n, string tag, NormalSuccessor t) { + n instanceof Ast::AssertNode and tag = assertThrowTag() and t instanceof DirectSuccessor + } + predicate beginAbruptCompletion( AstSigImpl::AstNode ast, PreControlFlowNode n, AbruptCompletion c, boolean always ) { - none() + ast instanceof Ast::AssertNode and + n.isAdditional(ast, assertThrowTag()) and + c.asSimpleAbruptCompletion() instanceof ExceptionSuccessor and + always = true } predicate endAbruptCompletion(AstSigImpl::AstNode ast, PreControlFlowNode n, AbruptCompletion c) { none() } - predicate step(PreControlFlowNode n1, PreControlFlowNode n2) { none() } + predicate step(PreControlFlowNode n1, PreControlFlowNode n2) { + exists(Ast::AssertNode assertStmt | + n1.isBefore(assertStmt) and + n2.isBefore(assertStmt.getTest()) + or + n1.isAfterTrue(assertStmt.getTest()) and + n2.isAfter(assertStmt) + or + n1.isAfterFalse(assertStmt.getTest()) and + ( + n2.isBefore(assertStmt.getMsg()) + or + not exists(assertStmt.getMsg()) and + n2.isAdditional(assertStmt, assertThrowTag()) + ) + or + n1.isAfter(assertStmt.getMsg()) and + n2.isAdditional(assertStmt, assertThrowTag()) + ) + } } import CfgCachedStage From 66bbb60614315efbccd672e7bb0a2d7feb769d57 Mon Sep 17 00:00:00 2001 From: Taus Date: Tue, 21 Apr 2026 14:45:21 +0000 Subject: [PATCH 15/44] Python: Function calls Co-authored-by: yoff --- .../controlflow/internal/AstNodeImpl.qll | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll index a68a01f50a92..09f815297243 100644 --- a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll +++ b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll @@ -271,6 +271,25 @@ private module Ast { ExprNode getRight() { result.asExpr() = binExpr.getRight() } } + /** A call expression (`func(args...)`). */ + class CallNode extends ExprNode { + private Py::Call call; + + CallNode() { call = this.asExpr() } + + ExprNode getFunc() { result.asExpr() = call.getFunc() } + + ExprNode getPositionalArg(int n) { result.asExpr() = call.getPositionalArg(n) } + + int getNumberOfPositionalArgs() { result = count(call.getAPositionalArg()) } + + ExprNode getKeywordValue(int n) { + result.asExpr() = call.getNamedArg(n).(Py::Keyword).getValue() + } + + int getNumberOfNamedArgs() { result = count(call.getANamedArg()) } + } + /** A subscript expression (`obj[index]`). */ class SubscriptNode extends ExprNode { private Py::Subscript sub; @@ -512,6 +531,16 @@ module AstSigImpl implements AstSig { index = 2 and result = ie.getOrelse() ) or + // Call: func (0), positional args (1..n), keyword values (n+1..n+k) + exists(Ast::CallNode call | call = n | + index = 0 and result = call.getFunc() + or + result = call.getPositionalArg(index - 1) and index >= 1 + or + result = call.getKeywordValue(index - 1 - call.getNumberOfPositionalArgs()) and + index >= 1 + call.getNumberOfPositionalArgs() + ) + or // Python BinaryExpr (arithmetic, bitwise, matmul, etc.): left (0), right (1) exists(Ast::BinaryExprNode be | be = n | index = 0 and result = be.getLeft() From 2db400aebd7bb7f61c2dd1214f24049999fc67b6 Mon Sep 17 00:00:00 2001 From: Taus Date: Tue, 21 Apr 2026 14:49:13 +0000 Subject: [PATCH 16/44] Python: Attributes Co-authored-by: yoff --- .../python/controlflow/internal/AstNodeImpl.qll | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll index 09f815297243..8eab5bbb5057 100644 --- a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll +++ b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll @@ -301,6 +301,15 @@ private module Ast { ExprNode getIndex() { result.asExpr() = sub.getIndex() } } + /** An attribute access (`obj.name`). */ + class AttributeNode extends ExprNode { + private Py::Attribute attr; + + AttributeNode() { attr = this.asExpr() } + + ExprNode getObject() { result.asExpr() = attr.getObject() } + } + /** A tuple literal. */ class TupleNode extends ExprNode { private Py::Tuple tuple; @@ -555,6 +564,9 @@ module AstSigImpl implements AstSig { index = 1 and result = sub.getIndex() ) or + // Attribute (obj.name): object (0) + index = 0 and result = n.(Ast::AttributeNode).getObject() + or // Tuple, List, Set: elements left to right result = n.(Ast::TupleNode).getElt(index) or From 542efce4a62255182330614fc5aec49f98006ea6 Mon Sep 17 00:00:00 2001 From: Taus Date: Tue, 21 Apr 2026 14:53:26 +0000 Subject: [PATCH 17/44] Python: assignments Co-authored-by: yoff --- .../controlflow/internal/AstNodeImpl.qll | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll index 8eab5bbb5057..9671d6c31bbd 100644 --- a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll +++ b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll @@ -147,6 +147,39 @@ private module Ast { ExprNode getValue() { result.asExpr() = exprStmt.getValue() } } + /** An assignment statement (`x = y = expr`). */ + class AssignNode extends StmtNode { + private Py::Assign assign; + + AssignNode() { assign = this.asStmt() } + + ExprNode getValue() { result.asExpr() = assign.getValue() } + + ExprNode getTarget(int n) { result.asExpr() = assign.getTarget(n) } + + int getNumberOfTargets() { result = count(assign.getATarget()) } + } + + /** An augmented assignment statement (`x += expr`). */ + class AugAssignNode extends StmtNode { + private Py::AugAssign augAssign; + + AugAssignNode() { augAssign = this.asStmt() } + + ExprNode getOperation() { result.asExpr() = augAssign.getOperation() } + } + + /** An assignment expression / walrus operator (`x := expr`). */ + class AssignExprNode extends ExprNode { + private Py::AssignExpr assignExpr; + + AssignExprNode() { assignExpr = this.asExpr() } + + ExprNode getValue() { result.asExpr() = assignExpr.getValue() } + + ExprNode getTarget() { result.asExpr() = assignExpr.getTarget() } + } + /** A `while` statement. */ class WhileNode extends StmtNode { private Py::While whileStmt; @@ -481,6 +514,23 @@ module AstSigImpl implements AstSig { // ExprStmt: the expression (0) index = 0 and result = n.(Ast::ExprStmtNode).getValue() or + // Assign: value (0), targets (1..n) + exists(Ast::AssignNode a | a = n | + index = 0 and result = a.getValue() + or + result = a.getTarget(index - 1) and index >= 1 + ) + or + // AugAssign: the operation (0) + index = 0 and result = n.(Ast::AugAssignNode).getOperation() + or + // AssignExpr (walrus :=): value (0), target (1) + exists(Ast::AssignExprNode ae | ae = n | + index = 0 and result = ae.getValue() + or + index = 1 and result = ae.getTarget() + ) + or // WhileStmt: condition (0), body (1) // Note: Python while/else is not directly supported by the shared library. exists(Ast::WhileNode w | w = n | From 0acbb12fb928804731040ad7f0b49c077a27ddf9 Mon Sep 17 00:00:00 2001 From: Taus Date: Tue, 21 Apr 2026 15:02:11 +0000 Subject: [PATCH 18/44] Python: More simple statements Co-authored-by: yoff --- .../controlflow/internal/AstNodeImpl.qll | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll index 9671d6c31bbd..26f778130ccb 100644 --- a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll +++ b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll @@ -249,6 +249,15 @@ private module Ast { ExprNode getMsg() { result.asExpr() = assertStmt.getMsg() } } + /** A `delete` statement. */ + class DeleteNode extends StmtNode { + private Py::Delete del; + + DeleteNode() { del = this.asStmt() } + + ExprNode getTarget(int n) { result.asExpr() = del.getTarget(n) } + } + /** A `try` statement. */ class TryNode extends StmtNode { private Py::Try tryStmt; @@ -396,6 +405,86 @@ private module Ast { ExprNode getOperand() { result.asExpr() = unaryExpr.getOperand() } } + /** A comparison expression (`a < b`, `a < b < c`, etc.). */ + class CompareNode extends ExprNode { + private Py::Compare cmp; + + CompareNode() { cmp = this.asExpr() } + + ExprNode getLeft() { result.asExpr() = cmp.getLeft() } + + ExprNode getComparator(int n) { result.asExpr() = cmp.getComparator(n) } + } + + /** A slice expression (`start:stop:step`). */ + class SliceNode extends ExprNode { + private Py::Slice slice; + + SliceNode() { slice = this.asExpr() } + + ExprNode getStart() { result.asExpr() = slice.getStart() } + + ExprNode getStop() { result.asExpr() = slice.getStop() } + + ExprNode getStep() { result.asExpr() = slice.getStep() } + } + + /** A starred expression (`*x`). */ + class StarredNode extends ExprNode { + private Py::Starred starred; + + StarredNode() { starred = this.asExpr() } + + ExprNode getValue() { result.asExpr() = starred.getValue() } + } + + /** A formatted string literal (`f"...{expr}..."`). */ + class FstringNode extends ExprNode { + private Py::Fstring fstring; + + FstringNode() { fstring = this.asExpr() } + + ExprNode getValue(int n) { result.asExpr() = fstring.getValue(n) } + } + + /** A formatted value inside an f-string (`{expr}` or `{expr:spec}`). */ + class FormattedValueNode extends ExprNode { + private Py::FormattedValue fv; + + FormattedValueNode() { fv = this.asExpr() } + + ExprNode getValue() { result.asExpr() = fv.getValue() } + + ExprNode getFormatSpec() { result.asExpr() = fv.getFormatSpec() } + } + + /** A `yield` expression. */ + class YieldNode extends ExprNode { + private Py::Yield yield; + + YieldNode() { yield = this.asExpr() } + + ExprNode getValue() { result.asExpr() = yield.getValue() } + } + + /** A `yield from` expression. */ + class YieldFromNode extends ExprNode { + private Py::YieldFrom yieldFrom; + + YieldFromNode() { yieldFrom = this.asExpr() } + + ExprNode getValue() { result.asExpr() = yieldFrom.getValue() } + } + + /** An `await` expression. */ + class AwaitNode extends ExprNode { + private Py::Await await; + + AwaitNode() { await = this.asExpr() } + + ExprNode getValue() { result.asExpr() = await.getValue() } + } + /** * A `not` expression. This is a `UnaryExpr` whose operator is `Not`. */ @@ -558,6 +647,9 @@ module AstSigImpl implements AstSig { index = 1 and result = a.getMsg() ) or + // Delete: targets left to right + result = n.(Ast::DeleteNode).getTarget(index) + or // ThrowStmt (raise): the exception (0), the cause (1) exists(Ast::RaiseNode r | r = n | index = 0 and result = r.getException() @@ -634,6 +726,44 @@ module AstSigImpl implements AstSig { // Arithmetic unary (-x, +x, ~x): operand (0) index = 0 and result = n.(Ast::ArithmeticUnaryNode).getOperand() or + // Compare (a < b < c): left (0), comparators (1..n) + exists(Ast::CompareNode cmp | cmp = n | + index = 0 and result = cmp.getLeft() + or + result = cmp.getComparator(index - 1) and index >= 1 + ) + or + // Slice (start:stop:step): start (0), stop (1), step (2) + exists(Ast::SliceNode sl | sl = n | + index = 0 and result = sl.getStart() + or + index = 1 and result = sl.getStop() + or + index = 2 and result = sl.getStep() + ) + or + // Starred (*x): value (0) + index = 0 and result = n.(Ast::StarredNode).getValue() + or + // Fstring: values left to right + result = n.(Ast::FstringNode).getValue(index) + or + // FormattedValue ({expr} or {expr:spec}): value (0), format spec (1) + exists(Ast::FormattedValueNode fv | fv = n | + index = 0 and result = fv.getValue() + or + index = 1 and result = fv.getFormatSpec() + ) + or + // Yield: value (0) + index = 0 and result = n.(Ast::YieldNode).getValue() + or + // YieldFrom: value (0) + index = 0 and result = n.(Ast::YieldFromNode).getValue() + or + // Await: value (0) + index = 0 and result = n.(Ast::AwaitNode).getValue() + or // LogicalNotExpr: operand (0) index = 0 and result = n.(Ast::NotExprNode).getOperand() or From b229066891bab3ecc52aa3bfe534c5886493bb41 Mon Sep 17 00:00:00 2001 From: Taus Date: Tue, 21 Apr 2026 15:05:48 +0000 Subject: [PATCH 19/44] Python: Add `with` Co-authored-by: yoff --- .../controlflow/internal/AstNodeImpl.qll | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll index 26f778130ccb..18bdeca4e317 100644 --- a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll +++ b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll @@ -228,6 +228,19 @@ private module Ast { ExprNode getCause() { result.asExpr() = raise.getCause() } } + /** A `with` statement. */ + class WithNode extends StmtNode { + private Py::With withStmt; + + WithNode() { withStmt = this.asStmt() } + + ExprNode getContextExpr() { result.asExpr() = withStmt.getContextExpr() } + + ExprNode getOptionalVars() { result.asExpr() = withStmt.getOptionalVars() } + + StmtListNode getBody() { result.asStmtList() = withStmt.getBody() } + } + /** A `break` statement. */ class BreakNode extends StmtNode { BreakNode() { this.asStmt() instanceof Py::Break } @@ -650,6 +663,15 @@ module AstSigImpl implements AstSig { // Delete: targets left to right result = n.(Ast::DeleteNode).getTarget(index) or + // With: context expr (0), optional vars (1), body (2) + exists(Ast::WithNode w | w = n | + index = 0 and result = w.getContextExpr() + or + index = 1 and result = w.getOptionalVars() + or + index = 2 and result = w.getBody() + ) + or // ThrowStmt (raise): the exception (0), the cause (1) exists(Ast::RaiseNode r | r = n | index = 0 and result = r.getException() From 1f82dbc583d1de6778f5f321a5391561ccdcc011 Mon Sep 17 00:00:00 2001 From: Taus Date: Tue, 21 Apr 2026 15:09:40 +0000 Subject: [PATCH 20/44] Python: Comprehensions Co-authored-by: yoff --- .../controlflow/internal/AstNodeImpl.qll | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll index 18bdeca4e317..cd41a0e1c3c4 100644 --- a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll +++ b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll @@ -418,6 +418,27 @@ private module Ast { ExprNode getOperand() { result.asExpr() = unaryExpr.getOperand() } } + /** + * A comprehension or generator expression. + * The iterable is evaluated in the enclosing scope; the body runs in a + * nested synthetic function scope handled by its own CFG. + */ + class ComprehensionNode extends ExprNode { + private Py::Expr iterable; + + ComprehensionNode() { + iterable = this.asExpr().(Py::ListComp).getIterable() + or + iterable = this.asExpr().(Py::SetComp).getIterable() + or + iterable = this.asExpr().(Py::DictComp).getIterable() + or + iterable = this.asExpr().(Py::GeneratorExp).getIterable() + } + + ExprNode getIterable() { result.asExpr() = iterable } + } + /** A comparison expression (`a < b`, `a < b < c`, etc.). */ class CompareNode extends ExprNode { private Py::Compare cmp; @@ -731,6 +752,9 @@ module AstSigImpl implements AstSig { // Attribute (obj.name): object (0) index = 0 and result = n.(Ast::AttributeNode).getObject() or + // Comprehension/generator: iterable (0) + index = 0 and result = n.(Ast::ComprehensionNode).getIterable() + or // Tuple, List, Set: elements left to right result = n.(Ast::TupleNode).getElt(index) or From 53da31bd154531b3d06ff7d7a36662855b98131f Mon Sep 17 00:00:00 2001 From: Taus Date: Tue, 21 Apr 2026 15:18:27 +0000 Subject: [PATCH 21/44] Python: More nodes Not entirely sure about the `else:` blocks. Co-authored-by: yoff --- .../controlflow/internal/AstNodeImpl.qll | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll index cd41a0e1c3c4..7bf4f1514b0d 100644 --- a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll +++ b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll @@ -519,6 +519,37 @@ private module Ast { ExprNode getValue() { result.asExpr() = await.getValue() } } + /** A class definition expression (has base classes evaluated at definition time). */ + class ClassExprNode extends ExprNode { + private Py::ClassExpr classExpr; + + ClassExprNode() { classExpr = this.asExpr() } + + ExprNode getBase(int n) { result.asExpr() = classExpr.getBase(n) } + } + + /** A function definition expression (has default args evaluated at definition time). */ + class FunctionExprNode extends ExprNode { + private Py::FunctionExpr funcExpr; + + FunctionExprNode() { funcExpr = this.asExpr() } + + ExprNode getDefault(int n) { result.asExpr() = funcExpr.getArgs().getDefault(n) } + + ExprNode getKwDefault(int n) { result.asExpr() = funcExpr.getArgs().getKwDefault(n) } + } + + /** A lambda expression (has default args evaluated at definition time). */ + class LambdaNode extends ExprNode { + private Py::Lambda lambda; + + LambdaNode() { lambda = this.asExpr() } + + ExprNode getDefault(int n) { result.asExpr() = lambda.getArgs().getDefault(n) } + + ExprNode getKwDefault(int n) { result.asExpr() = lambda.getArgs().getKwDefault(n) } + } + /** * A `not` expression. This is a `UnaryExpr` whose operator is `Not`. */ @@ -810,6 +841,27 @@ module AstSigImpl implements AstSig { // Await: value (0) index = 0 and result = n.(Ast::AwaitNode).getValue() or + // ClassExpr: base classes left to right + result = n.(Ast::ClassExprNode).getBase(index) + or + // FunctionExpr: defaults left to right, then kw defaults + exists(Ast::FunctionExprNode fe | fe = n | + result = fe.getDefault(index) + or + result = + fe.getKwDefault(index - + count(Py::Expr d | d = fe.asExpr().(Py::FunctionExpr).getArgs().getADefault())) + ) + or + // Lambda: defaults left to right, then kw defaults + exists(Ast::LambdaNode lam | lam = n | + result = lam.getDefault(index) + or + result = + lam.getKwDefault(index - + count(Py::Expr d | d = lam.asExpr().(Py::Lambda).getArgs().getADefault())) + ) + or // LogicalNotExpr: operand (0) index = 0 and result = n.(Ast::NotExprNode).getOperand() or @@ -1156,6 +1208,29 @@ private module Input implements InputSig1, InputSig2 { n1.isAfter(assertStmt.getMsg()) and n2.isAdditional(assertStmt, assertThrowTag()) ) + or + // While/else: when the condition is false, flow to the else block + // (if present) before the after-while node. + exists(Ast::WhileNode w, Ast::StmtListNode orelse | orelse = w.getOrelse() | + n1.isAfterFalse(w.getTest()) and + n2.isBefore(orelse) + or + n1.isAfter(orelse) and + n2.isAfter(w) + ) + or + // For/else: when the collection is empty or the loop completes normally, + // flow through the else block before the after-for node. + exists(Ast::ForNode f, Ast::StmtListNode orelse | orelse = f.getOrelse() | + n1.isAfterValue(f.getIter(), any(EmptinessSuccessor t | t.getValue() = true)) and + n2.isBefore(orelse) + or + n1.isAfter(f.getBody()) and + n2.isBefore(orelse) + or + n1.isAfter(orelse) and + n2.isAfter(f) + ) } } From 0e1f1d9f09fb22c42e94347c810bde02bb38e019 Mon Sep 17 00:00:00 2001 From: Taus Date: Tue, 21 Apr 2026 15:28:11 +0000 Subject: [PATCH 22/44] Python: Support `match` Co-authored-by: yoff --- .../controlflow/internal/AstNodeImpl.qll | 52 ++++++++++++++----- 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll index 7bf4f1514b0d..ae2cd1d5a69e 100644 --- a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll +++ b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll @@ -271,6 +271,30 @@ private module Ast { ExprNode getTarget(int n) { result.asExpr() = del.getTarget(n) } } + /** A `match` statement. */ + class MatchStmtNode extends StmtNode { + private Py::MatchStmt matchStmt; + + MatchStmtNode() { matchStmt = this.asStmt() } + + ExprNode getSubject() { result.asExpr() = matchStmt.getSubject() } + + CaseNode getCase(int n) { result.asStmt() = matchStmt.getCase(n) } + } + + /** A `case` clause in a match statement. */ + class CaseNode extends StmtNode { + private Py::Case caseStmt; + + CaseNode() { caseStmt = this.asStmt() } + + ExprNode getGuard() { result.asExpr() = caseStmt.getGuard().(Py::Guard).getTest() } + + StmtListNode getBody() { result.asStmtList() = caseStmt.getBody() } + + predicate isWildcard() { caseStmt.getPattern() instanceof Py::MatchWildcardPattern } + } + /** A `try` statement. */ class TryNode extends StmtNode { private Py::Try tryStmt; @@ -1035,31 +1059,33 @@ module AstSigImpl implements AstSig { Stmt getBody() { result = this.(Ast::ExceptionHandlerNode).getBody() } } - // ===== Switch/match — stubs for now ===== - /** A switch/match statement. Not yet implemented for Python. */ - class Switch extends AstNode { - Switch() { none() } + // ===== Switch/match ===== + /** A `match` statement, mapped to the shared CFG's `Switch`. */ + class Switch extends Stmt { + Switch() { this instanceof Ast::MatchStmtNode } - Expr getExpr() { none() } + Expr getExpr() { result = this.(Ast::MatchStmtNode).getSubject() } - Case getCase(int index) { none() } + Case getCase(int index) { result = this.(Ast::MatchStmtNode).getCase(index) } Stmt getStmt(int index) { none() } } - /** A case in a switch/match. Not yet implemented for Python. */ - class Case extends AstNode { - Case() { none() } + /** A `case` clause in a match statement. */ + class Case extends Stmt { + Case() { this instanceof Ast::CaseNode } AstNode getAPattern() { none() } - Expr getGuard() { none() } + Expr getGuard() { result = this.(Ast::CaseNode).getGuard() } - AstNode getBody() { none() } + AstNode getBody() { result = this.(Ast::CaseNode).getBody() } } - /** A default case. Not yet implemented for Python. */ - class DefaultCase extends Case { } + /** A wildcard case (`case _:`). */ + class DefaultCase extends Case { + DefaultCase() { this.(Ast::CaseNode).isWildcard() } + } // ===== Expression types ===== /** A conditional expression (`x if cond else y`). */ From f85b532bb3cbbe508030999ae8d1f365125ba8e1 Mon Sep 17 00:00:00 2001 From: Taus Date: Tue, 21 Apr 2026 15:36:54 +0000 Subject: [PATCH 23/44] Python: Fix match Co-authored-by: yoff --- .../python/controlflow/internal/AstNodeImpl.qll | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll index ae2cd1d5a69e..d027e5184755 100644 --- a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll +++ b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll @@ -762,6 +762,20 @@ module AstSigImpl implements AstSig { result = t.getHandler(index - 1) and index >= 1 ) or + // MatchStmt: subject (0), cases (1..n) + exists(Ast::MatchStmtNode m | m = n | + index = 0 and result = m.getSubject() + or + result = m.getCase(index - 1) and index >= 1 + ) + or + // Case: guard (0), body (1) + exists(Ast::CaseNode c | c = n | + index = 0 and result = c.getGuard() + or + index = 1 and result = c.getBody() + ) + or // CatchClause (except handler): type (0), name (1), body (2) exists(Ast::ExceptionHandlerNode h | h = n | index = 0 and result = h.getType() From 04b8c4bc7e3a7a27c283d58e6cced17862eb2642 Mon Sep 17 00:00:00 2001 From: Taus Date: Tue, 21 Apr 2026 16:00:46 +0000 Subject: [PATCH 24/44] Python: Fix exception issue Co-authored-by: yoff --- .../ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll | 2 ++ 1 file changed, 2 insertions(+) diff --git a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll index d027e5184755..c57cd6973732 100644 --- a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll +++ b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll @@ -760,6 +760,8 @@ module AstSigImpl implements AstSig { index = 0 and result = t.getBody() or result = t.getHandler(index - 1) and index >= 1 + or + index = -1 and result = t.getFinalbody() ) or // MatchStmt: subject (0), cases (1..n) From e3155ea54420c7508ec157f4be3d9f8c60f7932a Mon Sep 17 00:00:00 2001 From: Taus Date: Tue, 21 Apr 2026 16:03:38 +0000 Subject: [PATCH 25/44] Python: Handle dict unpacking in calls Co-authored-by: yoff --- .../ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll | 2 ++ 1 file changed, 2 insertions(+) diff --git a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll index c57cd6973732..c5e2d010688c 100644 --- a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll +++ b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll @@ -364,6 +364,8 @@ private module Ast { ExprNode getKeywordValue(int n) { result.asExpr() = call.getNamedArg(n).(Py::Keyword).getValue() + or + result.asExpr() = call.getNamedArg(n).(Py::DictUnpacking).getValue() } int getNumberOfNamedArgs() { result = count(call.getANamedArg()) } From 1af415bec336069e4032c6510287db255b3146d5 Mon Sep 17 00:00:00 2001 From: Taus Date: Tue, 21 Apr 2026 16:19:00 +0000 Subject: [PATCH 26/44] WIP --- .../AnnotationHasCfgNode.expected | 1 + .../evaluation-order/AnnotationHasCfgNode.ql | 16 +++++++ .../NewCfgAnnotationHasCfgNode.expected | 1 + .../NewCfgAnnotationHasCfgNode.ql | 18 ++++++++ .../NewCfgConsecutiveTimestamps.expected | 1 + .../evaluation-order/NewCfgImpl.qll | 9 ++-- .../NewCfgNoBasicBlock.expected | 1 + .../evaluation-order/NewCfgNoBasicBlock.ql | 18 ++++++++ .../evaluation-order/NoBasicBlock.expected | 1 + .../evaluation-order/NoBasicBlock.ql | 16 +++++++ .../evaluation-order/TimerUtils.qll | 45 ++++++++++++++----- 11 files changed, 112 insertions(+), 15 deletions(-) create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/AnnotationHasCfgNode.expected create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/AnnotationHasCfgNode.ql create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgAnnotationHasCfgNode.expected create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgAnnotationHasCfgNode.ql create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgNoBasicBlock.expected create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgNoBasicBlock.ql create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/NoBasicBlock.expected create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/NoBasicBlock.ql diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/AnnotationHasCfgNode.expected b/python/ql/test/library-tests/ControlFlow/evaluation-order/AnnotationHasCfgNode.expected new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/AnnotationHasCfgNode.expected @@ -0,0 +1 @@ + diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/AnnotationHasCfgNode.ql b/python/ql/test/library-tests/ControlFlow/evaluation-order/AnnotationHasCfgNode.ql new file mode 100644 index 000000000000..5311d118576b --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/AnnotationHasCfgNode.ql @@ -0,0 +1,16 @@ +/** + * Checks that every timer annotation has a corresponding CFG node. + */ + +import python +import TimerUtils +import OldCfgImpl + +private module Utils = EvalOrderCfgUtils; + +private import Utils::CfgTests + +from TimerAnnotation ann +where annotationWithoutCfgNode(ann) +select ann, "Annotation in $@ has no CFG node", ann.getTestFunction(), + ann.getTestFunction().getName() diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgAnnotationHasCfgNode.expected b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgAnnotationHasCfgNode.expected new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgAnnotationHasCfgNode.expected @@ -0,0 +1 @@ + diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgAnnotationHasCfgNode.ql b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgAnnotationHasCfgNode.ql new file mode 100644 index 000000000000..4b1d82e27e67 --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgAnnotationHasCfgNode.ql @@ -0,0 +1,18 @@ +/** + * New-CFG version of AnnotationHasCfgNode. + * + * Checks that every timer annotation has a corresponding CFG node. + */ + +import python +import TimerUtils +import NewCfgImpl + +private module Utils = EvalOrderCfgUtils; + +private import Utils::CfgTests + +from TimerAnnotation ann +where annotationWithoutCfgNode(ann) +select ann, "Annotation in $@ has no CFG node", ann.getTestFunction(), + ann.getTestFunction().getName() diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgConsecutiveTimestamps.expected b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgConsecutiveTimestamps.expected index e69de29bb2d1..bce948bb58a5 100644 --- a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgConsecutiveTimestamps.expected +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgConsecutiveTimestamps.expected @@ -0,0 +1 @@ +| test_if.py:51:9:51:9 | IntegerLiteral | $@ in $@ has no consecutive successor (expected 6) | test_if.py:51:15:51:15 | IntegerLiteral | Timestamp 5 | test_if.py:43:1:43:31 | Function test_if_elif_else_first | test_if_elif_else_first | diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgImpl.qll b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgImpl.qll index cb968c6fb603..97c6a9c043fa 100644 --- a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgImpl.qll +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgImpl.qll @@ -14,9 +14,12 @@ private class NewBasicBlock = CfgImpl::BasicBlock; /** New (shared) CFG implementation of the evaluation-order signature. */ module NewCfg implements EvalOrderCfgSig { class CfgNode instanceof NewControlFlowNode { - // Only include the unique representative node for each AST node, - // filtering out synthetic before/after/entry/exit/additional nodes. - CfgNode() { NewControlFlowNode.super.injects(_) } + // Use the post-order representative for each AST node: the "after" node. + // For simple leaf nodes this is the merged before/after node. For + // post-order expressions this is the TAstNode. For pre-order expressions + // (and/or/not/ternary) this uses an AfterValueNode, which places the + // expression after its operands — matching the timer test expectations. + CfgNode() { NewControlFlowNode.super.isAfter(_) } string toString() { result = NewControlFlowNode.super.toString() } diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgNoBasicBlock.expected b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgNoBasicBlock.expected new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgNoBasicBlock.expected @@ -0,0 +1 @@ + diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgNoBasicBlock.ql b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgNoBasicBlock.ql new file mode 100644 index 000000000000..e07890f72502 --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgNoBasicBlock.ql @@ -0,0 +1,18 @@ +/** + * New-CFG version of NoBasicBlock. + * + * Checks that every annotated CFG node belongs to a basic block. + */ + +import python +import TimerUtils +import NewCfgImpl + +private module Utils = EvalOrderCfgUtils; + +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() diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NoBasicBlock.expected b/python/ql/test/library-tests/ControlFlow/evaluation-order/NoBasicBlock.expected new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/NoBasicBlock.expected @@ -0,0 +1 @@ + diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NoBasicBlock.ql b/python/ql/test/library-tests/ControlFlow/evaluation-order/NoBasicBlock.ql new file mode 100644 index 000000000000..5568bd2a9a4a --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/NoBasicBlock.ql @@ -0,0 +1,16 @@ +/** + * Checks that every annotated CFG node belongs to a basic block. + */ + +import python +import TimerUtils +import OldCfgImpl + +private module Utils = EvalOrderCfgUtils; + +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() diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/TimerUtils.qll b/python/ql/test/library-tests/ControlFlow/evaluation-order/TimerUtils.qll index 7d9329155b5f..dc46f00f6f56 100644 --- a/python/ql/test/library-tests/ControlFlow/evaluation-order/TimerUtils.qll +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/TimerUtils.qll @@ -92,7 +92,7 @@ class TimerAnnotation extends TTimerAnnotation { abstract Expr getAnnotatedExpr(); /** Gets the enclosing annotation expression (the `BinaryExpr` or `Call`). */ - abstract Expr getExpr(); + abstract Expr getTimerExpr(); /** Holds if this is a dead-code annotation (`t.dead[n]`). */ predicate isDead() { this instanceof DeadTimerAnnotation } @@ -100,9 +100,9 @@ class TimerAnnotation extends TTimerAnnotation { /** Holds if this is a never-evaluated annotation (`t.never`). */ predicate isNever() { this instanceof NeverTimerAnnotation } - string toString() { result = this.getExpr().toString() } + string toString() { result = this.getAnnotatedExpr().toString() } - Location getLocation() { result = this.getExpr().getLocation() } + Location getLocation() { result = this.getAnnotatedExpr().getLocation() } } /** A matmul-based timer annotation: `expr @ t[n]`. */ @@ -119,7 +119,7 @@ class MatmulTimerAnnotation extends TMatmulAnnotation, TimerAnnotation { override Expr getAnnotatedExpr() { result = annotated } - override BinaryExpr getExpr() { result.getLeft() = annotated } + override BinaryExpr getTimerExpr() { result.getLeft() = annotated } } /** A call-based timer annotation: `t(expr, n)`. */ @@ -136,7 +136,7 @@ class CallTimerAnnotation extends TCallAnnotation, TimerAnnotation { override Expr getAnnotatedExpr() { result = annotated } - override Call getExpr() { result.getArg(0) = annotated } + override Call getTimerExpr() { result.getArg(0) = annotated } } /** A dead-code timer annotation: `expr @ t.dead[n]`. */ @@ -153,7 +153,7 @@ class DeadTimerAnnotation extends TDeadAnnotation, TimerAnnotation { override Expr getAnnotatedExpr() { result = annotated } - override BinaryExpr getExpr() { result.getLeft() = annotated } + override BinaryExpr getTimerExpr() { result.getLeft() = annotated } } /** A never-evaluated annotation: `expr @ t.never`. */ @@ -169,7 +169,7 @@ class NeverTimerAnnotation extends TNeverAnnotation, TimerAnnotation { override Expr getAnnotatedExpr() { result = annotated } - override BinaryExpr getExpr() { result.getLeft() = annotated } + override BinaryExpr getTimerExpr() { result.getLeft() = annotated } } /** @@ -240,7 +240,7 @@ module EvalOrderCfgUtils { class TimerCfgNode extends CfgNode { private TimerAnnotation annot; - TimerCfgNode() { annot.getExpr() = this.getNode() } + TimerCfgNode() { annot.getAnnotatedExpr() = this.getNode() } /** Gets a timestamp value from this annotation. */ int getATimestamp() { result = annot.getATimestamp() } @@ -322,7 +322,7 @@ module EvalOrderCfgUtils { private predicate hasNestedScopeAnnotation(TestFunction f) { exists(TimerAnnotation a | a.getTestFunction() = f and - a.getExpr().getScope() != f + a.getAnnotatedExpr().getScope() != f ) } @@ -335,7 +335,7 @@ module EvalOrderCfgUtils { not ann.isDead() and a = ann.getATimestamp() and not exists(TimerCfgNode x, TimerCfgNode y | - ann.getExpr() = x.getNode() and + ann.getAnnotatedExpr() = x.getNode() and nextTimerAnnotation(x, y) and (a + 1) = y.getATimestamp() ) and @@ -354,7 +354,7 @@ module EvalOrderCfgUtils { */ predicate neverReachable(NeverTimerAnnotation ann) { exists(CfgNode n, Scope s | - n.getNode() = ann.getExpr() and + n.getNode() = ann.getAnnotatedExpr() and s = n.getScope() and ( // Reachable via inter-block path (includes same block) @@ -417,6 +417,27 @@ module EvalOrderCfgUtils { minB = min(b.getATimestamp()) and maxA >= minB } + + /** + * Holds if CFG node `n` in test function `f` does not belong to any basic block. + */ + predicate noBasicBlock(CfgNode n, TestFunction f) { + n.getScope() = f and + not exists(n.getBasicBlock()) + } + + /** + * Holds if non-dead annotation `ann` has no corresponding CFG node. + */ + predicate annotationWithoutCfgNode(TimerAnnotation ann) { + not ann.isDead() and + not ann.isNever() and + not exists(CfgNode n | n.getNode() = ann.getAnnotatedExpr()) + } + + predicate annotationWithCfgNode(TimerAnnotation ann) { + exists(CfgNode n | n.getNode() = ann.getAnnotatedExpr()) + } } } @@ -427,7 +448,7 @@ module EvalOrderCfgUtils { predicate isTimerMechanism(Expr e, TestFunction f) { exists(TimerAnnotation a | a.getTestFunction() = f and - e = a.getExpr().getASubExpression*() + e = a.getTimerExpr().getASubExpression*() ) } From a33b49a3f3680a30d9aa190af47b302c3b0427ed Mon Sep 17 00:00:00 2001 From: Taus Date: Tue, 28 Apr 2026 14:12:13 +0000 Subject: [PATCH 27/44] WIP2 --- .../BasicBlockOrdering.expected | 14 +- .../ConsecutiveTimestamps.expected | 10 +- .../evaluation-order/NeverReachable.expected | 4 +- .../evaluation-order/NeverReachable.ql | 2 +- .../NewCfgBranchTimestamps.expected | 282 ++++++++++++++++++ .../NewCfgBranchTimestamps.ql | 23 ++ ...gConsecutivePredecessorTimestamps.expected | 1 + .../NewCfgConsecutivePredecessorTimestamps.ql | 22 ++ .../NewCfgConsecutiveTimestamps.expected | 1 - .../evaluation-order/NewCfgImpl.qll | 15 + .../evaluation-order/NewCfgNeverReachable.ql | 2 +- .../evaluation-order/NoBackwardFlow.expected | 10 + .../evaluation-order/StrictForward.expected | 10 + .../evaluation-order/TimerUtils.qll | 225 ++++++++++---- .../evaluation-order/test_assert_raise.py | 4 +- .../evaluation-order/test_basic.py | 4 +- .../evaluation-order/test_boolean.py | 16 +- .../evaluation-order/test_conditional.py | 14 +- .../ControlFlow/evaluation-order/test_if.py | 34 ++- .../evaluation-order/test_loops.py | 10 +- .../evaluation-order/test_match.py | 34 +-- .../ControlFlow/evaluation-order/test_try.py | 18 +- .../ControlFlow/evaluation-order/timer.py | 94 +++--- 23 files changed, 668 insertions(+), 181 deletions(-) create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgBranchTimestamps.expected create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgBranchTimestamps.ql create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgConsecutivePredecessorTimestamps.expected create mode 100644 python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgConsecutivePredecessorTimestamps.ql diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/BasicBlockOrdering.expected b/python/ql/test/library-tests/ControlFlow/evaluation-order/BasicBlockOrdering.expected index 573094ddf734..80fa3350282f 100644 --- a/python/ql/test/library-tests/ControlFlow/evaluation-order/BasicBlockOrdering.expected +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/BasicBlockOrdering.expected @@ -1 +1,13 @@ -| test_comprehensions.py:21:29:21:40 | ControlFlowNode for BinaryExpr | Basic block ordering: $@ appears before $@ | test_comprehensions.py:21:35:21:35 | IntegerLiteral | timestamp 9 | test_comprehensions.py:21:21:21:21 | IntegerLiteral | timestamp 8 | +| test_boolean.py:9:10:9:43 | ControlFlowNode for BoolExpr | Basic block ordering: $@ appears before $@ | test_boolean.py:9:59:9:59 | IntegerLiteral | timestamp 2 | test_boolean.py:9:19:9:19 | IntegerLiteral | timestamp 0 | +| test_boolean.py:15:10:15:43 | ControlFlowNode for BoolExpr | Basic block ordering: $@ appears before $@ | test_boolean.py:15:50:15:50 | IntegerLiteral | timestamp 1 | test_boolean.py:15:20:15:20 | IntegerLiteral | timestamp 0 | +| test_boolean.py:21:10:21:42 | ControlFlowNode for BoolExpr | Basic block ordering: $@ appears before $@ | test_boolean.py:21:49:21:49 | IntegerLiteral | timestamp 1 | test_boolean.py:21:19:21:19 | IntegerLiteral | timestamp 0 | +| test_boolean.py:27:10:27:43 | ControlFlowNode for BoolExpr | Basic block ordering: $@ appears before $@ | test_boolean.py:27:59:27:59 | IntegerLiteral | timestamp 2 | test_boolean.py:27:20:27:20 | IntegerLiteral | timestamp 0 | +| test_boolean.py:40:10:40:61 | ControlFlowNode for BoolExpr | Basic block ordering: $@ appears before $@ | test_boolean.py:40:86:40:86 | IntegerLiteral | timestamp 3 | test_boolean.py:40:16:40:16 | IntegerLiteral | timestamp 0 | +| test_boolean.py:46:10:46:61 | ControlFlowNode for BoolExpr | Basic block ordering: $@ appears before $@ | test_boolean.py:46:86:46:86 | IntegerLiteral | timestamp 3 | test_boolean.py:46:16:46:16 | IntegerLiteral | timestamp 0 | +| test_boolean.py:52:10:52:95 | ControlFlowNode for BoolExpr | Basic block ordering: $@ appears before $@ | test_boolean.py:52:120:52:120 | IntegerLiteral | timestamp 4 | test_boolean.py:52:20:52:20 | IntegerLiteral | timestamp 0 | +| test_boolean.py:52:10:52:95 | ControlFlowNode for BoolExpr | Basic block ordering: $@ appears before $@ | test_boolean.py:52:120:52:120 | IntegerLiteral | timestamp 4 | test_boolean.py:52:63:52:63 | IntegerLiteral | timestamp 2 | +| test_boolean.py:52:11:52:47 | ControlFlowNode for BoolExpr | Basic block ordering: $@ appears before $@ | test_boolean.py:52:63:52:63 | IntegerLiteral | timestamp 2 | test_boolean.py:52:20:52:20 | IntegerLiteral | timestamp 0 | +| test_boolean.py:64:10:64:52 | ControlFlowNode for BoolExpr | Basic block ordering: $@ appears before $@ | test_boolean.py:64:59:64:59 | IntegerLiteral | timestamp 6 | test_boolean.py:64:17:64:17 | IntegerLiteral | timestamp 0 | +| test_boolean.py:64:10:64:52 | ControlFlowNode for BoolExpr | Basic block ordering: $@ appears before $@ | test_boolean.py:64:59:64:59 | IntegerLiteral | timestamp 6 | test_boolean.py:64:27:64:27 | IntegerLiteral | timestamp 2 | +| test_boolean.py:76:10:76:51 | ControlFlowNode for BoolExpr | Basic block ordering: $@ appears before $@ | test_boolean.py:76:58:76:58 | IntegerLiteral | timestamp 6 | test_boolean.py:76:17:76:17 | IntegerLiteral | timestamp 0 | +| test_boolean.py:76:10:76:51 | ControlFlowNode for BoolExpr | Basic block ordering: $@ appears before $@ | test_boolean.py:76:58:76:58 | IntegerLiteral | timestamp 6 | test_boolean.py:76:27:76:27 | IntegerLiteral | timestamp 2 | diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/ConsecutiveTimestamps.expected b/python/ql/test/library-tests/ControlFlow/evaluation-order/ConsecutiveTimestamps.expected index e20e20c464d4..e8071c044213 100644 --- a/python/ql/test/library-tests/ControlFlow/evaluation-order/ConsecutiveTimestamps.expected +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/ConsecutiveTimestamps.expected @@ -1 +1,9 @@ -| test_if.py:51:9:51:16 | BinaryExpr | $@ in $@ has no consecutive successor (expected 6) | test_if.py:51:15:51:15 | IntegerLiteral | Timestamp 5 | test_if.py:43:1:43:31 | Function test_if_elif_else_first | test_if_elif_else_first | +| test_boolean.py:9:26:9:27 | IntegerLiteral | $@ in $@ has no consecutive successor (expected 2) | test_boolean.py:9:33:9:33 | IntegerLiteral | Timestamp 1 | test_boolean.py:7:1:7:27 | Function test_and_both_sides | test_and_both_sides | +| test_boolean.py:15:10:15:14 | False | $@ in $@ has no consecutive successor (expected 1) | test_boolean.py:15:20:15:20 | IntegerLiteral | Timestamp 0 | test_boolean.py:13:1:13:30 | Function test_and_short_circuit | test_and_short_circuit | +| test_boolean.py:21:10:21:13 | True | $@ in $@ has no consecutive successor (expected 1) | test_boolean.py:21:19:21:19 | IntegerLiteral | Timestamp 0 | test_boolean.py:19:1:19:29 | Function test_or_short_circuit | test_or_short_circuit | +| test_boolean.py:27:26:27:27 | IntegerLiteral | $@ in $@ has no consecutive successor (expected 2) | test_boolean.py:27:33:27:33 | IntegerLiteral | Timestamp 1 | test_boolean.py:25:1:25:26 | Function test_or_both_sides | test_or_both_sides | +| test_boolean.py:40:45:40:45 | IntegerLiteral | $@ in $@ has no consecutive successor (expected 3) | test_boolean.py:40:51:40:51 | IntegerLiteral | Timestamp 2 | test_boolean.py:38:1:38:24 | Function test_chained_and | test_chained_and | +| test_boolean.py:46:44:46:45 | IntegerLiteral | $@ in $@ has no consecutive successor (expected 3) | test_boolean.py:46:51:46:51 | IntegerLiteral | Timestamp 2 | test_boolean.py:44:1:44:23 | Function test_chained_or | test_chained_or | +| 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 | diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NeverReachable.expected b/python/ql/test/library-tests/ControlFlow/evaluation-order/NeverReachable.expected index 200ebdbc6a74..874a7dfb0960 100644 --- a/python/ql/test/library-tests/ControlFlow/evaluation-order/NeverReachable.expected +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/NeverReachable.expected @@ -1,2 +1,2 @@ -| test_match.py:159:13:159:23 | BinaryExpr | Node annotated with t.never is reachable in $@ | test_match.py:151:1:151:42 | Function test_match_exhaustive_return_first | test_match_exhaustive_return_first | -| test_match.py:172:13:172:23 | BinaryExpr | Node annotated with t.never is reachable in $@ | test_match.py:164:1:164:45 | Function test_match_exhaustive_return_wildcard | test_match_exhaustive_return_wildcard | +| test_match.py:159:13:159:13 | IntegerLiteral | Node annotated with t.never is reachable in $@ | test_match.py:151:1:151:42 | Function test_match_exhaustive_return_first | test_match_exhaustive_return_first | +| test_match.py:172:13:172:13 | IntegerLiteral | Node annotated with t.never is reachable in $@ | test_match.py:164:1:164:45 | Function test_match_exhaustive_return_wildcard | test_match_exhaustive_return_wildcard | diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NeverReachable.ql b/python/ql/test/library-tests/ControlFlow/evaluation-order/NeverReachable.ql index db55c1d92e4b..b09a936a0a40 100644 --- a/python/ql/test/library-tests/ControlFlow/evaluation-order/NeverReachable.ql +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/NeverReachable.ql @@ -12,7 +12,7 @@ private module Utils = EvalOrderCfgUtils; private import Utils::CfgTests -from NeverTimerAnnotation ann +from TimerAnnotation ann where neverReachable(ann) select ann, "Node annotated with t.never is reachable in $@", ann.getTestFunction(), ann.getTestFunction().getName() diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgBranchTimestamps.expected b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgBranchTimestamps.expected new file mode 100644 index 000000000000..fcc9a17aa746 --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgBranchTimestamps.expected @@ -0,0 +1,282 @@ +| test_assert_raise.py:51:20:51:53 | After BinaryExpr() | Timestamp 3 on true/false branch is missing a dead() annotation on the false successor in $@ | test_assert_raise.py:48:1:48:25 | Function test_bare_reraise | test_bare_reraise | +| test_assert_raise.py:51:20:51:53 | After BinaryExpr() | Timestamp 3 on true/false branch is missing a dead() annotation on the true successor in $@ | test_assert_raise.py:48:1:48:25 | Function test_bare_reraise | test_bare_reraise | +| test_assert_raise.py:51:20:51:53 | After BinaryExpr() | Timestamp 4 on true/false branch is missing a dead() annotation on the false successor in $@ | test_assert_raise.py:48:1:48:25 | Function test_bare_reraise | test_bare_reraise | +| test_assert_raise.py:51:20:51:53 | After BinaryExpr() | Timestamp 4 on true/false branch is missing a dead() annotation on the true successor in $@ | test_assert_raise.py:48:1:48:25 | Function test_bare_reraise | test_bare_reraise | +| test_loops.py:10:12:10:52 | After Compare | Timestamp 4 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:8:1:8:23 | Function test_while_loop | test_while_loop | +| test_loops.py:10:12:10:52 | After Compare | Timestamp 4 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:8:1:8:23 | Function test_while_loop | test_while_loop | +| test_loops.py:10:12:10:52 | After Compare | Timestamp 10 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:8:1:8:23 | Function test_while_loop | test_while_loop | +| test_loops.py:10:12:10:52 | After Compare | Timestamp 10 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:8:1:8:23 | Function test_while_loop | test_while_loop | +| test_loops.py:10:12:10:52 | After Compare | Timestamp 16 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:8:1:8:23 | Function test_while_loop | test_while_loop | +| test_loops.py:10:12:10:52 | After Compare | Timestamp 16 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:8:1:8:23 | Function test_while_loop | test_while_loop | +| test_loops.py:10:12:10:52 | After Compare | Timestamp 22 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:8:1:8:23 | Function test_while_loop | test_while_loop | +| test_loops.py:10:12:10:52 | After Compare | Timestamp 22 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:8:1:8:23 | Function test_while_loop | test_while_loop | +| test_loops.py:19:12:19:46 | After Compare | Timestamp 4 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:17:1:17:24 | Function test_while_break | test_while_break | +| test_loops.py:19:12:19:46 | After Compare | Timestamp 4 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:17:1:17:24 | Function test_while_break | test_while_break | +| test_loops.py:19:12:19:46 | After Compare | Timestamp 13 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:17:1:17:24 | Function test_while_break | test_while_break | +| test_loops.py:19:12:19:46 | After Compare | Timestamp 13 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:17:1:17:24 | Function test_while_break | test_while_break | +| test_loops.py:19:12:19:46 | After Compare | Timestamp 22 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:17:1:17:24 | Function test_while_break | test_while_break | +| test_loops.py:19:12:19:46 | After Compare | Timestamp 22 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:17:1:17:24 | Function test_while_break | test_while_break | +| test_loops.py:19:12:19:46 | After Compare | Timestamp 25 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:17:1:17:24 | Function test_while_break | test_while_break | +| test_loops.py:19:12:19:46 | After Compare | Timestamp 25 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:17:1:17:24 | Function test_while_break | test_while_break | +| test_loops.py:20:13:20:48 | After Compare | Timestamp 7 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:17:1:17:24 | Function test_while_break | test_while_break | +| test_loops.py:20:13:20:48 | After Compare | Timestamp 7 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:17:1:17:24 | Function test_while_break | test_while_break | +| test_loops.py:20:13:20:48 | After Compare | Timestamp 16 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:17:1:17:24 | Function test_while_break | test_while_break | +| test_loops.py:20:13:20:48 | After Compare | Timestamp 16 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:17:1:17:24 | Function test_while_break | test_while_break | +| test_loops.py:20:13:20:48 | After Compare | Timestamp 25 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:17:1:17:24 | Function test_while_break | test_while_break | +| test_loops.py:20:13:20:48 | After Compare | Timestamp 25 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:17:1:17:24 | Function test_while_break | test_while_break | +| test_loops.py:31:12:31:54 | After Compare | Timestamp 5 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:28:1:28:27 | Function test_while_continue | test_while_continue | +| test_loops.py:31:12:31:54 | After Compare | Timestamp 5 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:28:1:28:27 | Function test_while_continue | test_while_continue | +| test_loops.py:31:12:31:54 | After Compare | Timestamp 17 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:28:1:28:27 | Function test_while_continue | test_while_continue | +| test_loops.py:31:12:31:54 | After Compare | Timestamp 17 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:28:1:28:27 | Function test_while_continue | test_while_continue | +| test_loops.py:31:12:31:54 | After Compare | Timestamp 26 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:28:1:28:27 | Function test_while_continue | test_while_continue | +| test_loops.py:31:12:31:54 | After Compare | Timestamp 26 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:28:1:28:27 | Function test_while_continue | test_while_continue | +| test_loops.py:31:12:31:54 | After Compare | Timestamp 38 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:28:1:28:27 | Function test_while_continue | test_while_continue | +| test_loops.py:31:12:31:54 | After Compare | Timestamp 38 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:28:1:28:27 | Function test_while_continue | test_while_continue | +| test_loops.py:33:13:33:48 | After Compare | Timestamp 2 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:28:1:28:27 | Function test_while_continue | test_while_continue | +| test_loops.py:33:13:33:48 | After Compare | Timestamp 2 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:28:1:28:27 | Function test_while_continue | test_while_continue | +| test_loops.py:33:13:33:48 | After Compare | Timestamp 11 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:28:1:28:27 | Function test_while_continue | test_while_continue | +| test_loops.py:33:13:33:48 | After Compare | Timestamp 11 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:28:1:28:27 | Function test_while_continue | test_while_continue | +| test_loops.py:33:13:33:48 | After Compare | Timestamp 14 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:28:1:28:27 | Function test_while_continue | test_while_continue | +| test_loops.py:33:13:33:48 | After Compare | Timestamp 14 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:28:1:28:27 | Function test_while_continue | test_while_continue | +| test_loops.py:33:13:33:48 | After Compare | Timestamp 23 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:28:1:28:27 | Function test_while_continue | test_while_continue | +| test_loops.py:33:13:33:48 | After Compare | Timestamp 23 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:28:1:28:27 | Function test_while_continue | test_while_continue | +| test_loops.py:33:13:33:48 | After Compare | Timestamp 32 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:28:1:28:27 | Function test_while_continue | test_while_continue | +| test_loops.py:33:13:33:48 | After Compare | Timestamp 32 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:28:1:28:27 | Function test_while_continue | test_while_continue | +| test_loops.py:33:13:33:48 | After Compare | Timestamp 35 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:28:1:28:27 | Function test_while_continue | test_while_continue | +| test_loops.py:33:13:33:48 | After Compare | Timestamp 35 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:28:1:28:27 | Function test_while_continue | test_while_continue | +| test_loops.py:43:12:43:44 | After Compare | Timestamp 4 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:41:1:41:23 | Function test_while_else | test_while_else | +| test_loops.py:43:12:43:44 | After Compare | Timestamp 4 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:41:1:41:23 | Function test_while_else | test_while_else | +| test_loops.py:43:12:43:44 | After Compare | Timestamp 10 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:41:1:41:23 | Function test_while_else | test_while_else | +| test_loops.py:43:12:43:44 | After Compare | Timestamp 10 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:41:1:41:23 | Function test_while_else | test_while_else | +| test_loops.py:43:12:43:44 | After Compare | Timestamp 16 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:41:1:41:23 | Function test_while_else | test_while_else | +| test_loops.py:43:12:43:44 | After Compare | Timestamp 16 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:41:1:41:23 | Function test_while_else | test_while_else | +| test_loops.py:53:12:53:38 | After Compare | Timestamp 4 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:51:1:51:29 | Function test_while_else_break | test_while_else_break | +| test_loops.py:53:12:53:38 | After Compare | Timestamp 4 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:51:1:51:29 | Function test_while_else_break | test_while_else_break | +| test_loops.py:53:12:53:38 | After Compare | Timestamp 13 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:51:1:51:29 | Function test_while_else_break | test_while_else_break | +| test_loops.py:53:12:53:38 | After Compare | Timestamp 13 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:51:1:51:29 | Function test_while_else_break | test_while_else_break | +| test_loops.py:53:12:53:38 | After Compare | Timestamp 16 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:51:1:51:29 | Function test_while_else_break | test_while_else_break | +| test_loops.py:53:12:53:38 | After Compare | Timestamp 16 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:51:1:51:29 | Function test_while_else_break | test_while_else_break | +| test_loops.py:54:13:54:40 | After Compare | Timestamp 7 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:51:1:51:29 | Function test_while_else_break | test_while_else_break | +| test_loops.py:54:13:54:40 | After Compare | Timestamp 7 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:51:1:51:29 | Function test_while_else_break | test_while_else_break | +| test_loops.py:54:13:54:40 | After Compare | Timestamp 16 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:51:1:51:29 | Function test_while_else_break | test_while_else_break | +| test_loops.py:54:13:54:40 | After Compare | Timestamp 16 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:51:1:51:29 | Function test_while_else_break | test_while_else_break | +| test_loops.py:65:14:65:43 | After List | Timestamp 4 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:64:1:64:21 | Function test_for_list | test_for_list | +| test_loops.py:65:14:65:43 | After List | Timestamp 4 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:64:1:64:21 | Function test_for_list | test_for_list | +| test_loops.py:65:14:65:43 | After List | Timestamp 5 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:64:1:64:21 | Function test_for_list | test_for_list | +| test_loops.py:65:14:65:43 | After List | Timestamp 5 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:64:1:64:21 | Function test_for_list | test_for_list | +| test_loops.py:65:14:65:43 | After List | Timestamp 6 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:64:1:64:21 | Function test_for_list | test_for_list | +| test_loops.py:65:14:65:43 | After List | Timestamp 6 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:64:1:64:21 | Function test_for_list | test_for_list | +| test_loops.py:65:14:65:43 | After List | Timestamp 7 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:64:1:64:21 | Function test_for_list | test_for_list | +| test_loops.py:65:14:65:43 | After List | Timestamp 7 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:64:1:64:21 | Function test_for_list | test_for_list | +| test_loops.py:66:9:66:9 | x | Timestamp 4 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:64:1:64:21 | Function test_for_list | test_for_list | +| test_loops.py:66:9:66:9 | x | Timestamp 4 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:64:1:64:21 | Function test_for_list | test_for_list | +| test_loops.py:66:9:66:9 | x | Timestamp 5 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:64:1:64:21 | Function test_for_list | test_for_list | +| test_loops.py:66:9:66:9 | x | Timestamp 5 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:64:1:64:21 | Function test_for_list | test_for_list | +| test_loops.py:66:9:66:9 | x | Timestamp 6 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:64:1:64:21 | Function test_for_list | test_for_list | +| test_loops.py:66:9:66:9 | x | Timestamp 6 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:64:1:64:21 | Function test_for_list | test_for_list | +| test_loops.py:66:9:66:9 | x | Timestamp 7 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:64:1:64:21 | Function test_for_list | test_for_list | +| test_loops.py:66:9:66:9 | x | Timestamp 7 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:64:1:64:21 | Function test_for_list | test_for_list | +| test_loops.py:73:14:73:37 | After BinaryExpr() | Timestamp 3 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:72:1:72:22 | Function test_for_range | test_for_range | +| test_loops.py:73:14:73:37 | After BinaryExpr() | Timestamp 3 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:72:1:72:22 | Function test_for_range | test_for_range | +| test_loops.py:73:14:73:37 | After BinaryExpr() | Timestamp 4 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:72:1:72:22 | Function test_for_range | test_for_range | +| test_loops.py:73:14:73:37 | After BinaryExpr() | Timestamp 4 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:72:1:72:22 | Function test_for_range | test_for_range | +| test_loops.py:73:14:73:37 | After BinaryExpr() | Timestamp 5 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:72:1:72:22 | Function test_for_range | test_for_range | +| test_loops.py:73:14:73:37 | After BinaryExpr() | Timestamp 5 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:72:1:72:22 | Function test_for_range | test_for_range | +| test_loops.py:73:14:73:37 | After BinaryExpr() | Timestamp 6 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:72:1:72:22 | Function test_for_range | test_for_range | +| test_loops.py:73:14:73:37 | After BinaryExpr() | Timestamp 6 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:72:1:72:22 | Function test_for_range | test_for_range | +| test_loops.py:74:9:74:9 | i | Timestamp 3 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:72:1:72:22 | Function test_for_range | test_for_range | +| test_loops.py:74:9:74:9 | i | Timestamp 3 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:72:1:72:22 | Function test_for_range | test_for_range | +| test_loops.py:74:9:74:9 | i | Timestamp 4 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:72:1:72:22 | Function test_for_range | test_for_range | +| test_loops.py:74:9:74:9 | i | Timestamp 4 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:72:1:72:22 | Function test_for_range | test_for_range | +| test_loops.py:74:9:74:9 | i | Timestamp 5 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:72:1:72:22 | Function test_for_range | test_for_range | +| test_loops.py:74:9:74:9 | i | Timestamp 5 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:72:1:72:22 | Function test_for_range | test_for_range | +| test_loops.py:74:9:74:9 | i | Timestamp 6 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:72:1:72:22 | Function test_for_range | test_for_range | +| test_loops.py:74:9:74:9 | i | Timestamp 6 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:72:1:72:22 | Function test_for_range | test_for_range | +| test_loops.py:81:14:81:53 | After List | Timestamp 5 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:80:1:80:22 | Function test_for_break | test_for_break | +| test_loops.py:81:14:81:53 | After List | Timestamp 5 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:80:1:80:22 | Function test_for_break | test_for_break | +| test_loops.py:81:14:81:53 | After List | Timestamp 9 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:80:1:80:22 | Function test_for_break | test_for_break | +| test_loops.py:81:14:81:53 | After List | Timestamp 9 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:80:1:80:22 | Function test_for_break | test_for_break | +| test_loops.py:81:14:81:53 | After List | Timestamp 13 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:80:1:80:22 | Function test_for_break | test_for_break | +| test_loops.py:81:14:81:53 | After List | Timestamp 13 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:80:1:80:22 | Function test_for_break | test_for_break | +| test_loops.py:81:14:81:53 | After List | Timestamp 16 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:80:1:80:22 | Function test_for_break | test_for_break | +| test_loops.py:81:14:81:53 | After List | Timestamp 16 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:80:1:80:22 | Function test_for_break | test_for_break | +| test_loops.py:82:13:82:47 | After Compare | Timestamp 8 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:80:1:80:22 | Function test_for_break | test_for_break | +| test_loops.py:82:13:82:47 | After Compare | Timestamp 8 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:80:1:80:22 | Function test_for_break | test_for_break | +| test_loops.py:82:13:82:47 | After Compare | Timestamp 12 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:80:1:80:22 | Function test_for_break | test_for_break | +| test_loops.py:82:13:82:47 | After Compare | Timestamp 12 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:80:1:80:22 | Function test_for_break | test_for_break | +| test_loops.py:82:13:82:47 | After Compare | Timestamp 16 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:80:1:80:22 | Function test_for_break | test_for_break | +| test_loops.py:82:13:82:47 | After Compare | Timestamp 16 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:80:1:80:22 | Function test_for_break | test_for_break | +| test_loops.py:84:9:84:9 | x | Timestamp 5 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:80:1:80:22 | Function test_for_break | test_for_break | +| test_loops.py:84:9:84:9 | x | Timestamp 5 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:80:1:80:22 | Function test_for_break | test_for_break | +| test_loops.py:84:9:84:9 | x | Timestamp 9 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:80:1:80:22 | Function test_for_break | test_for_break | +| test_loops.py:84:9:84:9 | x | Timestamp 9 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:80:1:80:22 | Function test_for_break | test_for_break | +| test_loops.py:84:9:84:9 | x | Timestamp 13 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:80:1:80:22 | Function test_for_break | test_for_break | +| test_loops.py:84:9:84:9 | x | Timestamp 13 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:80:1:80:22 | Function test_for_break | test_for_break | +| test_loops.py:84:9:84:9 | x | Timestamp 16 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:80:1:80:22 | Function test_for_break | test_for_break | +| test_loops.py:84:9:84:9 | x | Timestamp 16 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:80:1:80:22 | Function test_for_break | test_for_break | +| test_loops.py:92:14:92:43 | After List | Timestamp 5 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:90:1:90:25 | Function test_for_continue | test_for_continue | +| test_loops.py:92:14:92:43 | After List | Timestamp 5 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:90:1:90:25 | Function test_for_continue | test_for_continue | +| test_loops.py:92:14:92:43 | After List | Timestamp 11 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:90:1:90:25 | Function test_for_continue | test_for_continue | +| test_loops.py:92:14:92:43 | After List | Timestamp 11 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:90:1:90:25 | Function test_for_continue | test_for_continue | +| test_loops.py:92:14:92:43 | After List | Timestamp 14 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:90:1:90:25 | Function test_for_continue | test_for_continue | +| test_loops.py:92:14:92:43 | After List | Timestamp 14 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:90:1:90:25 | Function test_for_continue | test_for_continue | +| test_loops.py:92:14:92:43 | After List | Timestamp 20 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:90:1:90:25 | Function test_for_continue | test_for_continue | +| test_loops.py:92:14:92:43 | After List | Timestamp 20 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:90:1:90:25 | Function test_for_continue | test_for_continue | +| test_loops.py:93:13:93:48 | After Compare | Timestamp 5 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:90:1:90:25 | Function test_for_continue | test_for_continue | +| test_loops.py:93:13:93:48 | After Compare | Timestamp 5 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:90:1:90:25 | Function test_for_continue | test_for_continue | +| test_loops.py:93:13:93:48 | After Compare | Timestamp 8 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:90:1:90:25 | Function test_for_continue | test_for_continue | +| test_loops.py:93:13:93:48 | After Compare | Timestamp 8 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:90:1:90:25 | Function test_for_continue | test_for_continue | +| test_loops.py:93:13:93:48 | After Compare | Timestamp 11 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:90:1:90:25 | Function test_for_continue | test_for_continue | +| test_loops.py:93:13:93:48 | After Compare | Timestamp 11 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:90:1:90:25 | Function test_for_continue | test_for_continue | +| test_loops.py:93:13:93:48 | After Compare | Timestamp 14 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:90:1:90:25 | Function test_for_continue | test_for_continue | +| test_loops.py:93:13:93:48 | After Compare | Timestamp 14 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:90:1:90:25 | Function test_for_continue | test_for_continue | +| test_loops.py:93:13:93:48 | After Compare | Timestamp 17 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:90:1:90:25 | Function test_for_continue | test_for_continue | +| test_loops.py:93:13:93:48 | After Compare | Timestamp 17 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:90:1:90:25 | Function test_for_continue | test_for_continue | +| test_loops.py:93:13:93:48 | After Compare | Timestamp 20 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:90:1:90:25 | Function test_for_continue | test_for_continue | +| test_loops.py:93:13:93:48 | After Compare | Timestamp 20 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:90:1:90:25 | Function test_for_continue | test_for_continue | +| test_loops.py:95:18:95:48 | After BinaryExpr | Timestamp 5 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:90:1:90:25 | Function test_for_continue | test_for_continue | +| test_loops.py:95:18:95:48 | After BinaryExpr | Timestamp 5 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:90:1:90:25 | Function test_for_continue | test_for_continue | +| test_loops.py:95:18:95:48 | After BinaryExpr | Timestamp 11 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:90:1:90:25 | Function test_for_continue | test_for_continue | +| test_loops.py:95:18:95:48 | After BinaryExpr | Timestamp 11 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:90:1:90:25 | Function test_for_continue | test_for_continue | +| test_loops.py:95:18:95:48 | After BinaryExpr | Timestamp 14 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:90:1:90:25 | Function test_for_continue | test_for_continue | +| test_loops.py:95:18:95:48 | After BinaryExpr | Timestamp 14 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:90:1:90:25 | Function test_for_continue | test_for_continue | +| test_loops.py:95:18:95:48 | After BinaryExpr | Timestamp 20 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:90:1:90:25 | Function test_for_continue | test_for_continue | +| test_loops.py:95:18:95:48 | After BinaryExpr | Timestamp 20 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:90:1:90:25 | Function test_for_continue | test_for_continue | +| test_loops.py:102:14:102:33 | After List | Timestamp 3 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:101:1:101:21 | Function test_for_else | test_for_else | +| test_loops.py:102:14:102:33 | After List | Timestamp 3 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:101:1:101:21 | Function test_for_else | test_for_else | +| test_loops.py:102:14:102:33 | After List | Timestamp 4 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:101:1:101:21 | Function test_for_else | test_for_else | +| test_loops.py:102:14:102:33 | After List | Timestamp 4 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:101:1:101:21 | Function test_for_else | test_for_else | +| test_loops.py:102:14:102:33 | After List | Timestamp 5 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:101:1:101:21 | Function test_for_else | test_for_else | +| test_loops.py:102:14:102:33 | After List | Timestamp 5 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:101:1:101:21 | Function test_for_else | test_for_else | +| test_loops.py:103:9:103:9 | x | Timestamp 3 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:101:1:101:21 | Function test_for_else | test_for_else | +| test_loops.py:103:9:103:9 | x | Timestamp 3 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:101:1:101:21 | Function test_for_else | test_for_else | +| test_loops.py:103:9:103:9 | x | Timestamp 4 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:101:1:101:21 | Function test_for_else | test_for_else | +| test_loops.py:103:9:103:9 | x | Timestamp 4 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:101:1:101:21 | Function test_for_else | test_for_else | +| test_loops.py:103:9:103:9 | x | Timestamp 5 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:101:1:101:21 | Function test_for_else | test_for_else | +| test_loops.py:103:9:103:9 | x | Timestamp 5 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:101:1:101:21 | Function test_for_else | test_for_else | +| test_loops.py:111:14:111:43 | After List | Timestamp 4 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:110:1:110:27 | Function test_for_else_break | test_for_else_break | +| test_loops.py:111:14:111:43 | After List | Timestamp 4 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:110:1:110:27 | Function test_for_else_break | test_for_else_break | +| test_loops.py:111:14:111:43 | After List | Timestamp 8 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:110:1:110:27 | Function test_for_else_break | test_for_else_break | +| test_loops.py:111:14:111:43 | After List | Timestamp 8 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:110:1:110:27 | Function test_for_else_break | test_for_else_break | +| test_loops.py:111:14:111:43 | After List | Timestamp 11 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:110:1:110:27 | Function test_for_else_break | test_for_else_break | +| test_loops.py:111:14:111:43 | After List | Timestamp 11 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:110:1:110:27 | Function test_for_else_break | test_for_else_break | +| test_loops.py:112:13:112:38 | After Compare | Timestamp 7 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:110:1:110:27 | Function test_for_else_break | test_for_else_break | +| test_loops.py:112:13:112:38 | After Compare | Timestamp 7 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:110:1:110:27 | Function test_for_else_break | test_for_else_break | +| test_loops.py:112:13:112:38 | After Compare | Timestamp 11 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:110:1:110:27 | Function test_for_else_break | test_for_else_break | +| test_loops.py:112:13:112:38 | After Compare | Timestamp 11 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:110:1:110:27 | Function test_for_else_break | test_for_else_break | +| test_loops.py:114:9:114:9 | x | Timestamp 4 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:110:1:110:27 | Function test_for_else_break | test_for_else_break | +| test_loops.py:114:9:114:9 | x | Timestamp 4 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:110:1:110:27 | Function test_for_else_break | test_for_else_break | +| test_loops.py:114:9:114:9 | x | Timestamp 8 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:110:1:110:27 | Function test_for_else_break | test_for_else_break | +| test_loops.py:114:9:114:9 | x | Timestamp 8 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:110:1:110:27 | Function test_for_else_break | test_for_else_break | +| test_loops.py:114:9:114:9 | x | Timestamp 11 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:110:1:110:27 | Function test_for_else_break | test_for_else_break | +| test_loops.py:114:9:114:9 | x | Timestamp 11 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:110:1:110:27 | Function test_for_else_break | test_for_else_break | +| test_loops.py:123:14:123:33 | After List | Timestamp 21 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:122:1:122:25 | Function test_nested_loops | test_nested_loops | +| test_loops.py:123:14:123:33 | After List | Timestamp 21 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:122:1:122:25 | Function test_nested_loops | test_nested_loops | +| test_loops.py:124:18:124:47 | After List | Timestamp 3 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:122:1:122:25 | Function test_nested_loops | test_nested_loops | +| test_loops.py:124:18:124:47 | After List | Timestamp 3 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:122:1:122:25 | Function test_nested_loops | test_nested_loops | +| test_loops.py:124:18:124:47 | After List | Timestamp 6 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:122:1:122:25 | Function test_nested_loops | test_nested_loops | +| test_loops.py:124:18:124:47 | After List | Timestamp 6 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:122:1:122:25 | Function test_nested_loops | test_nested_loops | +| test_loops.py:124:18:124:47 | After List | Timestamp 9 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:122:1:122:25 | Function test_nested_loops | test_nested_loops | +| test_loops.py:124:18:124:47 | After List | Timestamp 9 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:122:1:122:25 | Function test_nested_loops | test_nested_loops | +| test_loops.py:124:18:124:47 | After List | Timestamp 12 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:122:1:122:25 | Function test_nested_loops | test_nested_loops | +| test_loops.py:124:18:124:47 | After List | Timestamp 12 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:122:1:122:25 | Function test_nested_loops | test_nested_loops | +| test_loops.py:124:18:124:47 | After List | Timestamp 15 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:122:1:122:25 | Function test_nested_loops | test_nested_loops | +| test_loops.py:124:18:124:47 | After List | Timestamp 15 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:122:1:122:25 | Function test_nested_loops | test_nested_loops | +| test_loops.py:124:18:124:47 | After List | Timestamp 18 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:122:1:122:25 | Function test_nested_loops | test_nested_loops | +| test_loops.py:124:18:124:47 | After List | Timestamp 18 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:122:1:122:25 | Function test_nested_loops | test_nested_loops | +| test_loops.py:124:18:124:47 | After List | Timestamp 21 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:122:1:122:25 | Function test_nested_loops | test_nested_loops | +| test_loops.py:124:18:124:47 | After List | Timestamp 21 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:122:1:122:25 | Function test_nested_loops | test_nested_loops | +| test_loops.py:125:14:125:65 | After BinaryExpr | Timestamp 3 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:122:1:122:25 | Function test_nested_loops | test_nested_loops | +| test_loops.py:125:14:125:65 | After BinaryExpr | Timestamp 3 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:122:1:122:25 | Function test_nested_loops | test_nested_loops | +| test_loops.py:125:14:125:65 | After BinaryExpr | Timestamp 6 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:122:1:122:25 | Function test_nested_loops | test_nested_loops | +| test_loops.py:125:14:125:65 | After BinaryExpr | Timestamp 6 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:122:1:122:25 | Function test_nested_loops | test_nested_loops | +| test_loops.py:125:14:125:65 | After BinaryExpr | Timestamp 9 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:122:1:122:25 | Function test_nested_loops | test_nested_loops | +| test_loops.py:125:14:125:65 | After BinaryExpr | Timestamp 9 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:122:1:122:25 | Function test_nested_loops | test_nested_loops | +| test_loops.py:125:14:125:65 | After BinaryExpr | Timestamp 12 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:122:1:122:25 | Function test_nested_loops | test_nested_loops | +| test_loops.py:125:14:125:65 | After BinaryExpr | Timestamp 12 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:122:1:122:25 | Function test_nested_loops | test_nested_loops | +| test_loops.py:125:14:125:65 | After BinaryExpr | Timestamp 15 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:122:1:122:25 | Function test_nested_loops | test_nested_loops | +| test_loops.py:125:14:125:65 | After BinaryExpr | Timestamp 15 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:122:1:122:25 | Function test_nested_loops | test_nested_loops | +| test_loops.py:125:14:125:65 | After BinaryExpr | Timestamp 18 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:122:1:122:25 | Function test_nested_loops | test_nested_loops | +| test_loops.py:125:14:125:65 | After BinaryExpr | Timestamp 18 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:122:1:122:25 | Function test_nested_loops | test_nested_loops | +| test_loops.py:125:14:125:65 | After BinaryExpr | Timestamp 21 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:122:1:122:25 | Function test_nested_loops | test_nested_loops | +| test_loops.py:125:14:125:65 | After BinaryExpr | Timestamp 21 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:122:1:122:25 | Function test_nested_loops | test_nested_loops | +| test_loops.py:133:11:133:14 | True | Timestamp 2 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:131:1:131:29 | Function test_while_true_break | test_while_true_break | +| test_loops.py:133:11:133:14 | True | Timestamp 2 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:131:1:131:29 | Function test_while_true_break | test_while_true_break | +| test_loops.py:133:11:133:14 | True | Timestamp 9 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:131:1:131:29 | Function test_while_true_break | test_while_true_break | +| test_loops.py:133:11:133:14 | True | Timestamp 9 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:131:1:131:29 | Function test_while_true_break | test_while_true_break | +| test_loops.py:133:11:133:14 | True | Timestamp 16 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:131:1:131:29 | Function test_while_true_break | test_while_true_break | +| test_loops.py:133:11:133:14 | True | Timestamp 16 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:131:1:131:29 | Function test_while_true_break | test_while_true_break | +| test_loops.py:133:11:133:14 | True | Timestamp 22 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:131:1:131:29 | Function test_while_true_break | test_while_true_break | +| test_loops.py:133:11:133:14 | True | Timestamp 22 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:131:1:131:29 | Function test_while_true_break | test_while_true_break | +| test_loops.py:135:13:135:48 | After Compare | Timestamp 1 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:131:1:131:29 | Function test_while_true_break | test_while_true_break | +| test_loops.py:135:13:135:48 | After Compare | Timestamp 1 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:131:1:131:29 | Function test_while_true_break | test_while_true_break | +| test_loops.py:135:13:135:48 | After Compare | Timestamp 8 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:131:1:131:29 | Function test_while_true_break | test_while_true_break | +| test_loops.py:135:13:135:48 | After Compare | Timestamp 8 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:131:1:131:29 | Function test_while_true_break | test_while_true_break | +| test_loops.py:135:13:135:48 | After Compare | Timestamp 15 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:131:1:131:29 | Function test_while_true_break | test_while_true_break | +| test_loops.py:135:13:135:48 | After Compare | Timestamp 15 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:131:1:131:29 | Function test_while_true_break | test_while_true_break | +| test_loops.py:135:13:135:48 | After Compare | Timestamp 22 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:131:1:131:29 | Function test_while_true_break | test_while_true_break | +| test_loops.py:135:13:135:48 | After Compare | Timestamp 22 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:131:1:131:29 | Function test_while_true_break | test_while_true_break | +| test_loops.py:143:21:143:83 | After BinaryExpr() | Timestamp 6 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:142:1:142:26 | Function test_for_enumerate | test_for_enumerate | +| test_loops.py:143:21:143:83 | After BinaryExpr() | Timestamp 6 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:142:1:142:26 | Function test_for_enumerate | test_for_enumerate | +| test_loops.py:143:21:143:83 | After BinaryExpr() | Timestamp 8 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:142:1:142:26 | Function test_for_enumerate | test_for_enumerate | +| test_loops.py:143:21:143:83 | After BinaryExpr() | Timestamp 8 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:142:1:142:26 | Function test_for_enumerate | test_for_enumerate | +| test_loops.py:143:21:143:83 | After BinaryExpr() | Timestamp 10 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:142:1:142:26 | Function test_for_enumerate | test_for_enumerate | +| test_loops.py:143:21:143:83 | After BinaryExpr() | Timestamp 10 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:142:1:142:26 | Function test_for_enumerate | test_for_enumerate | +| test_loops.py:143:21:143:83 | After BinaryExpr() | Timestamp 12 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:142:1:142:26 | Function test_for_enumerate | test_for_enumerate | +| test_loops.py:143:21:143:83 | After BinaryExpr() | Timestamp 12 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:142:1:142:26 | Function test_for_enumerate | test_for_enumerate | +| test_loops.py:145:9:145:11 | val | Timestamp 6 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:142:1:142:26 | Function test_for_enumerate | test_for_enumerate | +| test_loops.py:145:9:145:11 | val | Timestamp 6 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:142:1:142:26 | Function test_for_enumerate | test_for_enumerate | +| test_loops.py:145:9:145:11 | val | Timestamp 8 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:142:1:142:26 | Function test_for_enumerate | test_for_enumerate | +| test_loops.py:145:9:145:11 | val | Timestamp 8 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:142:1:142:26 | Function test_for_enumerate | test_for_enumerate | +| test_loops.py:145:9:145:11 | val | Timestamp 10 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:142:1:142:26 | Function test_for_enumerate | test_for_enumerate | +| test_loops.py:145:9:145:11 | val | Timestamp 10 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:142:1:142:26 | Function test_for_enumerate | test_for_enumerate | +| test_loops.py:145:9:145:11 | val | Timestamp 12 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:142:1:142:26 | Function test_for_enumerate | test_for_enumerate | +| test_loops.py:145:9:145:11 | val | Timestamp 12 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:142:1:142:26 | Function test_for_enumerate | test_for_enumerate | +| test_match.py:16:11:16:11 | x | Timestamp 2 on true/false branch is missing a dead() annotation on the false successor in $@ | test_match.py:14:1:14:26 | Function test_match_literal | test_match_literal | +| test_match.py:16:11:16:11 | x | Timestamp 2 on true/false branch is missing a dead() annotation on the true successor in $@ | test_match.py:14:1:14:26 | Function test_match_literal | test_match_literal | +| test_match.py:16:11:16:11 | x | Timestamp 3 on true/false branch is missing a dead() annotation on the false successor in $@ | test_match.py:14:1:14:26 | Function test_match_literal | test_match_literal | +| test_match.py:16:11:16:11 | x | Timestamp 3 on true/false branch is missing a dead() annotation on the true successor in $@ | test_match.py:14:1:14:26 | Function test_match_literal | test_match_literal | +| test_match.py:27:11:27:11 | x | Timestamp 2 on true/false branch is missing a dead() annotation on the false successor in $@ | test_match.py:25:1:25:38 | Function test_match_literal_fallthrough | test_match_literal_fallthrough | +| test_match.py:27:11:27:11 | x | Timestamp 2 on true/false branch is missing a dead() annotation on the true successor in $@ | test_match.py:25:1:25:38 | Function test_match_literal_fallthrough | test_match_literal_fallthrough | +| test_match.py:27:11:27:11 | x | Timestamp 3 on true/false branch is missing a dead() annotation on the false successor in $@ | test_match.py:25:1:25:38 | Function test_match_literal_fallthrough | test_match_literal_fallthrough | +| test_match.py:27:11:27:11 | x | Timestamp 3 on true/false branch is missing a dead() annotation on the true successor in $@ | test_match.py:25:1:25:38 | Function test_match_literal_fallthrough | test_match_literal_fallthrough | +| test_match.py:51:11:51:11 | x | Timestamp 2 on true/false branch is missing a dead() annotation on the false successor in $@ | test_match.py:49:1:49:26 | Function test_match_capture | test_match_capture | +| test_match.py:51:11:51:11 | x | Timestamp 2 on true/false branch is missing a dead() annotation on the true successor in $@ | test_match.py:49:1:49:26 | Function test_match_capture | test_match_capture | +| test_match.py:51:11:51:11 | x | Timestamp 3 on true/false branch is missing a dead() annotation on the false successor in $@ | test_match.py:49:1:49:26 | Function test_match_capture | test_match_capture | +| test_match.py:51:11:51:11 | x | Timestamp 3 on true/false branch is missing a dead() annotation on the true successor in $@ | test_match.py:49:1:49:26 | Function test_match_capture | test_match_capture | +| test_match.py:71:11:71:11 | x | Timestamp 2 on true/false branch is missing a dead() annotation on the false successor in $@ | test_match.py:69:1:69:24 | Function test_match_guard | test_match_guard | +| test_match.py:71:11:71:11 | x | Timestamp 2 on true/false branch is missing a dead() annotation on the true successor in $@ | test_match.py:69:1:69:24 | Function test_match_guard | test_match_guard | +| test_match.py:82:11:82:11 | x | Timestamp 2 on true/false branch is missing a dead() annotation on the false successor in $@ | test_match.py:80:1:80:32 | Function test_match_class_pattern | test_match_class_pattern | +| test_match.py:82:11:82:11 | x | Timestamp 2 on true/false branch is missing a dead() annotation on the true successor in $@ | test_match.py:80:1:80:32 | Function test_match_class_pattern | test_match_class_pattern | +| test_match.py:82:11:82:11 | x | Timestamp 3 on true/false branch is missing a dead() annotation on the false successor in $@ | test_match.py:80:1:80:32 | Function test_match_class_pattern | test_match_class_pattern | +| test_match.py:82:11:82:11 | x | Timestamp 3 on true/false branch is missing a dead() annotation on the true successor in $@ | test_match.py:80:1:80:32 | Function test_match_class_pattern | test_match_class_pattern | +| test_match.py:93:11:93:11 | x | Timestamp 4 on true/false branch is missing a dead() annotation on the false successor in $@ | test_match.py:91:1:91:27 | Function test_match_sequence | test_match_sequence | +| test_match.py:93:11:93:11 | x | Timestamp 4 on true/false branch is missing a dead() annotation on the true successor in $@ | test_match.py:91:1:91:27 | Function test_match_sequence | test_match_sequence | +| test_try.py:95:16:95:36 | After BinaryExpr() | Timestamp 3 on true/false branch is missing a dead() annotation on the false successor in $@ | test_try.py:92:1:92:41 | Function test_try_except_finally_exception | test_try_except_finally_exception | +| test_try.py:95:16:95:36 | After BinaryExpr() | Timestamp 3 on true/false branch is missing a dead() annotation on the true successor in $@ | test_try.py:92:1:92:41 | Function test_try_except_finally_exception | test_try_except_finally_exception | +| test_try.py:95:16:95:36 | After BinaryExpr() | Timestamp 4 on true/false branch is missing a dead() annotation on the false successor in $@ | test_try.py:92:1:92:41 | Function test_try_except_finally_exception | test_try_except_finally_exception | +| test_try.py:95:16:95:36 | After BinaryExpr() | Timestamp 4 on true/false branch is missing a dead() annotation on the true successor in $@ | test_try.py:92:1:92:41 | Function test_try_except_finally_exception | test_try_except_finally_exception | +| test_try.py:147:20:147:40 | After BinaryExpr() | Timestamp 4 on true/false branch is missing a dead() annotation on the false successor in $@ | test_try.py:142:1:142:30 | Function test_nested_try_except | test_nested_try_except | +| test_try.py:147:20:147:40 | After BinaryExpr() | Timestamp 4 on true/false branch is missing a dead() annotation on the true successor in $@ | test_try.py:142:1:142:30 | Function test_nested_try_except | test_nested_try_except | +| test_try.py:162:17:162:52 | After Compare | Timestamp 7 on true/false branch is missing a dead() annotation on the false successor in $@ | test_try.py:158:1:158:24 | Function test_try_in_loop | test_try_in_loop | +| test_try.py:162:17:162:52 | After Compare | Timestamp 7 on true/false branch is missing a dead() annotation on the true successor in $@ | test_try.py:158:1:158:24 | Function test_try_in_loop | test_try_in_loop | +| test_try.py:162:17:162:52 | After Compare | Timestamp 14 on true/false branch is missing a dead() annotation on the false successor in $@ | test_try.py:158:1:158:24 | Function test_try_in_loop | test_try_in_loop | +| test_try.py:162:17:162:52 | After Compare | Timestamp 14 on true/false branch is missing a dead() annotation on the true successor in $@ | test_try.py:158:1:158:24 | Function test_try_in_loop | test_try_in_loop | +| test_try.py:162:17:162:52 | After Compare | Timestamp 23 on true/false branch is missing a dead() annotation on the false successor in $@ | test_try.py:158:1:158:24 | Function test_try_in_loop | test_try_in_loop | +| test_try.py:162:17:162:52 | After Compare | Timestamp 23 on true/false branch is missing a dead() annotation on the true successor in $@ | test_try.py:158:1:158:24 | Function test_try_in_loop | test_try_in_loop | +| test_try.py:176:20:176:40 | After BinaryExpr() | Timestamp 3 on true/false branch is missing a dead() annotation on the false successor in $@ | test_try.py:172:1:172:20 | Function test_reraise | test_reraise | +| test_try.py:176:20:176:40 | After BinaryExpr() | Timestamp 3 on true/false branch is missing a dead() annotation on the true successor in $@ | test_try.py:172:1:172:20 | Function test_reraise | test_reraise | +| test_try.py:176:20:176:40 | After BinaryExpr() | Timestamp 4 on true/false branch is missing a dead() annotation on the false successor in $@ | test_try.py:172:1:172:20 | Function test_reraise | test_reraise | +| test_try.py:176:20:176:40 | After BinaryExpr() | Timestamp 4 on true/false branch is missing a dead() annotation on the true successor in $@ | test_try.py:172:1:172:20 | Function test_reraise | test_reraise | +| test_with.py:55:14:55:33 | After List | Timestamp 3 on true/false branch is missing a dead() annotation on the false successor in $@ | test_with.py:54:1:54:25 | Function test_with_in_loop | test_with_in_loop | +| test_with.py:55:14:55:33 | After List | Timestamp 3 on true/false branch is missing a dead() annotation on the true successor in $@ | test_with.py:54:1:54:25 | Function test_with_in_loop | test_with_in_loop | +| test_with.py:55:14:55:33 | After List | Timestamp 6 on true/false branch is missing a dead() annotation on the false successor in $@ | test_with.py:54:1:54:25 | Function test_with_in_loop | test_with_in_loop | +| test_with.py:55:14:55:33 | After List | Timestamp 6 on true/false branch is missing a dead() annotation on the true successor in $@ | test_with.py:54:1:54:25 | Function test_with_in_loop | test_with_in_loop | +| test_with.py:55:14:55:33 | After List | Timestamp 9 on true/false branch is missing a dead() annotation on the false successor in $@ | test_with.py:54:1:54:25 | Function test_with_in_loop | test_with_in_loop | +| test_with.py:55:14:55:33 | After List | Timestamp 9 on true/false branch is missing a dead() annotation on the true successor in $@ | test_with.py:54:1:54:25 | Function test_with_in_loop | test_with_in_loop | +| test_with.py:57:17:57:17 | i | Timestamp 3 on true/false branch is missing a dead() annotation on the false successor in $@ | test_with.py:54:1:54:25 | Function test_with_in_loop | test_with_in_loop | +| test_with.py:57:17:57:17 | i | Timestamp 3 on true/false branch is missing a dead() annotation on the true successor in $@ | test_with.py:54:1:54:25 | Function test_with_in_loop | test_with_in_loop | +| test_with.py:57:17:57:17 | i | Timestamp 6 on true/false branch is missing a dead() annotation on the false successor in $@ | test_with.py:54:1:54:25 | Function test_with_in_loop | test_with_in_loop | +| test_with.py:57:17:57:17 | i | Timestamp 6 on true/false branch is missing a dead() annotation on the true successor in $@ | test_with.py:54:1:54:25 | Function test_with_in_loop | test_with_in_loop | +| test_with.py:57:17:57:17 | i | Timestamp 9 on true/false branch is missing a dead() annotation on the false successor in $@ | test_with.py:54:1:54:25 | Function test_with_in_loop | test_with_in_loop | +| test_with.py:57:17:57:17 | i | Timestamp 9 on true/false branch is missing a dead() annotation on the true successor in $@ | test_with.py:54:1:54:25 | Function test_with_in_loop | test_with_in_loop | diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgBranchTimestamps.ql b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgBranchTimestamps.ql new file mode 100644 index 000000000000..cd591b867666 --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgBranchTimestamps.ql @@ -0,0 +1,23 @@ +/** + * 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. + */ + +import python +import TimerUtils +import NewCfgImpl + +private module Utils = EvalOrderCfgUtils; + +private import Utils +private import Utils::CfgTests + +from TimerCfgNode node, int ts, string branch +where missingBranchTimestamp(node, ts, branch) +select node, + "Timestamp " + ts + " on true/false branch is missing a dead() annotation on the " + branch + + " successor in $@", node.getTestFunction(), node.getTestFunction().getName() diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgConsecutivePredecessorTimestamps.expected b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgConsecutivePredecessorTimestamps.expected new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgConsecutivePredecessorTimestamps.expected @@ -0,0 +1 @@ + diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgConsecutivePredecessorTimestamps.ql b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgConsecutivePredecessorTimestamps.ql new file mode 100644 index 000000000000..3feacae264e5 --- /dev/null +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgConsecutivePredecessorTimestamps.ql @@ -0,0 +1,22 @@ +/** + * 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 TimerUtils +import NewCfgImpl + +private module Utils = EvalOrderCfgUtils; + +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() diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgConsecutiveTimestamps.expected b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgConsecutiveTimestamps.expected index bce948bb58a5..e69de29bb2d1 100644 --- a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgConsecutiveTimestamps.expected +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgConsecutiveTimestamps.expected @@ -1 +0,0 @@ -| test_if.py:51:9:51:9 | IntegerLiteral | $@ in $@ has no consecutive successor (expected 6) | test_if.py:51:15:51:15 | IntegerLiteral | Timestamp 5 | test_if.py:43:1:43:31 | Function test_if_elif_else_first | test_if_elif_else_first | diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgImpl.qll b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgImpl.qll index 97c6a9c043fa..1da80d2ee0dd 100644 --- a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgImpl.qll +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgImpl.qll @@ -6,6 +6,7 @@ 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; @@ -31,6 +32,20 @@ module NewCfg implements EvalOrderCfgSig { 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 diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgNeverReachable.ql b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgNeverReachable.ql index 3430d49b57ef..6949b2cc6e9b 100644 --- a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgNeverReachable.ql +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgNeverReachable.ql @@ -15,7 +15,7 @@ private module Utils = EvalOrderCfgUtils; private import Utils::CfgTests -from NeverTimerAnnotation ann +from TimerAnnotation ann where neverReachable(ann) select ann, "Node annotated with t.never is reachable in $@", ann.getTestFunction(), ann.getTestFunction().getName() diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NoBackwardFlow.expected b/python/ql/test/library-tests/ControlFlow/evaluation-order/NoBackwardFlow.expected index e69de29bb2d1..1ef8be08d27b 100644 --- a/python/ql/test/library-tests/ControlFlow/evaluation-order/NoBackwardFlow.expected +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/NoBackwardFlow.expected @@ -0,0 +1,10 @@ +| test_boolean.py:9:10:9:43 | ControlFlowNode for BoolExpr | Backward flow: $@ flows to $@ (max timestamp $@) | test_boolean.py:9:59:9:59 | IntegerLiteral | 2 | test_boolean.py:9:10:9:13 | ControlFlowNode for True | True | test_boolean.py:9:19:9:19 | IntegerLiteral | 0 | +| test_boolean.py:15:10:15:43 | ControlFlowNode for BoolExpr | Backward flow: $@ flows to $@ (max timestamp $@) | test_boolean.py:15:50:15:50 | IntegerLiteral | 1 | test_boolean.py:15:10:15:14 | ControlFlowNode for False | False | test_boolean.py:15:20:15:20 | IntegerLiteral | 0 | +| test_boolean.py:21:10:21:42 | ControlFlowNode for BoolExpr | Backward flow: $@ flows to $@ (max timestamp $@) | test_boolean.py:21:49:21:49 | IntegerLiteral | 1 | test_boolean.py:21:10:21:13 | ControlFlowNode for True | True | test_boolean.py:21:19:21:19 | IntegerLiteral | 0 | +| test_boolean.py:27:10:27:43 | ControlFlowNode for BoolExpr | Backward flow: $@ flows to $@ (max timestamp $@) | test_boolean.py:27:59:27:59 | IntegerLiteral | 2 | test_boolean.py:27:10:27:14 | ControlFlowNode for False | False | test_boolean.py:27:20:27:20 | IntegerLiteral | 0 | +| test_boolean.py:40:10:40:61 | ControlFlowNode for BoolExpr | Backward flow: $@ flows to $@ (max timestamp $@) | test_boolean.py:40:86:40:86 | IntegerLiteral | 3 | test_boolean.py:40:10:40:10 | ControlFlowNode for IntegerLiteral | IntegerLiteral | test_boolean.py:40:16:40:16 | IntegerLiteral | 0 | +| test_boolean.py:46:10:46:61 | ControlFlowNode for BoolExpr | Backward flow: $@ flows to $@ (max timestamp $@) | test_boolean.py:46:86:46:86 | IntegerLiteral | 3 | test_boolean.py:46:10:46:10 | ControlFlowNode for IntegerLiteral | IntegerLiteral | test_boolean.py:46:16:46:16 | IntegerLiteral | 0 | +| test_boolean.py:52:10:52:95 | ControlFlowNode for BoolExpr | Backward flow: $@ flows to $@ (max timestamp $@) | test_boolean.py:52:120:52:120 | IntegerLiteral | 4 | test_boolean.py:52:11:52:47 | ControlFlowNode for BoolExpr | BoolExpr | test_boolean.py:52:63:52:63 | IntegerLiteral | 2 | +| test_boolean.py:52:11:52:47 | ControlFlowNode for BoolExpr | Backward flow: $@ flows to $@ (max timestamp $@) | test_boolean.py:52:63:52:63 | IntegerLiteral | 2 | test_boolean.py:52:11:52:14 | ControlFlowNode for True | True | test_boolean.py:52:20:52:20 | IntegerLiteral | 0 | +| test_boolean.py:64:10:64:52 | ControlFlowNode for BoolExpr | Backward flow: $@ flows to $@ (max timestamp $@) | test_boolean.py:64:59:64:59 | IntegerLiteral | 6 | test_boolean.py:64:11:64:11 | ControlFlowNode for f | f | test_boolean.py:64:17:64:17 | IntegerLiteral | 0 | +| test_boolean.py:76:10:76:51 | ControlFlowNode for BoolExpr | Backward flow: $@ flows to $@ (max timestamp $@) | test_boolean.py:76:58:76:58 | IntegerLiteral | 6 | test_boolean.py:76:11:76:11 | ControlFlowNode for f | f | test_boolean.py:76:17:76:17 | IntegerLiteral | 0 | diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/StrictForward.expected b/python/ql/test/library-tests/ControlFlow/evaluation-order/StrictForward.expected index e69de29bb2d1..aa03001b61bd 100644 --- a/python/ql/test/library-tests/ControlFlow/evaluation-order/StrictForward.expected +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/StrictForward.expected @@ -0,0 +1,10 @@ +| test_boolean.py:9:10:9:43 | ControlFlowNode for BoolExpr | Strict forward violation: $@ flows to $@ | test_boolean.py:9:59:9:59 | IntegerLiteral | timestamp 2 | test_boolean.py:9:19:9:19 | IntegerLiteral | timestamp 0 | +| test_boolean.py:15:10:15:43 | ControlFlowNode for BoolExpr | Strict forward violation: $@ flows to $@ | test_boolean.py:15:50:15:50 | IntegerLiteral | timestamp 1 | test_boolean.py:15:20:15:20 | IntegerLiteral | timestamp 0 | +| test_boolean.py:21:10:21:42 | ControlFlowNode for BoolExpr | Strict forward violation: $@ flows to $@ | test_boolean.py:21:49:21:49 | IntegerLiteral | timestamp 1 | test_boolean.py:21:19:21:19 | IntegerLiteral | timestamp 0 | +| test_boolean.py:27:10:27:43 | ControlFlowNode for BoolExpr | Strict forward violation: $@ flows to $@ | test_boolean.py:27:59:27:59 | IntegerLiteral | timestamp 2 | test_boolean.py:27:20:27:20 | IntegerLiteral | timestamp 0 | +| test_boolean.py:40:10:40:61 | ControlFlowNode for BoolExpr | Strict forward violation: $@ flows to $@ | test_boolean.py:40:86:40:86 | IntegerLiteral | timestamp 3 | test_boolean.py:40:16:40:16 | IntegerLiteral | timestamp 0 | +| test_boolean.py:46:10:46:61 | ControlFlowNode for BoolExpr | Strict forward violation: $@ flows to $@ | test_boolean.py:46:86:46:86 | IntegerLiteral | timestamp 3 | test_boolean.py:46:16:46:16 | IntegerLiteral | timestamp 0 | +| test_boolean.py:52:10:52:95 | ControlFlowNode for BoolExpr | Strict forward violation: $@ flows to $@ | test_boolean.py:52:120:52:120 | IntegerLiteral | timestamp 4 | test_boolean.py:52:63:52:63 | IntegerLiteral | timestamp 2 | +| test_boolean.py:52:11:52:47 | ControlFlowNode for BoolExpr | Strict forward violation: $@ flows to $@ | test_boolean.py:52:63:52:63 | IntegerLiteral | timestamp 2 | test_boolean.py:52:20:52:20 | IntegerLiteral | timestamp 0 | +| test_boolean.py:64:10:64:52 | ControlFlowNode for BoolExpr | Strict forward violation: $@ flows to $@ | test_boolean.py:64:59:64:59 | IntegerLiteral | timestamp 6 | test_boolean.py:64:17:64:17 | IntegerLiteral | timestamp 0 | +| test_boolean.py:76:10:76:51 | ControlFlowNode for BoolExpr | Strict forward violation: $@ flows to $@ | test_boolean.py:76:58:76:58 | IntegerLiteral | timestamp 6 | test_boolean.py:76:17:76:17 | IntegerLiteral | timestamp 0 | diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/TimerUtils.qll b/python/ql/test/library-tests/ControlFlow/evaluation-order/TimerUtils.qll index dc46f00f6f56..da66bd31b258 100644 --- a/python/ql/test/library-tests/ControlFlow/evaluation-order/TimerUtils.qll +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/TimerUtils.qll @@ -29,9 +29,40 @@ private IntegerLiteral timestampLiteral(Expr timestamps) { result = timestamps.(Tuple).getAnElt() } +/** + * Gets an element from a timestamp subscript index. Each element is either + * an `IntegerLiteral` (live), a `Call` to `dead` (dead), a `Name("never")` + * (never), or a tuple containing any mix of these. + */ +private Expr timestampElement(Expr timestamps) { + result = timestamps and not timestamps instanceof Tuple + or + result = timestamps.(Tuple).getAnElt() +} + +/** Gets a live timestamp value from a subscript index expression. */ +private IntegerLiteral liveTimestampLiteral(Expr timestamps) { + result = timestampElement(timestamps) and + not result = any(Call c).getAnArg() +} + +/** Gets a dead timestamp value from a subscript index expression. */ +private IntegerLiteral deadTimestampLiteral(Expr timestamps) { + exists(Call c | + c = timestampElement(timestamps) and + c.getFunc().(Name).getId() = "dead" and + result = c.getArg(0) + ) +} + +/** Holds if the subscript index contains `never`. */ +private predicate hasNever(Expr timestamps) { + timestampElement(timestamps).(Name).getId() = "never" +} + /** A timer annotation in the AST. */ private newtype TTimerAnnotation = - /** `expr @ t[n]` or `expr @ t[n, m, ...]` */ + /** `expr @ t[n]` or `expr @ t[n, m, ...]` or `expr @ t[dead(n), m, never]` */ TMatmulAnnotation(TestFunction func, Expr annotated, Expr timestamps) { exists(BinaryExpr be | be.getOp() instanceof MatMult and @@ -49,40 +80,29 @@ private newtype TTimerAnnotation = annotated = call.getArg(0) and timestamps = call.getArg(1) ) - } or - /** `expr @ t.dead[n]` — dead-code annotation */ - TDeadAnnotation(TestFunction func, Expr annotated, Expr timestamps) { - exists(BinaryExpr be | - be.getOp() instanceof MatMult and - be.getRight().(Subscript).getObject().(Attribute).getObject("dead").(Name).getId() = - func.getTimerParamName() and - be.getScope().getEnclosingScope*() = func and - annotated = be.getLeft() and - timestamps = be.getRight().(Subscript).getIndex() - ) - } or - /** `expr @ t.never` — annotation for code that should never be evaluated */ - TNeverAnnotation(TestFunction func, Expr annotated) { - exists(BinaryExpr be | - be.getOp() instanceof MatMult and - be.getRight().(Attribute).getObject("never").(Name).getId() = func.getTimerParamName() and - be.getScope().getEnclosingScope*() = func and - annotated = be.getLeft() - ) } /** A timer annotation (wrapping the newtype for a clean API). */ class TimerAnnotation extends TTimerAnnotation { - /** Gets a timestamp value from this annotation. */ + /** Gets a live timestamp value from this annotation. */ int getATimestamp() { exists(this.getTimestampExpr(result)) } - /** Gets the source expression for timestamp value `ts`. */ + /** Gets the source expression for live timestamp value `ts`. */ IntegerLiteral getTimestampExpr(int ts) { - result = timestampLiteral(this.getTimestampsExpr()) and + result = liveTimestampLiteral(this.getTimestampsExpr()) and + result.getValue() = ts + } + + /** Gets a dead timestamp value from this annotation. */ + int getADeadTimestamp() { exists(this.getDeadTimestampExpr(result)) } + + /** Gets the source expression for dead timestamp value `ts`. */ + IntegerLiteral getDeadTimestampExpr(int ts) { + result = deadTimestampLiteral(this.getTimestampsExpr()) and result.getValue() = ts } - /** Gets the raw timestamp expression (single int or tuple). */ + /** Gets the raw timestamp expression (single element or tuple). */ abstract Expr getTimestampsExpr(); /** Gets the test function this annotation belongs to. */ @@ -94,18 +114,25 @@ class TimerAnnotation extends TTimerAnnotation { /** Gets the enclosing annotation expression (the `BinaryExpr` or `Call`). */ abstract Expr getTimerExpr(); - /** Holds if this is a dead-code annotation (`t.dead[n]`). */ - predicate isDead() { this instanceof DeadTimerAnnotation } + /** Holds if timestamp `ts` is marked as dead in this annotation. */ + predicate isDeadTimestamp(int ts) { ts = this.getADeadTimestamp() } + + /** Holds if all timestamps in this annotation are dead (no live timestamps). */ + predicate isDead() { + not exists(this.getATimestamp()) and + not this.isNever() and + exists(this.getADeadTimestamp()) + } - /** Holds if this is a never-evaluated annotation (`t.never`). */ - predicate isNever() { this instanceof NeverTimerAnnotation } + /** Holds if this is a never-evaluated annotation (contains `never`). */ + predicate isNever() { hasNever(this.getTimestampsExpr()) } string toString() { result = this.getAnnotatedExpr().toString() } Location getLocation() { result = this.getAnnotatedExpr().getLocation() } } -/** A matmul-based timer annotation: `expr @ t[n]`. */ +/** A matmul-based timer annotation: `expr @ t[...]`. */ class MatmulTimerAnnotation extends TMatmulAnnotation, TimerAnnotation { TestFunction func; Expr annotated; @@ -139,39 +166,6 @@ class CallTimerAnnotation extends TCallAnnotation, TimerAnnotation { override Call getTimerExpr() { result.getArg(0) = annotated } } -/** A dead-code timer annotation: `expr @ t.dead[n]`. */ -class DeadTimerAnnotation extends TDeadAnnotation, TimerAnnotation { - TestFunction func; - Expr annotated; - Expr timestamps; - - DeadTimerAnnotation() { this = TDeadAnnotation(func, annotated, timestamps) } - - override Expr getTimestampsExpr() { result = timestamps } - - override TestFunction getTestFunction() { result = func } - - override Expr getAnnotatedExpr() { result = annotated } - - override BinaryExpr getTimerExpr() { result.getLeft() = annotated } -} - -/** A never-evaluated annotation: `expr @ t.never`. */ -class NeverTimerAnnotation extends TNeverAnnotation, TimerAnnotation { - TestFunction func; - Expr annotated; - - NeverTimerAnnotation() { this = TNeverAnnotation(func, annotated) } - - override Expr getTimestampsExpr() { none() } - - override TestFunction getTestFunction() { result = func } - - override Expr getAnnotatedExpr() { result = annotated } - - override BinaryExpr getTimerExpr() { result.getLeft() = annotated } -} - /** * Signature module defining the CFG interface needed by evaluation-order tests. * This allows the test utilities to be instantiated with different CFG implementations. @@ -191,6 +185,12 @@ signature module EvalOrderCfgSig { /** Gets a successor of this CFG node (including exceptional). */ CfgNode getASuccessor(); + /** Gets a true-branch successor of this CFG node, if any. */ + CfgNode getATrueSuccessor(); + + /** Gets a false-branch successor of this CFG node, if any. */ + CfgNode getAFalseSuccessor(); + /** Gets an exceptional successor of this CFG node. */ CfgNode getAnExceptionalSuccessor(); @@ -251,7 +251,10 @@ module EvalOrderCfgUtils { /** Gets the test function this annotation belongs to. */ TestFunction getTestFunction() { result = annot.getTestFunction() } - /** Holds if this is a dead-code annotation. */ + /** Holds if timestamp `ts` is marked as dead. */ + predicate isDeadTimestamp(int ts) { annot.isDeadTimestamp(ts) } + + /** Holds if all timestamps in this annotation are dead. */ predicate isDead() { annot.isDead() } /** Holds if this is a never-evaluated annotation. */ @@ -275,6 +278,42 @@ module EvalOrderCfgUtils { ) } + /** + * Holds if `next` is the next timer annotation reachable from `n` via + * the true branch, skipping non-annotated intermediaries and after-value + * nodes for the same AST node. + */ + predicate nextTimerAnnotationFromTrue(CfgNode n, TimerCfgNode next) { + exists(CfgNode trueSucc | + trueSucc = n.getATrueSuccessor() and + trueSucc.getScope() = n.getScope() + | + // If the true successor is a different annotated node, use it + next = trueSucc and next.getNode() != n.getNode() + or + // Otherwise skip through it (it's an after-value node for the same expr) + nextTimerAnnotation(trueSucc, next) + ) + } + + /** + * Holds if `next` is the next timer annotation reachable from `n` via + * the false branch, skipping non-annotated intermediaries and after-value + * nodes for the same AST node. + */ + predicate nextTimerAnnotationFromFalse(CfgNode n, TimerCfgNode next) { + exists(CfgNode falseSucc | + falseSucc = n.getAFalseSuccessor() and + falseSucc.getScope() = n.getScope() + | + // If the false successor is a different annotated node, use it + next = falseSucc and next.getNode() != n.getNode() + or + // Otherwise skip through it (it's an after-value node for the same expr) + nextTimerAnnotation(falseSucc, next) + ) + } + /** CFG-dependent test predicates, one per evaluation-order query. */ module CfgTests { /** @@ -352,7 +391,8 @@ module EvalOrderCfgUtils { * Holds if the expression annotated with `t.never` is reachable from * its scope's entry. */ - predicate neverReachable(NeverTimerAnnotation ann) { + predicate neverReachable(TimerAnnotation ann) { + ann.isNever() and exists(CfgNode n, Scope s | n.getNode() = ann.getAnnotatedExpr() and s = n.getScope() and @@ -438,6 +478,61 @@ module EvalOrderCfgUtils { predicate annotationWithCfgNode(TimerAnnotation ann) { exists(CfgNode n | n.getNode() = ann.getAnnotatedExpr()) } + + /** + * Holds if annotation `ann` with timestamp `a` has no consecutive + * predecessor (expected `a - 1`) in the CFG. + */ + predicate consecutivePredecessorTimestamps(TimerAnnotation ann, int a) { + not hasNestedScopeAnnotation(ann.getTestFunction()) and + not ann.isDead() and + a = ann.getATimestamp() and + not exists(TimerCfgNode x, TimerCfgNode y | + ann.getAnnotatedExpr() = y.getNode() and + nextTimerAnnotation(x, y) and + (a - 1) = x.getATimestamp() + ) and + // Exclude the minimum timestamp in the function (it has no predecessor) + not a = + min(TimerAnnotation other | + other.getTestFunction() = ann.getTestFunction() and + not other.isDead() + | + other.getATimestamp() + ) + } + + /** + * Holds if `node` has both a true and false successor, but the true + * successor's timestamp `ts` is not marked as dead on the false + * successor (or vice versa). + * + * This checks that boolean branches are properly annotated: when a + * condition splits into true/false paths, the next annotated node + * on each side should account for the other side's timestamps as dead. + */ + predicate missingBranchTimestamp(TimerCfgNode node, int ts, string branch) { + not hasNestedScopeAnnotation(node.getTestFunction()) and + exists(TimerCfgNode trueNext, TimerCfgNode falseNext | + nextTimerAnnotationFromTrue(node, trueNext) and + nextTimerAnnotationFromFalse(node, falseNext) and + trueNext != falseNext + | + // True successor has live timestamp ts, but false successor + // doesn't have it as dead + ts = trueNext.getATimestamp() and + not falseNext.isDeadTimestamp(ts) and + not ts = falseNext.getATimestamp() and + branch = "false" + or + // False successor has live timestamp ts, but true successor + // doesn't have it as dead + ts = falseNext.getATimestamp() and + not trueNext.isDeadTimestamp(ts) and + not ts = trueNext.getATimestamp() and + branch = "true" + ) + } } } diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/test_assert_raise.py b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_assert_raise.py index 9958d922ec8f..692a9c6e407c 100644 --- a/python/ql/test/library-tests/ControlFlow/evaluation-order/test_assert_raise.py +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_assert_raise.py @@ -1,6 +1,6 @@ """Assert and raise statement evaluation order.""" -from timer import test +from timer import test, dead @test @@ -13,7 +13,7 @@ def test_assert_true(t): @test def test_assert_true_with_message(t): x = True @ t[0] - assert x @ t[1], "msg" @ t.dead[2] + assert x @ t[1], "msg" @ t[dead(2)] y = 1 @ t[2] diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/test_basic.py b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_basic.py index f2ece3a0820d..3e8ee925d913 100644 --- a/python/ql/test/library-tests/ControlFlow/evaluation-order/test_basic.py +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_basic.py @@ -8,7 +8,7 @@ timer mechanism itself (t[n], t.dead[n]). """ -from timer import test +from timer import test, never @test @@ -178,7 +178,7 @@ def test_unreachable_after_return(t): def f(): x = 1 @ t[1] return x @ t[2] - y = 2 @ t.never + y = 2 @ t[never] result = (f @ t[0])() @ t[3] diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/test_boolean.py b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_boolean.py index d8183cb64842..a3b2268a8315 100644 --- a/python/ql/test/library-tests/ControlFlow/evaluation-order/test_boolean.py +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_boolean.py @@ -1,30 +1,30 @@ """Short-circuit boolean operators and evaluation order.""" -from timer import test +from timer import test, dead @test def test_and_both_sides(t): # True and X — both operands evaluated, result is X - x = (True @ t[0] and 42 @ t[1]) @ t[2] + x = (True @ t[0] and 42 @ t[1, dead(2)]) @ t[dead(1), 2] @test def test_and_short_circuit(t): # False and ... — right side never evaluated - x = (False @ t[0] and True @ t.dead[1]) @ t[1] + x = (False @ t[0] and True @ t[dead(1)]) @ t[1, dead(2)] @test def test_or_short_circuit(t): # True or ... — right side never evaluated - x = (True @ t[0] or False @ t.dead[1]) @ t[1] + x = (True @ t[0] or False @ t[dead(1)]) @ t[1, dead(2)] @test def test_or_both_sides(t): # False or X — both operands evaluated, result is X - x = (False @ t[0] or 42 @ t[1]) @ t[2] + x = (False @ t[0] or 42 @ t[1, dead(2)]) @ t[dead(1), 2] @test @@ -37,19 +37,19 @@ def test_not(t): @test def test_chained_and(t): # 1 and 2 and 3 — all truthy, all evaluated left-to-right - x = (1 @ t[0] and 2 @ t[1] and 3 @ t[2]) @ t[3] + x = (1 @ t[0] and 2 @ t[1, dead(3)] and 3 @ t[2, dead(3)]) @ t[dead(1), dead(2), 3] @test def test_chained_or(t): # 0 or "" or 42 — first two falsy, all evaluated until truthy found - x = (0 @ t[0] or "" @ t[1] or 42 @ t[2]) @ t[3] + x = (0 @ t[0] or "" @ t[1, dead(3)] or 42 @ t[2, dead(3)]) @ t[dead(1), dead(2), 3] @test def test_mixed_and_or(t): # True and False or 42 => (True and False) or 42 => False or 42 => 42 - x = ((True @ t[0] and False @ t[1]) @ t[2] or 42 @ t[3]) @ t[4] + x = ((True @ t[0] and False @ t[1, dead(2)]) @ t[dead(1), 2, dead(4)] or 42 @ t[3, dead(4)]) @ t[dead(2), dead(3), 4] @test diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/test_conditional.py b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_conditional.py index 2c543e913e4d..48d45a779583 100644 --- a/python/ql/test/library-tests/ControlFlow/evaluation-order/test_conditional.py +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_conditional.py @@ -1,38 +1,38 @@ """Ternary conditional expressions and evaluation order.""" -from timer import test +from timer import test, dead @test def test_ternary_true(t): # Condition is True — consequent evaluated, alternative skipped - x = (1 @ t[1] if True @ t[0] else 2 @ t.dead[1]) @ t[2] + x = (1 @ t[1] if True @ t[0] else 2 @ t[dead(1)]) @ t[2] @test def test_ternary_false(t): # Condition is False — alternative evaluated, consequent skipped - x = (1 @ t.dead[1] if False @ t[0] else 2 @ t[1]) @ t[2] + x = (1 @ t[dead(1)] if False @ t[0] else 2 @ t[1]) @ t[2] @test def test_ternary_nested(t): # Nested: outer condition True, inner condition True # ((10 if C1 else 20) if C2 else 30) — C2 first, then C1, then 10 - x = ((10 @ t[2] if True @ t[1] else 20 @ t.dead[2]) @ t[3] if True @ t[0] else 30 @ t.dead[1]) @ t[4] + x = ((10 @ t[2] if True @ t[1] else 20 @ t[dead(2)]) @ t[3] if True @ t[0] else 30 @ t[dead(1)]) @ t[4] @test def test_ternary_assignment(t): # Ternary result assigned, then used in later expression - value = (100 @ t[1] if True @ t[0] else 200 @ t.dead[1]) @ t[2] + value = (100 @ t[1] if True @ t[0] else 200 @ t[dead(1)]) @ t[2] result = (value @ t[3] + 1 @ t[4]) @ t[5] @test def test_ternary_complex_expressions(t): # Complex sub-expressions in condition and consequent - x = ((1 @ t[3] + 2 @ t[4]) @ t[5] if (3 @ t[0] > 2 @ t[1]) @ t[2] else (4 @ t.dead[3] + 5 @ t.dead[4]) @ t.dead[5]) @ t[6] + x = ((1 @ t[3] + 2 @ t[4]) @ t[5] if (3 @ t[0] > 2 @ t[1]) @ t[2] else (4 @ t[dead(3)] + 5 @ t[dead(4)]) @ t[dead(5)]) @ t[6] @test @@ -41,4 +41,4 @@ def test_ternary_as_argument(t): def f(a): return a @ t[4] - result = (f @ t[0])((1 @ t[2] if True @ t[1] else 2 @ t.dead[2]) @ t[3]) @ t[5] + result = (f @ t[0])((1 @ t[2] if True @ t[1] else 2 @ t[dead(2)]) @ t[3]) @ t[5] diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/test_if.py b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_if.py index 3190e94c6eba..79abb278684c 100644 --- a/python/ql/test/library-tests/ControlFlow/evaluation-order/test_if.py +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_if.py @@ -1,6 +1,6 @@ """If/elif/else control flow evaluation order.""" -from timer import test +from timer import test, dead @test @@ -15,7 +15,7 @@ def test_if_true(t): def test_if_false(t): x = False @ t[0] if x @ t[1]: - y = 1 @ t.dead[2] + y = 1 @ t[dead(2)] z = 0 @ t[2] @@ -25,7 +25,7 @@ def test_if_else_true(t): if x @ t[1]: y = 1 @ t[2] else: - y = 2 @ t.dead[2] + y = 2 @ t[dead(2)] z = 0 @ t[3] @@ -33,7 +33,7 @@ def test_if_else_true(t): def test_if_else_false(t): x = False @ t[0] if x @ t[1]: - y = 1 @ t.dead[2] + y = 1 @ t[dead(2)] else: y = 2 @ t[2] z = 0 @ t[3] @@ -44,10 +44,10 @@ def test_if_elif_else_first(t): x = 1 @ t[0] if (x @ t[1] == 1 @ t[2]) @ t[3]: y = "first" @ t[4] - elif (x @ t.dead[4] == 2 @ t.dead[5]) @ t.dead[6]: - y = "second" @ t.dead[4] + elif (x @ t[dead(4)] == 2 @ t[dead(5)]) @ t[dead(6)]: + y = "second" @ t[dead(4)] else: - y = "third" @ t.dead[4] + y = "third" @ t[dead(4)] z = 0 @ t[5] @@ -55,11 +55,11 @@ def test_if_elif_else_first(t): def test_if_elif_else_second(t): x = 2 @ t[0] if (x @ t[1] == 1 @ t[2]) @ t[3]: - y = "first" @ t.dead[7] + y = "first" @ t[dead(7)] elif (x @ t[4] == 2 @ t[5]) @ t[6]: y = "second" @ t[7] else: - y = "third" @ t.dead[7] + y = "third" @ t[dead(7)] z = 0 @ t[8] @@ -67,9 +67,9 @@ def test_if_elif_else_second(t): def test_if_elif_else_third(t): x = 3 @ t[0] if (x @ t[1] == 1 @ t[2]) @ t[3]: - y = "first" @ t.dead[7] + y = "first" @ t[dead(7)] elif (x @ t[4] == 2 @ t[5]) @ t[6]: - y = "second" @ t.dead[7] + y = "second" @ t[dead(7)] else: y = "third" @ t[7] z = 0 @ t[8] @@ -83,9 +83,9 @@ def test_nested_if_else(t): if y @ t[3]: z = 1 @ t[4] else: - z = 2 @ t.dead[4] + z = 2 @ t[dead(4)] else: - z = 3 @ t.dead[4] + z = 3 @ t[dead(4)] w = 0 @ t[5] @@ -94,7 +94,7 @@ def test_if_compound_condition(t): x = True @ t[0] y = False @ t[1] if (x @ t[2] and y @ t[3]) @ t[4]: - z = 1 @ t.dead[5] + z = 1 @ t[dead(5)] else: z = 2 @ t[5] w = 0 @ t[6] @@ -106,3 +106,9 @@ def test_if_pass(t): if x @ t[1]: pass z = 0 @ t[2] + + +@test + + +@test diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/test_loops.py b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_loops.py index e81c31acde5c..17df7a4703a3 100644 --- a/python/ql/test/library-tests/ControlFlow/evaluation-order/test_loops.py +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_loops.py @@ -1,6 +1,6 @@ """Loop control flow evaluation order tests.""" -from timer import test +from timer import test, dead # 1. Simple while loop (fixed iterations) @@ -55,7 +55,7 @@ def test_while_else_break(t): break i = (i @ t[7] + 1 @ t[8]) @ t[9] else: - never = True @ t.dead[16] + never = True @ t[dead(16)] after = True @ t[16] @@ -113,7 +113,7 @@ def test_for_else_break(t): break x @ t[7] else: - never = True @ t.dead[11] + never = True @ t[dead(11)] after = True @ t[11] @@ -122,8 +122,8 @@ def test_for_else_break(t): def test_nested_loops(t): for i in [1 @ t[0], 2 @ t[1]] @ t[2]: for j in [10 @ t[3, 12], 20 @ t[4, 13]] @ t[5, 14]: - (i @ t[6, 9, 15, 18] + j @ t[7, 10, 16, 19]) @ t[8, 11, 17, 20] - done = True @ t[21] + (i @ t[6, 9, 15, 18, dead(21)] + j @ t[7, 10, 16, 19]) @ t[8, 11, 17, 20] + done = True @ t[dead(3), dead(6), dead(9), dead(12), dead(15), dead(18), 21] # 13. While True with conditional break diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/test_match.py b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_match.py index 1dac5b0985c9..ba15a2d7c857 100644 --- a/python/ql/test/library-tests/ControlFlow/evaluation-order/test_match.py +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_match.py @@ -7,7 +7,7 @@ print("0/0 tests passed") sys.exit(0) -from timer import test +from timer import test, dead, never @test @@ -17,7 +17,7 @@ def test_match_literal(t): case 1: y = "one" @ t[2] case 2: - y = "two" @ t.dead[2] + y = "two" @ t[dead(2)] z = y @ t[3] @@ -26,9 +26,9 @@ def test_match_literal_fallthrough(t): x = 3 @ t[0] match x @ t[1]: case 1: - y = "one" @ t.dead[2] + y = "one" @ t[dead(2)] case 2: - y = "two" @ t.dead[2] + y = "two" @ t[dead(2)] case 3: y = "three" @ t[2] z = y @ t[3] @@ -39,7 +39,7 @@ def test_match_wildcard(t): x = 42 @ t[0] match x @ t[1]: case 1: - y = "one" @ t.dead[2] + y = "one" @ t[dead(2)] case _: y = "other" @ t[2] z = y @ t[3] @@ -61,7 +61,7 @@ def test_match_or_pattern(t): case 1 | 2: y = "low" @ t[2] case _: - y = "other" @ t.dead[2] + y = "other" @ t[dead(2)] z = y @ t[3] @@ -72,7 +72,7 @@ def test_match_guard(t): case n if (n @ t[2] > 3 @ t[3]) @ t[4]: y = n @ t[5] case _: - y = 0 @ t.dead[5] + y = 0 @ t[dead(5)] z = y @ t[6] @@ -83,7 +83,7 @@ def test_match_class_pattern(t): case int(): y = "integer" @ t[2] case str(): - y = "string" @ t.dead[2] + y = "string" @ t[dead(2)] z = y @ t[3] @@ -94,7 +94,7 @@ def test_match_sequence(t): case [a, b]: y = (a @ t[4] + b @ t[5]) @ t[6] case _: - y = 0 @ t.dead[6] + y = 0 @ t[dead(6)] z = y @ t[7] @@ -105,7 +105,7 @@ def test_match_mapping(t): case {"key": value}: y = value @ t[4] case _: - y = 0 @ t.dead[4] + y = 0 @ t[dead(4)] z = y @ t[5] @@ -116,7 +116,7 @@ def test_match_nested(t): case {"users": [{"name": name}]}: y = name @ t[7] case _: - y = "unknown" @ t.dead[7] + y = "unknown" @ t[dead(7)] z = y @ t[8] @@ -129,7 +129,7 @@ def test_match_or_pattern_with_as(t): result = ((uses @ t[2]).partition @ t[3])("@" @ t[4]) @ t[5] x = (result @ t[6])[0 @ t[7]] @ t[8] case _: - raise ((ValueError @ t.dead[2])(clause @ t.dead[3]) @ t.dead[4]) + raise ((ValueError @ t[dead(2)])(clause @ t[dead(3)]) @ t[dead(4)]) y = x @ t[9] @@ -140,7 +140,7 @@ def test_match_wildcard_raise(t): try: match clause @ t[1]: case (str() as uses) | {"uses": uses}: - result = uses @ t.dead[2] + result = uses @ t[dead(2)] case _: raise ((ValueError @ t[2])(f"Invalid: {clause @ t[3]}" @ t[4]) @ t[5]) except ValueError: @@ -155,8 +155,8 @@ def f(x): case 1: return "one" @ t[3] case _: - return "other" @ t.dead[3] - y = 0 @ t.never + return "other" @ t[dead(3)] + y = 0 @ t[never] result = (f @ t[0])(1 @ t[1]) @ t[4] @@ -166,8 +166,8 @@ def test_match_exhaustive_return_wildcard(t): def f(x): match x @ t[2]: case 1: - return "one" @ t.dead[3] + return "one" @ t[dead(3)] case _: return "other" @ t[3] - y = 0 @ t.never + y = 0 @ t[never] result = (f @ t[0])(99 @ t[1]) @ t[4] diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/test_try.py b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_try.py index d54730478b11..dd0b15457d69 100644 --- a/python/ql/test/library-tests/ControlFlow/evaluation-order/test_try.py +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/test_try.py @@ -1,6 +1,6 @@ """Exception handling control flow: try/except/else/finally evaluation order.""" -from timer import test +from timer import test, dead, never # 1. try/except — no exception raised (except block skipped) @@ -10,7 +10,7 @@ def test_try_no_exception(t): x = 1 @ t[0] y = 2 @ t[1] except ValueError: - z = 3 @ t.dead[2] + z = 3 @ t[dead(2)] after = 0 @ t[2] @@ -20,7 +20,7 @@ def test_try_with_exception(t): try: x = 1 @ t[0] raise ((ValueError @ t[1])() @ t[2]) - y = 2 @ t.never + y = 2 @ t[never] except ValueError: z = 3 @ t[3] after = 0 @ t[4] @@ -32,7 +32,7 @@ def test_try_except_else_no_exception(t): try: x = 1 @ t[0] except ValueError: - y = 2 @ t.dead[1] + y = 2 @ t[dead(1)] else: z = 3 @ t[1] after = 0 @ t[2] @@ -47,7 +47,7 @@ def test_try_except_else_with_exception(t): except ValueError: y = 2 @ t[3] else: - z = 3 @ t.dead[3] + z = 3 @ t[dead(3)] after = 0 @ t[4] @@ -81,7 +81,7 @@ def test_try_except_finally_no_exception(t): try: x = 1 @ t[0] except ValueError: - y = 2 @ t.dead[1] + y = 2 @ t[dead(1)] finally: z = 3 @ t[1] after = 0 @ t[2] @@ -109,7 +109,7 @@ def test_multiple_except_first(t): except ValueError: y = 2 @ t[3] except TypeError: - z = 3 @ t.dead[3] + z = 3 @ t[dead(3)] after = 0 @ t[4] @@ -120,7 +120,7 @@ def test_multiple_except_second(t): x = 1 @ t[0] raise ((TypeError @ t[1])() @ t[2]) except ValueError: - y = 2 @ t.dead[3] + y = 2 @ t[dead(3)] except TypeError: z = 3 @ t[3] after = 0 @ t[4] @@ -149,7 +149,7 @@ def test_nested_try_except(t): z = 3 @ t[4] w = 4 @ t[5] except TypeError: - v = 5 @ t.dead[6] + v = 5 @ t[dead(6)] after = 0 @ t[6] diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/timer.py b/python/ql/test/library-tests/ControlFlow/evaluation-order/timer.py index 6cec3fd50cba..e10dde2592af 100644 --- a/python/ql/test/library-tests/ControlFlow/evaluation-order/timer.py +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/timer.py @@ -5,7 +5,7 @@ Usage with @test decorator (preferred): - from timer import test + from timer import test, dead, never @test def test_sequential(t): @@ -13,18 +13,14 @@ def test_sequential(t): y = 2 @ t[1] z = (x + y) @ t[2] -Usage with context manager (manual): - - from timer import Timer - - with Timer("my_test") as t: - x = 1 @ t[0] - -Timer API: - t[n] - assert current timestamp is n, return marker - t[n, m, ...] - assert current timestamp is one of {n, m, ...} - t["label"] - record current timestamp under label (development aid) - t(value, n) - equivalent to: value @ t[n] +Annotation forms: + t[n] - assert current timestamp is n, return marker + t[n, m, ...] - assert current timestamp is one of {n, m, ...} + t[dead(n)] - mark timestamp n as dead (fails if evaluated) + t[dead(n), m] - dead at n, live at m + t[never] - mark as never evaluated (fails if evaluated) + t["label"] - record current timestamp under label (development aid) + t(value, n) - equivalent to: value @ t[n] Run a test file directly to self-validate: python test_file.py """ @@ -36,19 +32,41 @@ def test_sequential(t): class _Check: - """Marker returned by t[n] — asserts the current timestamp.""" + """Marker returned by t[n] — asserts the current timestamp. + + Receives the raw subscript elements: plain ints are live timestamps, + dead(n) markers are dead timestamps, and `never` means any evaluation + is an error. + """ - __slots__ = ("_timer", "_expected") + __slots__ = ("_timer", "_live", "_dead", "_never") - def __init__(self, timer, expected): + def __init__(self, timer, elements): self._timer = timer - self._expected = expected + self._live = set() + self._dead = set() + self._never = False + for e in elements: + if isinstance(e, int): + self._live.add(e) + elif isinstance(e, _DeadMarker): + self._dead.add(e.timestamp) + elif isinstance(e, _NeverSentinel): + self._never = True def __rmatmul__(self, value): ts = self._timer._tick() - if ts not in self._expected: + if self._never: self._timer._error( - f"expected {sorted(self._expected)}, got {ts}" + f"expression annotated with t[never] was evaluated (timestamp {ts})" + ) + elif ts in self._dead: + self._timer._error( + f"timestamp {ts} is marked dead but was evaluated" + ) + elif ts not in self._live: + self._timer._error( + f"expected {sorted(self._live)}, got {ts}" ) return value @@ -68,36 +86,24 @@ def __rmatmul__(self, value): return value -class _NeverCheck: - """Marker returned by t.never — fails if the expression is ever evaluated.""" +class _DeadMarker: + """Marker returned by dead(n) — used inside t[...] to mark a timestamp as dead.""" - def __init__(self, timer): - self._timer = timer + def __init__(self, timestamp): + self.timestamp = timestamp - def __rmatmul__(self, value): - self._timer._error("expression annotated with t.never was evaluated") - return value - - -class _DeadCheck: - """Marker returned by t.dead[n] — fails if the expression is ever evaluated.""" - - def __init__(self, timer): - self._timer = timer - def __rmatmul__(self, value): - self._timer._error("expression annotated with t.dead was evaluated") - return value +def dead(n): + """Mark timestamp `n` as dead code inside a timer subscript: t[dead(1), 2].""" + return _DeadMarker(n) -class _DeadSubscript: - """Subscriptable returned by t.dead — produces _DeadCheck markers.""" +class _NeverSentinel: + """Sentinel for never-evaluated annotations: t[never].""" + pass - def __init__(self, timer): - self._timer = timer - def __getitem__(self, key): - return _DeadCheck(self._timer) +never = _NeverSentinel() class Timer: @@ -113,8 +119,6 @@ def __init__(self, name=""): self._counter = 0 self._errors = [] self._labels = {} - self.dead = _DeadSubscript(self) - self.never = _NeverCheck(self) def __enter__(self): return self @@ -144,7 +148,7 @@ def __getitem__(self, key): if isinstance(key, str): return _Label(self, key) elif isinstance(key, tuple): - return _Check(self, list(key)) + return _Check(self, key) else: return _Check(self, [key]) From 68b3d575633222a2af66ea0235e3204ba9885714 Mon Sep 17 00:00:00 2001 From: Taus Date: Tue, 28 Apr 2026 14:59:11 +0000 Subject: [PATCH 28/44] Cleanup, printCFG Co-authored-by: yoff --- python/ql/lib/printCfgNew.ql | 45 +++++++++++++++++++ .../controlflow/internal/AstNodeImpl.qll | 43 +++++++++++++++++- 2 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 python/ql/lib/printCfgNew.ql diff --git a/python/ql/lib/printCfgNew.ql b/python/ql/lib/printCfgNew.ql new file mode 100644 index 000000000000..7c098cbf8f66 --- /dev/null +++ b/python/ql/lib/printCfgNew.ql @@ -0,0 +1,45 @@ +/** + * @name Print CFG (New) + * @description Produces a representation of a file's Control Flow Graph + * using the new shared control flow library. + * This query is used by the VS Code extension. + * @id python/print-cfg + * @kind graph + * @tags ide-contextual-queries/print-cfg + */ + +private import python as Py +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 { + predicate selectedSourceFile = selectedSourceFileAlias/0; + + predicate selectedSourceLine = selectedSourceLineAlias/0; + + predicate selectedSourceColumn = selectedSourceColumnAlias/0; + + predicate cfgScopeSpan( + AstSigImpl::Callable callable, Py::File file, int startLine, int startColumn, int endLine, + int endColumn + ) { + exists(Py::Scope scope | + scope = callable.asScope() and + file = scope.getLocation().getFile() and + scope.getLocation().hasLocationInfo(_, startLine, startColumn, endLine, endColumn) + ) + } +} + +import ControlFlow::ViewCfgQuery diff --git a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll index c5e2d010688c..15ec5dbfa734 100644 --- a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll +++ b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll @@ -11,6 +11,7 @@ private import python as Py private import codeql.controlflow.ControlFlowGraph private import codeql.controlflow.SuccessorType +private import codeql.util.Void private module Ast { /** The newtype representing AST nodes for the shared CFG library. */ @@ -717,6 +718,8 @@ module AstSigImpl implements AstSig { index = 0 and result = w.getTest() or index = 1 and result = w.getBody() + or + index = 2 and result = w.getOrelse() ) or // ForStmt (mapped as ForeachStmt): collection (0), variable (1), body (2) @@ -1046,12 +1049,17 @@ module AstSigImpl implements AstSig { Expr getExpr() { result = this.getValue() } } - /** A `raise` statement (mapped to `ThrowStmt`). */ - class ThrowStmt extends Stmt, Ast::RaiseNode { + /** A `raise` statement (mapped to `Throw`). */ + class Throw extends Stmt, Ast::RaiseNode { /** Gets the expression being raised. */ Expr getExpr() { result = this.getException() } } + /** A `goto` statement. Python has no goto. */ + class GotoStmt extends Stmt { + GotoStmt() { none() } + } + // ===== Try/except ===== /** A `try` statement. */ class TryStmt extends Stmt { @@ -1171,6 +1179,26 @@ module AstSigImpl implements AstSig { NullCoalescingExpr() { none() } } + /** An assignment expression. Python has no assignment expressions in the BinaryExpr sense. */ + class Assignment extends BinaryExpr { + Assignment() { none() } + } + + /** A simple assignment expression. */ + class AssignExpr extends Assignment { } + + /** A compound assignment expression. */ + class CompoundAssignment extends Assignment { } + + /** A short-circuiting logical AND compound assignment. Python has no `&&=` operator. */ + class AssignLogicalAndExpr extends CompoundAssignment { } + + /** A short-circuiting logical OR compound assignment. Python has no `||=` operator. */ + class AssignLogicalOrExpr extends CompoundAssignment { } + + /** A short-circuiting null-coalescing compound assignment. Python has no `??=` operator. */ + class AssignNullCoalescingExpr extends CompoundAssignment { } + /** A unary expression. Exists for the `not` subclass. */ class UnaryExpr extends Expr { UnaryExpr() { this instanceof Ast::NotExprNode } @@ -1186,6 +1214,15 @@ module AstSigImpl implements AstSig { /** Gets the boolean value of this literal. */ boolean getValue() { result = this.getBoolValue() } } + + /** A pattern match expression. Python has no `instanceof`-style pattern match expr. */ + class PatternMatchExpr extends Expr { + PatternMatchExpr() { none() } + + Expr getExpr() { none() } + + AstNode getPattern() { none() } + } } private module Cfg0 = Make0; @@ -1209,6 +1246,8 @@ private module Input implements InputSig1, InputSig2 { string toString() { result = "label" } } + class CallableBodyPartContext = Void; + predicate inConditionalContext(AstSigImpl::AstNode n, ConditionKind kind) { kind.isBoolean() and n = any(Ast::AssertNode a).getTest() From 3b0abad7014f103ac5f0de8e05570175be9babf7 Mon Sep 17 00:00:00 2001 From: yoff Date: Mon, 4 May 2026 15:31:24 +0200 Subject: [PATCH 29/44] Python: add pattern nodes Co-authored-by: Copilot --- .../controlflow/internal/AstNodeImpl.qll | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll index 15ec5dbfa734..d6ac7ceb1109 100644 --- a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll +++ b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll @@ -29,7 +29,8 @@ private module Ast { * Only created for inner pairs (index >= 1); the outermost pair (index 0) * is represented by the original `BoolExpr` node via `TExprNode`. */ - TBoolExprPair(Py::BoolExpr be, int index) { index = [1 .. count(be.getAValue()) - 2] } + TBoolExprPair(Py::BoolExpr be, int index) { index = [1 .. count(be.getAValue()) - 2] } or + TPatternNode(Py::Pattern p) /** * An AST node for the shared CFG. Each branch of the newtype gets a @@ -122,6 +123,21 @@ private module Ast { } } + class PatternNode extends Node, TPatternNode { + private Py::Pattern pattern; + + PatternNode() { this = TPatternNode(pattern) } + + /** Gets the underlying Python pattern. */ + Py::Pattern asPattern() { result = pattern } + + override string toString() { result = pattern.toString() } + + override Py::Location getLocation() { result = pattern.getLocation() } + + override ScopeNode getEnclosingScope() { result.asScope() = pattern.getScope() } + } + /** An `if` statement. */ class IfNode extends StmtNode { private Py::If ifStmt; @@ -289,6 +305,8 @@ private module Ast { CaseNode() { caseStmt = this.asStmt() } + PatternNode getPattern() { result.asPattern() = caseStmt.getPattern() } + ExprNode getGuard() { result.asExpr() = caseStmt.getGuard().(Py::Guard).getTest() } StmtListNode getBody() { result.asStmtList() = caseStmt.getBody() } @@ -778,9 +796,11 @@ module AstSigImpl implements AstSig { or // Case: guard (0), body (1) exists(Ast::CaseNode c | c = n | - index = 0 and result = c.getGuard() + index = 0 and result = c.getPattern() + or + index = 1 and result = c.getGuard() or - index = 1 and result = c.getBody() + index = 2 and result = c.getBody() ) or // CatchClause (except handler): type (0), name (1), body (2) @@ -1101,7 +1121,7 @@ module AstSigImpl implements AstSig { class Case extends Stmt { Case() { this instanceof Ast::CaseNode } - AstNode getAPattern() { none() } + AstNode getAPattern() { result = this.(Ast::CaseNode).getPattern() } Expr getGuard() { result = this.(Ast::CaseNode).getGuard() } From 0b4a24884f5ef12390377d363584cd00e9e4c86f Mon Sep 17 00:00:00 2001 From: yoff Date: Mon, 4 May 2026 15:34:33 +0200 Subject: [PATCH 30/44] python: add consistency checks Co-authored-by: aschackmull --- python/ql/consistency-queries/CfgConsistency.ql | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 python/ql/consistency-queries/CfgConsistency.ql diff --git a/python/ql/consistency-queries/CfgConsistency.ql b/python/ql/consistency-queries/CfgConsistency.ql new file mode 100644 index 000000000000..ab13eddf190c --- /dev/null +++ b/python/ql/consistency-queries/CfgConsistency.ql @@ -0,0 +1,2 @@ +import semmle.python.controlflow.internal.AstNodeImpl +import ControlFlow::Consistency From 58cda914db6159ec8f45ddedeaf36c020fc95db7 Mon Sep 17 00:00:00 2001 From: Copilot Date: Tue, 5 May 2026 13:45:50 +0000 Subject: [PATCH 31/44] Python: collapse two-layer AstNodeImpl into a single Ast module Merge the previous `Ast` and `AstSigImpl` modules into a single `module Ast implements AstSig`. Classes now use the signature names (IfStmt, WhileStmt, ForeachStmt, etc.) and signature predicates (getCondition, getThen, getElse, etc.) directly, with no intermediate renaming layer. Drop the TStmtListNode newtype branch entirely. Replace it with a synthetic TBlockStmt(parent, slot) keyed by a parent AST node and a slot label string ('body', 'orelse', 'finally'). Py::StmtList no longer appears in the newtype; the BlockStmt class provides indexed access to the underlying body items via getStmt(n). All 22 of 24 evaluation-order tests still pass; the same 2 comprehension-related failures predate this refactor. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/ql/lib/printCfgNew.ql | 2 +- .../controlflow/internal/AstNodeImpl.qll | 1476 ++++++++--------- 2 files changed, 664 insertions(+), 814 deletions(-) diff --git a/python/ql/lib/printCfgNew.ql b/python/ql/lib/printCfgNew.ql index 7c098cbf8f66..ba336de562a7 100644 --- a/python/ql/lib/printCfgNew.ql +++ b/python/ql/lib/printCfgNew.ql @@ -31,7 +31,7 @@ module ViewCfgQueryInput implements ControlFlow::ViewCfgQueryInputSig predicate selectedSourceColumn = selectedSourceColumnAlias/0; predicate cfgScopeSpan( - AstSigImpl::Callable callable, Py::File file, int startLine, int startColumn, int endLine, + Ast::Callable callable, Py::File file, int startLine, int startColumn, int endLine, int endColumn ) { exists(Py::Scope scope | diff --git a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll index d6ac7ceb1109..8a86ffa08748 100644 --- a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll +++ b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll @@ -1,11 +1,13 @@ /** - * Provides a newtype-based interface layer that mediates between the existing - * Python AST classes and the shared control-flow library's `AstSig` signature. + * Provides classes for the shared control-flow library, mediating between + * the Python AST and `AstSig`. * - * The newtype unifies Python's `Stmt`, `Expr`, `Scope`, and `StmtList` into a - * single `AstNode` type. Notably, `StmtList` (which is not an `AstNode` in the - * existing Python AST) is wrapped as a `BlockStmt` (a subtype of `Stmt`), - * since the shared CFG library expects statement blocks to be statements. + * The `Ast` module wraps Python's `Stmt`, `Expr`, `Scope`, and `Pattern`, + * and adds two synthetic kinds of node: + * - `BlockStmt`, identifying a body slot of a parent AST node (e.g. an + * `if`'s then or else branch). `Py::StmtList` itself is not directly + * wrapped. + * - Intermediate nodes for multi-operand boolean expressions. */ private import python as Py @@ -13,465 +15,719 @@ private import codeql.controlflow.ControlFlowGraph private import codeql.controlflow.SuccessorType private import codeql.util.Void -private module Ast { - /** The newtype representing AST nodes for the shared CFG library. */ +/** Provides the Python implementation of the shared CFG `AstSig`. */ +module Ast implements AstSig { + /** + * Maps a `(parent, slot)` pair to the `Py::StmtList` that holds the items + * of the `BlockStmt` for that slot. The slot string distinguishes between + * the multiple bodies that some parents have (e.g. `if` has `body` and + * `orelse`). + */ + private Py::StmtList getBodyStmtList(Py::AstNode parent, string slot) { + result = parent.(Py::Scope).getBody() and slot = "body" + or + result = parent.(Py::If).getBody() and slot = "body" + or + result = parent.(Py::If).getOrelse() and slot = "orelse" + or + result = parent.(Py::While).getBody() and slot = "body" + or + result = parent.(Py::While).getOrelse() and slot = "orelse" + or + result = parent.(Py::For).getBody() and slot = "body" + or + result = parent.(Py::For).getOrelse() and slot = "orelse" + or + result = parent.(Py::With).getBody() and slot = "body" + or + result = parent.(Py::Try).getBody() and slot = "body" + or + result = parent.(Py::Try).getOrelse() and slot = "orelse" + or + result = parent.(Py::Try).getFinalbody() and slot = "finally" + or + result = parent.(Py::Case).getBody() and slot = "body" + or + result = parent.(Py::ExceptStmt).getBody() and slot = "body" + or + result = parent.(Py::ExceptGroupStmt).getBody() and slot = "body" + } + private newtype TAstNode = - TStmtNode(Py::Stmt s) or - TExprNode(Py::Expr e) or - TScopeNode(Py::Scope sc) or - TStmtListNode(Py::StmtList sl) or + TStmt(Py::Stmt s) or + TExpr(Py::Expr e) or + TScope(Py::Scope sc) or + TPattern(Py::Pattern p) or /** - * A synthetic node representing an intermediate pair in a multi-operand - * `and`/`or` expression. For `a and b and c` (values 0,1,2), we - * synthesize a right-nested tree: the pair at index 1 represents - * `b and c`, which becomes the right operand of the outermost pair. - * - * Only created for inner pairs (index >= 1); the outermost pair (index 0) - * is represented by the original `BoolExpr` node via `TExprNode`. + * A synthetic intermediate node in a multi-operand `and`/`or` + * expression. For `a and b and c` (operands 0, 1, 2) we model the + * operation as a right-nested tree where the inner pair at index 1 + * represents `b and c` and is the right operand of the outer pair. + * The outermost pair (index 0) is represented by the underlying + * `Py::BoolExpr` itself via `TExpr`. */ TBoolExprPair(Py::BoolExpr be, int index) { index = [1 .. count(be.getAValue()) - 2] } or - TPatternNode(Py::Pattern p) - - /** - * An AST node for the shared CFG. Each branch of the newtype gets a - * subclass that overrides `toString` and `getLocation`. - */ - class Node extends TAstNode { - string toString() { none() } - - Py::Location getLocation() { none() } + /** + * A synthetic block statement, identifying one body slot of the + * `parent` AST node. The `slot` string disambiguates among multiple + * bodies of the same parent (`"body"`, `"orelse"`, `"finally"`). + */ + TBlockStmt(Py::AstNode parent, string slot) { exists(getBodyStmtList(parent, slot)) } - /** Gets the enclosing scope of this node, if any. */ - ScopeNode getEnclosingScope() { none() } - } + /** An AST node visible to the shared CFG. */ + class AstNode extends TAstNode { + /** Gets a textual representation of this AST node. */ + string toString() { + exists(Py::Stmt s | this = TStmt(s) and result = s.toString()) + or + exists(Py::Expr e | this = TExpr(e) and result = e.toString()) + or + exists(Py::Scope sc | this = TScope(sc) and result = sc.toString()) + or + exists(Py::Pattern p | this = TPattern(p) and result = p.toString()) + or + exists(Py::BoolExpr be | this = TBoolExprPair(be, _) and result = be.getOperator()) + or + exists(string slot | this = TBlockStmt(_, slot) and result = "block:" + slot) + } - class StmtNode extends Node, TStmtNode { - private Py::Stmt stmt; + /** Gets the location of this AST node. */ + Py::Location getLocation() { + exists(Py::Stmt s | this = TStmt(s) and result = s.getLocation()) + or + exists(Py::Expr e | this = TExpr(e) and result = e.getLocation()) + or + exists(Py::Scope sc | this = TScope(sc) and result = sc.getLocation()) + or + exists(Py::Pattern p | this = TPattern(p) and result = p.getLocation()) + or + exists(Py::BoolExpr be, int index | + this = TBoolExprPair(be, index) and result = be.getValue(index).getLocation() + ) + or + // BlockStmt has no native location; approximate with the first + // item's location. + exists(Py::AstNode parent, string slot | + this = TBlockStmt(parent, slot) and + result = getBodyStmtList(parent, slot).getItem(0).getLocation() + ) + } - StmtNode() { this = TStmtNode(stmt) } + /** Gets the enclosing callable that contains this node, if any. */ + Callable getEnclosingCallable() { + exists(Py::Stmt s | this = TStmt(s) and result.asScope() = s.getScope()) + or + exists(Py::Expr e | this = TExpr(e) and result.asScope() = e.getScope()) + or + exists(Py::Scope sc | this = TScope(sc) and result.asScope() = sc.getEnclosingScope()) + or + exists(Py::Pattern p | this = TPattern(p) and result.asScope() = p.getScope()) + or + exists(Py::BoolExpr be | this = TBoolExprPair(be, _) and result.asScope() = be.getScope()) + or + exists(Py::AstNode parent | this = TBlockStmt(parent, _) | + result.asScope() = parent.(Py::Scope) + or + result.asScope() = parent.(Py::Stmt).getScope() + ) + } - /** Gets the underlying Python statement. */ - Py::Stmt asStmt() { result = stmt } + /** Gets the underlying Python `Stmt`, if this node wraps one. */ + Py::Stmt asStmt() { this = TStmt(result) } - override string toString() { result = stmt.toString() } + /** Gets the underlying Python `Expr`, if this node wraps one. */ + Py::Expr asExpr() { this = TExpr(result) } - override Py::Location getLocation() { result = stmt.getLocation() } + /** Gets the underlying Python `Scope`, if this node wraps one. */ + Py::Scope asScope() { this = TScope(result) } - /** Gets the enclosing scope of this statement. */ - override ScopeNode getEnclosingScope() { result.asScope() = stmt.getScope() } + /** Gets the underlying Python `Pattern`, if this node wraps one. */ + Py::Pattern asPattern() { this = TPattern(result) } } - class ExprNode extends Node, TExprNode { - private Py::Expr expr; - - ExprNode() { this = TExprNode(expr) } - - /** Gets the underlying Python expression. */ - Py::Expr asExpr() { result = expr } + /** Gets the immediately enclosing callable that contains `node`. */ + Callable getEnclosingCallable(AstNode node) { result = node.getEnclosingCallable() } - override string toString() { result = expr.toString() } + /** + * A callable: a function, class, or module scope. + * + * In Python, all three are executable scopes with statement bodies. + */ + class Callable extends AstNode, TScope { } - override Py::Location getLocation() { result = expr.getLocation() } + /** Gets the body of callable `c`. */ + AstNode callableGetBody(Callable c) { result = TBlockStmt(c.asScope(), "body") } - /** Gets the enclosing scope of this expression. */ - override ScopeNode getEnclosingScope() { result.asScope() = expr.getScope() } + /** A statement. */ + class Stmt extends AstNode { + Stmt() { this instanceof TStmt or this instanceof TBlockStmt } } - class ScopeNode extends Node, TScopeNode { - private Py::Scope scope; - - ScopeNode() { this = TScopeNode(scope) } + /** An expression. */ + class Expr extends AstNode { + Expr() { this instanceof TExpr or this instanceof TBoolExprPair } + } - /** Gets the underlying Python scope. */ - Py::Scope asScope() { result = scope } + /** A pattern in a `match` statement. */ + additional class Pattern extends AstNode, TPattern { } - override string toString() { result = scope.toString() } + /** + * A block statement, modeling the body of a parent AST node as a + * sequence of statements. + */ + class BlockStmt extends Stmt, TBlockStmt { + private Py::AstNode parent; + private string slot; - override Py::Location getLocation() { result = scope.getLocation() } + BlockStmt() { this = TBlockStmt(parent, slot) } - /** Gets the body of this scope. */ - StmtListNode getBody() { result.asStmtList() = scope.getBody() } + /** Gets the `n`th (zero-based) statement in this block. */ + Stmt getStmt(int n) { result = TStmt(getBodyStmtList(parent, slot).getItem(n)) } - /** Gets the enclosing scope of this scope, if any. */ - override ScopeNode getEnclosingScope() { result.asScope() = scope.getEnclosingScope() } + /** Gets the last statement in this block. */ + Stmt getLastStmt() { result = TStmt(getBodyStmtList(parent, slot).getLastItem()) } } - class StmtListNode extends Node, TStmtListNode { - private Py::StmtList stmtList; + /** An expression statement. */ + class ExprStmt extends Stmt { + private Py::ExprStmt exprStmt; - StmtListNode() { this = TStmtListNode(stmtList) } + ExprStmt() { exprStmt = this.asStmt() } - /** Gets the underlying Python statement list. */ - Py::StmtList asStmtList() { result = stmtList } + /** Gets the expression in this expression statement. */ + Expr getExpr() { result = TExpr(exprStmt.getValue()) } + } - override string toString() { result = stmtList.toString() } + /** An assignment statement (`x = y = expr`). */ + additional class AssignStmt extends Stmt { + private Py::Assign assign; - // StmtList has no native location; approximate with first item's location. - override Py::Location getLocation() { result = stmtList.getItem(0).getLocation() } + AssignStmt() { assign = this.asStmt() } - /** Gets the `n`th (zero-based) statement in this block. */ - StmtNode getItem(int n) { result.asStmt() = stmtList.getItem(n) } + Expr getValue() { result = TExpr(assign.getValue()) } - /** Gets the last statement in this block. */ - StmtNode getLastItem() { result.asStmt() = stmtList.getLastItem() } + Expr getTarget(int n) { result = TExpr(assign.getTarget(n)) } - /** Gets the enclosing scope of this statement list. */ - override ScopeNode getEnclosingScope() { - result.asScope() = stmtList.getParent().(Py::Scope) - or - result.asScope() = stmtList.getParent().(Py::Stmt).getScope() - } + int getNumberOfTargets() { result = count(assign.getATarget()) } } - class PatternNode extends Node, TPatternNode { - private Py::Pattern pattern; + /** An augmented assignment statement (`x += expr`). */ + additional class AugAssignStmt extends Stmt { + private Py::AugAssign augAssign; + + AugAssignStmt() { augAssign = this.asStmt() } - PatternNode() { this = TPatternNode(pattern) } + Expr getOperation() { result = TExpr(augAssign.getOperation()) } + } - /** Gets the underlying Python pattern. */ - Py::Pattern asPattern() { result = pattern } + /** An assignment expression / walrus operator (`x := expr`). */ + additional class NamedExpr extends Expr { + private Py::AssignExpr assignExpr; - override string toString() { result = pattern.toString() } + NamedExpr() { assignExpr = this.asExpr() } - override Py::Location getLocation() { result = pattern.getLocation() } + Expr getValue() { result = TExpr(assignExpr.getValue()) } - override ScopeNode getEnclosingScope() { result.asScope() = pattern.getScope() } + Expr getTarget() { result = TExpr(assignExpr.getTarget()) } } - /** An `if` statement. */ - class IfNode extends StmtNode { + /** + * An `if` statement. + * + * Python's `elif` chains are represented as nested `If` nodes in the + * else branch's `StmtList`. The shared CFG library handles this + * naturally: `getElse()` returns the `BlockStmt` wrapping the else + * branch, and if that block contains a single `If`, the result is + * a chained conditional. + */ + class IfStmt extends Stmt { private Py::If ifStmt; - IfNode() { ifStmt = this.asStmt() } + IfStmt() { ifStmt = this.asStmt() } + + /** Gets the underlying Python `If` statement. */ + Py::If asIf() { result = ifStmt } /** Gets the condition of this `if` statement. */ - ExprNode getTest() { result.asExpr() = ifStmt.getTest() } + Expr getCondition() { result = TExpr(ifStmt.getTest()) } - /** Gets the if-true branch. */ - StmtListNode getBody() { result.asStmtList() = ifStmt.getBody() } + /** Gets the `then` (true) branch of this `if` statement. */ + Stmt getThen() { result = TBlockStmt(ifStmt, "body") } - /** Gets the if-false branch, if any. */ - StmtListNode getOrelse() { result.asStmtList() = ifStmt.getOrelse() } + /** Gets the `else` (false) branch, if any. */ + Stmt getElse() { result = TBlockStmt(ifStmt, "orelse") } } - /** An expression statement. */ - class ExprStmtNode extends StmtNode { - private Py::ExprStmt exprStmt; - - ExprStmtNode() { exprStmt = this.asStmt() } + /** A loop statement. */ + class LoopStmt extends Stmt { + LoopStmt() { this.asStmt() instanceof Py::While or this.asStmt() instanceof Py::For } - /** Gets the expression in this statement. */ - ExprNode getValue() { result.asExpr() = exprStmt.getValue() } + /** Gets the body of this loop statement. */ + Stmt getBody() { none() } } - /** An assignment statement (`x = y = expr`). */ - class AssignNode extends StmtNode { - private Py::Assign assign; + /** A `while` loop statement. */ + class WhileStmt extends LoopStmt { + private Py::While whileStmt; - AssignNode() { assign = this.asStmt() } + WhileStmt() { whileStmt = this.asStmt() } - ExprNode getValue() { result.asExpr() = assign.getValue() } + /** Gets the boolean condition of this `while` loop. */ + Expr getCondition() { result = TExpr(whileStmt.getTest()) } - ExprNode getTarget(int n) { result.asExpr() = assign.getTarget(n) } + override Stmt getBody() { result = TBlockStmt(whileStmt, "body") } - int getNumberOfTargets() { result = count(assign.getATarget()) } + /** Gets the `else` branch of this `while` loop, if any. */ + Stmt getElse() { result = TBlockStmt(whileStmt, "orelse") } } - /** An augmented assignment statement (`x += expr`). */ - class AugAssignNode extends StmtNode { - private Py::AugAssign augAssign; - - AugAssignNode() { augAssign = this.asStmt() } + /** + * A `do-while` loop statement. Python has no do-while construct. + */ + class DoStmt extends LoopStmt { + DoStmt() { none() } - ExprNode getOperation() { result.asExpr() = augAssign.getOperation() } + Expr getCondition() { none() } } - /** An assignment expression / walrus operator (`x := expr`). */ - class AssignExprNode extends ExprNode { - private Py::AssignExpr assignExpr; + /** A C-style `for` loop. Python has no C-style for loop. */ + class ForStmt extends LoopStmt { + ForStmt() { none() } - AssignExprNode() { assignExpr = this.asExpr() } + Expr getInit(int index) { none() } - ExprNode getValue() { result.asExpr() = assignExpr.getValue() } + Expr getCondition() { none() } - ExprNode getTarget() { result.asExpr() = assignExpr.getTarget() } + Expr getUpdate(int index) { none() } } - /** A `while` statement. */ - class WhileNode extends StmtNode { - private Py::While whileStmt; - - WhileNode() { whileStmt = this.asStmt() } - - ExprNode getTest() { result.asExpr() = whileStmt.getTest() } + /** A for-each loop (`for x in iterable:`). */ + class ForeachStmt extends LoopStmt { + private Py::For forStmt; - StmtListNode getBody() { result.asStmtList() = whileStmt.getBody() } + ForeachStmt() { forStmt = this.asStmt() } - StmtListNode getOrelse() { result.asStmtList() = whileStmt.getOrelse() } - } + /** Gets the loop variable. */ + Expr getVariable() { result = TExpr(forStmt.getTarget()) } - /** A `for` statement. */ - class ForNode extends StmtNode { - private Py::For forStmt; + /** Gets the collection being iterated. */ + Expr getCollection() { result = TExpr(forStmt.getIter()) } - ForNode() { forStmt = this.asStmt() } + override Stmt getBody() { result = TBlockStmt(forStmt, "body") } - ExprNode getTarget() { result.asExpr() = forStmt.getTarget() } + /** Gets the `else` branch of this `for` loop, if any. */ + Stmt getElse() { result = TBlockStmt(forStmt, "orelse") } + } - ExprNode getIter() { result.asExpr() = forStmt.getIter() } + /** A `break` statement. */ + class BreakStmt extends Stmt { + BreakStmt() { this.asStmt() instanceof Py::Break } + } - StmtListNode getBody() { result.asStmtList() = forStmt.getBody() } + /** A `continue` statement. */ + class ContinueStmt extends Stmt { + ContinueStmt() { this.asStmt() instanceof Py::Continue } + } - StmtListNode getOrelse() { result.asStmtList() = forStmt.getOrelse() } + /** A `goto` statement. Python has no goto. */ + class GotoStmt extends Stmt { + GotoStmt() { none() } } /** A `return` statement. */ - class ReturnNode extends StmtNode { + class ReturnStmt extends Stmt { private Py::Return ret; - ReturnNode() { ret = this.asStmt() } + ReturnStmt() { ret = this.asStmt() } - ExprNode getValue() { result.asExpr() = ret.getValue() } + /** Gets the expression being returned, if any. */ + Expr getExpr() { result = TExpr(ret.getValue()) } } - /** A `raise` statement. */ - class RaiseNode extends StmtNode { + /** A `raise` statement (mapped to `Throw`). */ + class Throw extends Stmt { private Py::Raise raise; - RaiseNode() { raise = this.asStmt() } + Throw() { raise = this.asStmt() } - ExprNode getException() { result.asExpr() = raise.getException() } + /** Gets the expression being raised. */ + Expr getExpr() { result = TExpr(raise.getException()) } - ExprNode getCause() { result.asExpr() = raise.getCause() } + /** Gets the cause of this `raise`, if any. */ + Expr getCause() { result = TExpr(raise.getCause()) } } /** A `with` statement. */ - class WithNode extends StmtNode { + additional class WithStmt extends Stmt { private Py::With withStmt; - WithNode() { withStmt = this.asStmt() } - - ExprNode getContextExpr() { result.asExpr() = withStmt.getContextExpr() } - - ExprNode getOptionalVars() { result.asExpr() = withStmt.getOptionalVars() } + WithStmt() { withStmt = this.asStmt() } - StmtListNode getBody() { result.asStmtList() = withStmt.getBody() } - } + Expr getContextExpr() { result = TExpr(withStmt.getContextExpr()) } - /** A `break` statement. */ - class BreakNode extends StmtNode { - BreakNode() { this.asStmt() instanceof Py::Break } - } + Expr getOptionalVars() { result = TExpr(withStmt.getOptionalVars()) } - /** A `continue` statement. */ - class ContinueNode extends StmtNode { - ContinueNode() { this.asStmt() instanceof Py::Continue } + Stmt getBody() { result = TBlockStmt(withStmt, "body") } } /** An `assert` statement. */ - class AssertNode extends StmtNode { + additional class AssertStmt extends Stmt { private Py::Assert assertStmt; - AssertNode() { assertStmt = this.asStmt() } + AssertStmt() { assertStmt = this.asStmt() } - ExprNode getTest() { result.asExpr() = assertStmt.getTest() } + Expr getTest() { result = TExpr(assertStmt.getTest()) } - ExprNode getMsg() { result.asExpr() = assertStmt.getMsg() } + Expr getMsg() { result = TExpr(assertStmt.getMsg()) } } /** A `delete` statement. */ - class DeleteNode extends StmtNode { + additional class DeleteStmt extends Stmt { private Py::Delete del; - DeleteNode() { del = this.asStmt() } + DeleteStmt() { del = this.asStmt() } + + Expr getTarget(int n) { result = TExpr(del.getTarget(n)) } + } + + /** A `try` statement. */ + class TryStmt extends Stmt { + private Py::Try tryStmt; + + TryStmt() { tryStmt = this.asStmt() } + + Stmt getBody() { result = TBlockStmt(tryStmt, "body") } + + /** Gets the `else` branch of this `try` statement, if any. */ + Stmt getElse() { result = TBlockStmt(tryStmt, "orelse") } + + Stmt getFinally() { result = TBlockStmt(tryStmt, "finally") } - ExprNode getTarget(int n) { result.asExpr() = del.getTarget(n) } + CatchClause getCatch(int index) { result = TStmt(tryStmt.getHandler(index)) } } - /** A `match` statement. */ - class MatchStmtNode extends StmtNode { + /** + * Gets the `else` branch of `try` statement `try`, if any. + */ + AstNode getTryElse(TryStmt try) { result = try.getElse() } + + /** An exception handler (`except` or `except*`). */ + class CatchClause extends Stmt { + private Py::ExceptionHandler handler; + + CatchClause() { handler = this.asStmt() } + + /** Gets the type expression of this exception handler. */ + Expr getType() { result = TExpr(handler.getType()) } + + /** Gets the variable name of this exception handler, if any. */ + AstNode getVariable() { result = TExpr(handler.getName()) } + + /** Holds: catch clauses do not have a `Condition` in Python's model. */ + Expr getCondition() { none() } + + /** Gets the body of this exception handler. */ + Stmt getBody() { + result = TBlockStmt(handler.(Py::ExceptStmt), "body") + or + result = TBlockStmt(handler.(Py::ExceptGroupStmt), "body") + } + } + + /** A `match` statement, mapped to the shared CFG's `Switch`. */ + class Switch extends Stmt { private Py::MatchStmt matchStmt; - MatchStmtNode() { matchStmt = this.asStmt() } + Switch() { matchStmt = this.asStmt() } + + Expr getExpr() { result = TExpr(matchStmt.getSubject()) } - ExprNode getSubject() { result.asExpr() = matchStmt.getSubject() } + Case getCase(int index) { result = TStmt(matchStmt.getCase(index)) } - CaseNode getCase(int n) { result.asStmt() = matchStmt.getCase(n) } + Stmt getStmt(int index) { none() } } /** A `case` clause in a match statement. */ - class CaseNode extends StmtNode { + class Case extends Stmt { private Py::Case caseStmt; - CaseNode() { caseStmt = this.asStmt() } + Case() { caseStmt = this.asStmt() } - PatternNode getPattern() { result.asPattern() = caseStmt.getPattern() } + AstNode getAPattern() { result = TPattern(caseStmt.getPattern()) } - ExprNode getGuard() { result.asExpr() = caseStmt.getGuard().(Py::Guard).getTest() } + Expr getGuard() { result = TExpr(caseStmt.getGuard().(Py::Guard).getTest()) } - StmtListNode getBody() { result.asStmtList() = caseStmt.getBody() } + AstNode getBody() { result = TBlockStmt(caseStmt, "body") } + /** Holds if this case is a wildcard pattern (`case _:`). */ predicate isWildcard() { caseStmt.getPattern() instanceof Py::MatchWildcardPattern } } - /** A `try` statement. */ - class TryNode extends StmtNode { - private Py::Try tryStmt; + /** A wildcard case (`case _:`). */ + class DefaultCase extends Case { + DefaultCase() { this.isWildcard() } + } - TryNode() { tryStmt = this.asStmt() } + /** A conditional expression (`x if cond else y`). */ + class ConditionalExpr extends Expr { + private Py::IfExp ifExp; - StmtListNode getBody() { result.asStmtList() = tryStmt.getBody() } + ConditionalExpr() { ifExp = this.asExpr() } - StmtListNode getOrelse() { result.asStmtList() = tryStmt.getOrelse() } + /** Gets the condition of this expression. */ + Expr getCondition() { result = TExpr(ifExp.getTest()) } - StmtListNode getFinalbody() { result.asStmtList() = tryStmt.getFinalbody() } + /** Gets the true branch of this expression. */ + Expr getThen() { result = TExpr(ifExp.getBody()) } - ExceptionHandlerNode getHandler(int i) { result.asStmt() = tryStmt.getHandler(i) } + /** Gets the false branch of this expression. */ + Expr getElse() { result = TExpr(ifExp.getOrelse()) } } - /** An exception handler (`except` or `except*`). */ - class ExceptionHandlerNode extends StmtNode { - private Py::ExceptionHandler handler; + /** + * A binary expression for the shared CFG. In Python, this covers + * `and`/`or` expressions (both real 2-operand and synthetic pairs). + */ + class BinaryExpr extends Expr { + BinaryExpr() { + exists(Py::BoolExpr be | this = TExpr(be) and count(be.getAValue()) >= 2) + or + this instanceof TBoolExprPair + } - ExceptionHandlerNode() { handler = this.asStmt() } + /** Gets the left operand of this binary expression. */ + Expr getLeftOperand() { + exists(Py::BoolExpr be | this = TExpr(be) and result = TExpr(be.getValue(0))) + or + exists(Py::BoolExpr be, int i | + this = TBoolExprPair(be, i) and result = TExpr(be.getValue(i)) + ) + } - ExprNode getType() { result.asExpr() = handler.getType() } + /** Gets the right operand of this binary expression. */ + Expr getRightOperand() { + // 2-operand BoolExpr: right operand is value(1). + exists(Py::BoolExpr be | + this = TExpr(be) and + count(be.getAValue()) = 2 and + result = TExpr(be.getValue(1)) + ) + or + // 3+ operand BoolExpr (outermost): right operand is the synthetic + // pair at index 1. + exists(Py::BoolExpr be | + this = TExpr(be) and + count(be.getAValue()) > 2 and + result = TBoolExprPair(be, 1) + ) + or + // Last synthetic pair: right operand is the final value. + exists(Py::BoolExpr be, int i, int n | + this = TBoolExprPair(be, i) and + n = count(be.getAValue()) and + i = n - 2 and + result = TExpr(be.getValue(i + 1)) + ) + or + // Non-last synthetic pair: right operand is the next pair. + exists(Py::BoolExpr be, int i, int n | + this = TBoolExprPair(be, i) and + n = count(be.getAValue()) and + i < n - 2 and + result = TBoolExprPair(be, i + 1) + ) + } + } - ExprNode getName() { result.asExpr() = handler.getName() } + /** A short-circuiting logical `and` expression. */ + class LogicalAndExpr extends BinaryExpr { + LogicalAndExpr() { + exists(Py::BoolExpr be | + be.getOp() instanceof Py::And and + (this = TExpr(be) or this = TBoolExprPair(be, _)) + ) + } + } - StmtListNode getBody() { - result.asStmtList() = handler.(Py::ExceptStmt).getBody() or - result.asStmtList() = handler.(Py::ExceptGroupStmt).getBody() + /** A short-circuiting logical `or` expression. */ + class LogicalOrExpr extends BinaryExpr { + LogicalOrExpr() { + exists(Py::BoolExpr be | + be.getOp() instanceof Py::Or and + (this = TExpr(be) or this = TBoolExprPair(be, _)) + ) } } - /** A conditional expression (`x if cond else y`). */ - class IfExpNode extends ExprNode { - private Py::IfExp ifExp; + /** A null-coalescing expression. Python has no null-coalescing operator. */ + class NullCoalescingExpr extends BinaryExpr { + NullCoalescingExpr() { none() } + } + + /** + * A unary expression. Currently only used for the `not` subclass. + */ + class UnaryExpr extends Expr { + UnaryExpr() { this.asExpr().(Py::UnaryExpr).getOp() instanceof Py::Not } + + /** Gets the operand of this unary expression. */ + Expr getOperand() { result = TExpr(this.asExpr().(Py::UnaryExpr).getOperand()) } + } + + /** A logical `not` expression. */ + class LogicalNotExpr extends UnaryExpr { } + + /** An assignment expression. Python's walrus is modelled separately. */ + class Assignment extends BinaryExpr { + Assignment() { none() } + } + + class AssignExpr extends Assignment { } + + class CompoundAssignment extends Assignment { } + + class AssignLogicalAndExpr extends CompoundAssignment { } + + class AssignLogicalOrExpr extends CompoundAssignment { } + + class AssignNullCoalescingExpr extends CompoundAssignment { } + + /** A boolean literal expression (`True` or `False`). */ + class BooleanLiteral extends Expr { + BooleanLiteral() { this.asExpr() instanceof Py::True or this.asExpr() instanceof Py::False } - IfExpNode() { ifExp = this.asExpr() } + /** Gets the boolean value of this literal. */ + boolean getValue() { + this.asExpr() instanceof Py::True and result = true + or + this.asExpr() instanceof Py::False and result = false + } + } - ExprNode getTest() { result.asExpr() = ifExp.getTest() } + /** A pattern match expression. Python has no `instanceof`-style pattern match expression. */ + class PatternMatchExpr extends Expr { + PatternMatchExpr() { none() } - ExprNode getBody() { result.asExpr() = ifExp.getBody() } + Expr getExpr() { none() } - ExprNode getOrelse() { result.asExpr() = ifExp.getOrelse() } + AstNode getPattern() { none() } } + // ===== Python-specific expression classes (used by `getChild`) ===== /** A Python binary expression (arithmetic, bitwise, matmul, etc.). */ - class BinaryExprNode extends ExprNode { + additional class ArithBinaryExpr extends Expr { private Py::BinaryExpr binExpr; - BinaryExprNode() { binExpr = this.asExpr() } + ArithBinaryExpr() { binExpr = this.asExpr() } - ExprNode getLeft() { result.asExpr() = binExpr.getLeft() } + Expr getLeft() { result = TExpr(binExpr.getLeft()) } - ExprNode getRight() { result.asExpr() = binExpr.getRight() } + Expr getRight() { result = TExpr(binExpr.getRight()) } } /** A call expression (`func(args...)`). */ - class CallNode extends ExprNode { + additional class CallExpr extends Expr { private Py::Call call; - CallNode() { call = this.asExpr() } + CallExpr() { call = this.asExpr() } - ExprNode getFunc() { result.asExpr() = call.getFunc() } + Expr getFunc() { result = TExpr(call.getFunc()) } - ExprNode getPositionalArg(int n) { result.asExpr() = call.getPositionalArg(n) } + Expr getPositionalArg(int n) { result = TExpr(call.getPositionalArg(n)) } int getNumberOfPositionalArgs() { result = count(call.getAPositionalArg()) } - ExprNode getKeywordValue(int n) { - result.asExpr() = call.getNamedArg(n).(Py::Keyword).getValue() + Expr getKeywordValue(int n) { + result = TExpr(call.getNamedArg(n).(Py::Keyword).getValue()) or - result.asExpr() = call.getNamedArg(n).(Py::DictUnpacking).getValue() + result = TExpr(call.getNamedArg(n).(Py::DictUnpacking).getValue()) } int getNumberOfNamedArgs() { result = count(call.getANamedArg()) } } /** A subscript expression (`obj[index]`). */ - class SubscriptNode extends ExprNode { + additional class SubscriptExpr extends Expr { private Py::Subscript sub; - SubscriptNode() { sub = this.asExpr() } + SubscriptExpr() { sub = this.asExpr() } - ExprNode getObject() { result.asExpr() = sub.getObject() } + Expr getObject() { result = TExpr(sub.getObject()) } - ExprNode getIndex() { result.asExpr() = sub.getIndex() } + Expr getIndex() { result = TExpr(sub.getIndex()) } } /** An attribute access (`obj.name`). */ - class AttributeNode extends ExprNode { + additional class AttributeExpr extends Expr { private Py::Attribute attr; - AttributeNode() { attr = this.asExpr() } + AttributeExpr() { attr = this.asExpr() } - ExprNode getObject() { result.asExpr() = attr.getObject() } + Expr getObject() { result = TExpr(attr.getObject()) } } /** A tuple literal. */ - class TupleNode extends ExprNode { + additional class TupleExpr extends Expr { private Py::Tuple tuple; - TupleNode() { tuple = this.asExpr() } + TupleExpr() { tuple = this.asExpr() } - ExprNode getElt(int n) { result.asExpr() = tuple.getElt(n) } + Expr getElt(int n) { result = TExpr(tuple.getElt(n)) } } /** A list literal. */ - class ListNode extends ExprNode { + additional class ListExpr extends Expr { private Py::List list; - ListNode() { list = this.asExpr() } + ListExpr() { list = this.asExpr() } - ExprNode getElt(int n) { result.asExpr() = list.getElt(n) } + Expr getElt(int n) { result = TExpr(list.getElt(n)) } } /** A set literal. */ - class SetNode extends ExprNode { + additional class SetExpr extends Expr { private Py::Set set; - SetNode() { set = this.asExpr() } + SetExpr() { set = this.asExpr() } - ExprNode getElt(int n) { result.asExpr() = set.getElt(n) } + Expr getElt(int n) { result = TExpr(set.getElt(n)) } } /** A dict literal. */ - class DictNode extends ExprNode { + additional class DictExpr extends Expr { private Py::Dict dict; - DictNode() { dict = this.asExpr() } + DictExpr() { dict = this.asExpr() } /** - * Gets the key of the `n`th item (at child index `2*n`), and the - * value at child index `2*n + 1`. + * Gets the key of the `n`th item (at child index `2*n`); the value is + * at child index `2*n + 1`. */ - ExprNode getKey(int n) { result.asExpr() = dict.getItem(n).(Py::KeyValuePair).getKey() } + Expr getKey(int n) { result = TExpr(dict.getItem(n).(Py::KeyValuePair).getKey()) } - ExprNode getValue(int n) { result.asExpr() = dict.getItem(n).(Py::KeyValuePair).getValue() } + Expr getValue(int n) { result = TExpr(dict.getItem(n).(Py::KeyValuePair).getValue()) } int getNumberOfItems() { result = count(dict.getAnItem()) } } /** A unary expression other than `not` (e.g., `-x`, `+x`, `~x`). */ - class ArithmeticUnaryNode extends ExprNode { + additional class ArithUnaryExpr extends Expr { private Py::UnaryExpr unaryExpr; - ArithmeticUnaryNode() { unaryExpr = this.asExpr() and not unaryExpr.getOp() instanceof Py::Not } + ArithUnaryExpr() { unaryExpr = this.asExpr() and not unaryExpr.getOp() instanceof Py::Not } - ExprNode getOperand() { result.asExpr() = unaryExpr.getOperand() } + Expr getOperand() { result = TExpr(unaryExpr.getOperand()) } } /** - * A comprehension or generator expression. - * The iterable is evaluated in the enclosing scope; the body runs in a - * nested synthetic function scope handled by its own CFG. + * A comprehension or generator expression. The iterable is evaluated in + * the enclosing scope; the body runs in a nested synthetic function + * scope handled by its own CFG. */ - class ComprehensionNode extends ExprNode { + additional class Comprehension extends Expr { private Py::Expr iterable; - ComprehensionNode() { + Comprehension() { iterable = this.asExpr().(Py::ListComp).getIterable() or iterable = this.asExpr().(Py::SetComp).getIterable() @@ -481,289 +737,193 @@ private module Ast { iterable = this.asExpr().(Py::GeneratorExp).getIterable() } - ExprNode getIterable() { result.asExpr() = iterable } + Expr getIterable() { result = TExpr(iterable) } } /** A comparison expression (`a < b`, `a < b < c`, etc.). */ - class CompareNode extends ExprNode { + additional class CompareExpr extends Expr { private Py::Compare cmp; - CompareNode() { cmp = this.asExpr() } + CompareExpr() { cmp = this.asExpr() } - ExprNode getLeft() { result.asExpr() = cmp.getLeft() } + Expr getLeft() { result = TExpr(cmp.getLeft()) } - ExprNode getComparator(int n) { result.asExpr() = cmp.getComparator(n) } + Expr getComparator(int n) { result = TExpr(cmp.getComparator(n)) } } /** A slice expression (`start:stop:step`). */ - class SliceNode extends ExprNode { + additional class SliceExpr extends Expr { private Py::Slice slice; - SliceNode() { slice = this.asExpr() } - - ExprNode getStart() { result.asExpr() = slice.getStart() } - - ExprNode getStop() { result.asExpr() = slice.getStop() } - - ExprNode getStep() { result.asExpr() = slice.getStep() } - } - - /** A starred expression (`*x`). */ - class StarredNode extends ExprNode { - private Py::Starred starred; - - StarredNode() { starred = this.asExpr() } + SliceExpr() { slice = this.asExpr() } - ExprNode getValue() { result.asExpr() = starred.getValue() } - } - - /** A formatted string literal (`f"...{expr}..."`). */ - class FstringNode extends ExprNode { - private Py::Fstring fstring; - - FstringNode() { fstring = this.asExpr() } - - ExprNode getValue(int n) { result.asExpr() = fstring.getValue(n) } - } - - /** A formatted value inside an f-string (`{expr}` or `{expr:spec}`). */ - class FormattedValueNode extends ExprNode { - private Py::FormattedValue fv; - - FormattedValueNode() { fv = this.asExpr() } - - ExprNode getValue() { result.asExpr() = fv.getValue() } - - ExprNode getFormatSpec() { result.asExpr() = fv.getFormatSpec() } - } - - /** A `yield` expression. */ - class YieldNode extends ExprNode { - private Py::Yield yield; - - YieldNode() { yield = this.asExpr() } - - ExprNode getValue() { result.asExpr() = yield.getValue() } - } - - /** A `yield from` expression. */ - class YieldFromNode extends ExprNode { - private Py::YieldFrom yieldFrom; - - YieldFromNode() { yieldFrom = this.asExpr() } - - ExprNode getValue() { result.asExpr() = yieldFrom.getValue() } - } - - /** An `await` expression. */ - class AwaitNode extends ExprNode { - private Py::Await await; + Expr getStart() { result = TExpr(slice.getStart()) } - AwaitNode() { await = this.asExpr() } + Expr getStop() { result = TExpr(slice.getStop()) } - ExprNode getValue() { result.asExpr() = await.getValue() } + Expr getStep() { result = TExpr(slice.getStep()) } } - /** A class definition expression (has base classes evaluated at definition time). */ - class ClassExprNode extends ExprNode { - private Py::ClassExpr classExpr; - - ClassExprNode() { classExpr = this.asExpr() } - - ExprNode getBase(int n) { result.asExpr() = classExpr.getBase(n) } - } - - /** A function definition expression (has default args evaluated at definition time). */ - class FunctionExprNode extends ExprNode { - private Py::FunctionExpr funcExpr; - - FunctionExprNode() { funcExpr = this.asExpr() } + /** A starred expression (`*x`). */ + additional class StarredExpr extends Expr { + private Py::Starred starred; - ExprNode getDefault(int n) { result.asExpr() = funcExpr.getArgs().getDefault(n) } + StarredExpr() { starred = this.asExpr() } - ExprNode getKwDefault(int n) { result.asExpr() = funcExpr.getArgs().getKwDefault(n) } + Expr getValue() { result = TExpr(starred.getValue()) } } - /** A lambda expression (has default args evaluated at definition time). */ - class LambdaNode extends ExprNode { - private Py::Lambda lambda; - - LambdaNode() { lambda = this.asExpr() } + /** A formatted string literal (`f"...{expr}..."`). */ + additional class FstringExpr extends Expr { + private Py::Fstring fstring; - ExprNode getDefault(int n) { result.asExpr() = lambda.getArgs().getDefault(n) } + FstringExpr() { fstring = this.asExpr() } - ExprNode getKwDefault(int n) { result.asExpr() = lambda.getArgs().getKwDefault(n) } + Expr getValue(int n) { result = TExpr(fstring.getValue(n)) } } - /** - * A `not` expression. This is a `UnaryExpr` whose operator is `Not`. - */ - class NotExprNode extends ExprNode { - private Py::UnaryExpr notExpr; + /** A formatted value inside an f-string (`{expr}` or `{expr:spec}`). */ + additional class FormattedValueExpr extends Expr { + private Py::FormattedValue fv; + + FormattedValueExpr() { fv = this.asExpr() } - NotExprNode() { notExpr = this.asExpr() and notExpr.getOp() instanceof Py::Not } + Expr getValue() { result = TExpr(fv.getValue()) } - ExprNode getOperand() { result.asExpr() = notExpr.getOperand() } + Expr getFormatSpec() { result = TExpr(fv.getFormatSpec()) } } - /** - * A boolean expression (`and`/`or`) with exactly 2 operands. - * For 2-operand BoolExprs, the `TExprNode` itself serves as the - * logical and/or expression. - */ - class BoolExpr2Node extends ExprNode { - private Py::BoolExpr boolExpr; + /** A `yield` expression. */ + additional class YieldExpr extends Expr { + private Py::Yield yield; - BoolExpr2Node() { boolExpr = this.asExpr() and count(boolExpr.getAValue()) = 2 } + YieldExpr() { yield = this.asExpr() } - predicate isAnd() { boolExpr.getOp() instanceof Py::And } + Expr getValue() { result = TExpr(yield.getValue()) } + } - predicate isOr() { boolExpr.getOp() instanceof Py::Or } + /** A `yield from` expression. */ + additional class YieldFromExpr extends Expr { + private Py::YieldFrom yieldFrom; - ExprNode getLeftOperand() { result.asExpr() = boolExpr.getValue(0) } + YieldFromExpr() { yieldFrom = this.asExpr() } - ExprNode getRightOperand() { result.asExpr() = boolExpr.getValue(1) } + Expr getValue() { result = TExpr(yieldFrom.getValue()) } } - /** - * The outermost pair of a multi-operand (3+) boolean expression. - * Represented by the original `BoolExpr` node (`TExprNode`). - * Left operand is `getValue(0)`, right operand is `TBoolExprPair(be, 1)`. - */ - class BoolExprOuterNode extends ExprNode { - private Py::BoolExpr boolExpr; + /** An `await` expression. */ + additional class AwaitExpr extends Expr { + private Py::Await await; - BoolExprOuterNode() { boolExpr = this.asExpr() and count(boolExpr.getAValue()) > 2 } + AwaitExpr() { await = this.asExpr() } - predicate isAnd() { boolExpr.getOp() instanceof Py::And } + Expr getValue() { result = TExpr(await.getValue()) } + } - predicate isOr() { boolExpr.getOp() instanceof Py::Or } + /** A class definition expression (has base classes evaluated at definition time). */ + additional class ClassDefExpr extends Expr { + private Py::ClassExpr classExpr; - Node getLeftOperand() { result = TExprNode(boolExpr.getValue(0)) } + ClassDefExpr() { classExpr = this.asExpr() } - Node getRightOperand() { result = TBoolExprPair(boolExpr, 1) } + Expr getBase(int n) { result = TExpr(classExpr.getBase(n)) } } - /** - * A synthetic intermediate node in a multi-operand boolean expression. - * Pair at index `i` has left=`getValue(i)` and right=pair at `i+1` - * (or `getValue(n-1)` for the last pair). - */ - class BoolExprPairNode extends Node, TBoolExprPair { - private Py::BoolExpr boolExpr; - private int index; - - BoolExprPairNode() { this = TBoolExprPair(boolExpr, index) } + /** A function definition expression (has default args evaluated at definition time). */ + additional class FunctionDefExpr extends Expr { + private Py::FunctionExpr funcExpr; - override string toString() { result = boolExpr.getOperator() } + FunctionDefExpr() { funcExpr = this.asExpr() } - override Py::Location getLocation() { result = boolExpr.getValue(index).getLocation() } + Expr getDefault(int n) { result = TExpr(funcExpr.getArgs().getDefault(n)) } - override ScopeNode getEnclosingScope() { - result.asScope() = boolExpr.getValue(index).getScope() - } + Expr getKwDefault(int n) { result = TExpr(funcExpr.getArgs().getKwDefault(n)) } - predicate isAnd() { boolExpr.getOp() instanceof Py::And } + int getNumberOfDefaults() { result = count(funcExpr.getArgs().getADefault()) } + } - predicate isOr() { boolExpr.getOp() instanceof Py::Or } + /** A lambda expression (has default args evaluated at definition time). */ + additional class LambdaExpr extends Expr { + private Py::Lambda lambda; - Node getLeftOperand() { result = TExprNode(boolExpr.getValue(index)) } + LambdaExpr() { lambda = this.asExpr() } - Node getRightOperand() { - // Last pair: right operand is the final value - index = count(boolExpr.getAValue()) - 2 and - result = TExprNode(boolExpr.getValue(index + 1)) - or - // Not last pair: right operand is the next synthetic pair - index < count(boolExpr.getAValue()) - 2 and - result = TBoolExprPair(boolExpr, index + 1) - } - } + Expr getDefault(int n) { result = TExpr(lambda.getArgs().getDefault(n)) } - /** A `True` or `False` literal. */ - class BoolLiteralNode extends ExprNode { - BoolLiteralNode() { this.asExpr() instanceof Py::True or this.asExpr() instanceof Py::False } + Expr getKwDefault(int n) { result = TExpr(lambda.getArgs().getKwDefault(n)) } - boolean getBoolValue() { - this.asExpr() instanceof Py::True and result = true - or - this.asExpr() instanceof Py::False and result = false - } + int getNumberOfDefaults() { result = count(lambda.getArgs().getADefault()) } } -} - -/** Provides an implementation of the AST signature for Python. */ -module AstSigImpl implements AstSig { - class AstNode = Ast::Node; /** Gets the child of `n` at the specified (zero-based) index. */ AstNode getChild(AstNode n, int index) { - // IfStmt: condition (0), then branch (1), else branch (2) - exists(Ast::IfNode ifNode | ifNode = n | - index = 0 and result = ifNode.getTest() + // BlockStmt: indexed statements + result = n.(BlockStmt).getStmt(index) + or + // IfStmt: condition (0), then (1), else (2) + exists(IfStmt ifStmt | ifStmt = n | + index = 0 and result = ifStmt.getCondition() or - index = 1 and result = ifNode.getBody() + index = 1 and result = ifStmt.getThen() or - index = 2 and result = ifNode.getOrelse() + index = 2 and result = ifStmt.getElse() ) or - // BlockStmt (StmtList): indexed statements - result = n.(Ast::StmtListNode).getItem(index) - or // ExprStmt: the expression (0) - index = 0 and result = n.(Ast::ExprStmtNode).getValue() + index = 0 and result = n.(ExprStmt).getExpr() or // Assign: value (0), targets (1..n) - exists(Ast::AssignNode a | a = n | + exists(AssignStmt a | a = n | index = 0 and result = a.getValue() or result = a.getTarget(index - 1) and index >= 1 ) or // AugAssign: the operation (0) - index = 0 and result = n.(Ast::AugAssignNode).getOperation() + index = 0 and result = n.(AugAssignStmt).getOperation() or - // AssignExpr (walrus :=): value (0), target (1) - exists(Ast::AssignExprNode ae | ae = n | - index = 0 and result = ae.getValue() + // Walrus (`x := expr`): value (0), target (1) + exists(NamedExpr ne | ne = n | + index = 0 and result = ne.getValue() or - index = 1 and result = ae.getTarget() + index = 1 and result = ne.getTarget() ) or - // WhileStmt: condition (0), body (1) - // Note: Python while/else is not directly supported by the shared library. - exists(Ast::WhileNode w | w = n | - index = 0 and result = w.getTest() + // WhileStmt: condition (0), body (1), orelse (2) + exists(WhileStmt w | w = n | + index = 0 and result = w.getCondition() or index = 1 and result = w.getBody() or - index = 2 and result = w.getOrelse() + index = 2 and result = w.getElse() ) or - // ForStmt (mapped as ForeachStmt): collection (0), variable (1), body (2) - exists(Ast::ForNode f | f = n | - index = 0 and result = f.getIter() + // ForeachStmt: collection (0), variable (1), body (2), orelse (3) + exists(ForeachStmt f | f = n | + index = 0 and result = f.getCollection() or - index = 1 and result = f.getTarget() + index = 1 and result = f.getVariable() or index = 2 and result = f.getBody() + or + index = 3 and result = f.getElse() ) or // ReturnStmt: the value (0) - index = 0 and result = n.(Ast::ReturnNode).getValue() + index = 0 and result = n.(ReturnStmt).getExpr() or - // Assert: test (0), message (1) - exists(Ast::AssertNode a | a = n | + // AssertStmt: test (0), message (1) + exists(AssertStmt a | a = n | index = 0 and result = a.getTest() or index = 1 and result = a.getMsg() ) or - // Delete: targets left to right - result = n.(Ast::DeleteNode).getTarget(index) + // DeleteStmt: targets left to right + result = n.(DeleteStmt).getTarget(index) or - // With: context expr (0), optional vars (1), body (2) - exists(Ast::WithNode w | w = n | + // WithStmt: context expr (0), optional vars (1), body (2) + exists(WithStmt w | w = n | index = 0 and result = w.getContextExpr() or index = 1 and result = w.getOptionalVars() @@ -771,32 +931,32 @@ module AstSigImpl implements AstSig { index = 2 and result = w.getBody() ) or - // ThrowStmt (raise): the exception (0), the cause (1) - exists(Ast::RaiseNode r | r = n | - index = 0 and result = r.getException() + // Throw (raise): exception (0), cause (1) + exists(Throw r | r = n | + index = 0 and result = r.getExpr() or index = 1 and result = r.getCause() ) or // TryStmt: body (0), handlers (1..n), finally (-1) - exists(Ast::TryNode t | t = n | + exists(TryStmt t | t = n | index = 0 and result = t.getBody() or - result = t.getHandler(index - 1) and index >= 1 + result = t.getCatch(index - 1) and index >= 1 or - index = -1 and result = t.getFinalbody() + index = -1 and result = t.getFinally() ) or - // MatchStmt: subject (0), cases (1..n) - exists(Ast::MatchStmtNode m | m = n | - index = 0 and result = m.getSubject() + // Switch (match): subject (0), cases (1..n) + exists(Switch m | m = n | + index = 0 and result = m.getExpr() or result = m.getCase(index - 1) and index >= 1 ) or - // Case: guard (0), body (1) - exists(Ast::CaseNode c | c = n | - index = 0 and result = c.getPattern() + // Case: pattern (0), guard (1), body (2) + exists(Case c | c = n | + index = 0 and result = c.getAPattern() or index = 1 and result = c.getGuard() or @@ -804,25 +964,25 @@ module AstSigImpl implements AstSig { ) or // CatchClause (except handler): type (0), name (1), body (2) - exists(Ast::ExceptionHandlerNode h | h = n | + exists(CatchClause h | h = n | index = 0 and result = h.getType() or - index = 1 and result = h.getName() + index = 1 and result = h.getVariable() or index = 2 and result = h.getBody() ) or // ConditionalExpr (IfExp): condition (0), then (1), else (2) - exists(Ast::IfExpNode ie | ie = n | - index = 0 and result = ie.getTest() + exists(ConditionalExpr ie | ie = n | + index = 0 and result = ie.getCondition() or - index = 1 and result = ie.getBody() + index = 1 and result = ie.getThen() or - index = 2 and result = ie.getOrelse() + index = 2 and result = ie.getElse() ) or // Call: func (0), positional args (1..n), keyword values (n+1..n+k) - exists(Ast::CallNode call | call = n | + exists(CallExpr call | call = n | index = 0 and result = call.getFunc() or result = call.getPositionalArg(index - 1) and index >= 1 @@ -832,51 +992,51 @@ module AstSigImpl implements AstSig { ) or // Python BinaryExpr (arithmetic, bitwise, matmul, etc.): left (0), right (1) - exists(Ast::BinaryExprNode be | be = n | + exists(ArithBinaryExpr be | be = n | index = 0 and result = be.getLeft() or index = 1 and result = be.getRight() ) or // Subscript (obj[index]): object (0), index (1) - exists(Ast::SubscriptNode sub | sub = n | + exists(SubscriptExpr sub | sub = n | index = 0 and result = sub.getObject() or index = 1 and result = sub.getIndex() ) or // Attribute (obj.name): object (0) - index = 0 and result = n.(Ast::AttributeNode).getObject() + index = 0 and result = n.(AttributeExpr).getObject() or // Comprehension/generator: iterable (0) - index = 0 and result = n.(Ast::ComprehensionNode).getIterable() + index = 0 and result = n.(Comprehension).getIterable() or // Tuple, List, Set: elements left to right - result = n.(Ast::TupleNode).getElt(index) + result = n.(TupleExpr).getElt(index) or - result = n.(Ast::ListNode).getElt(index) + result = n.(ListExpr).getElt(index) or - result = n.(Ast::SetNode).getElt(index) + result = n.(SetExpr).getElt(index) or // Dict: key(0), value(0), key(1), value(1), ... - exists(Ast::DictNode d, int item | d = n | + exists(DictExpr d, int item | d = n | index = 2 * item and result = d.getKey(item) or index = 2 * item + 1 and result = d.getValue(item) ) or // Arithmetic unary (-x, +x, ~x): operand (0) - index = 0 and result = n.(Ast::ArithmeticUnaryNode).getOperand() + index = 0 and result = n.(ArithUnaryExpr).getOperand() or // Compare (a < b < c): left (0), comparators (1..n) - exists(Ast::CompareNode cmp | cmp = n | + exists(CompareExpr cmp | cmp = n | index = 0 and result = cmp.getLeft() or result = cmp.getComparator(index - 1) and index >= 1 ) or // Slice (start:stop:step): start (0), stop (1), step (2) - exists(Ast::SliceNode sl | sl = n | + exists(SliceExpr sl | sl = n | index = 0 and result = sl.getStart() or index = 1 and result = sl.getStop() @@ -885,367 +1045,57 @@ module AstSigImpl implements AstSig { ) or // Starred (*x): value (0) - index = 0 and result = n.(Ast::StarredNode).getValue() + index = 0 and result = n.(StarredExpr).getValue() or // Fstring: values left to right - result = n.(Ast::FstringNode).getValue(index) + result = n.(FstringExpr).getValue(index) or // FormattedValue ({expr} or {expr:spec}): value (0), format spec (1) - exists(Ast::FormattedValueNode fv | fv = n | + exists(FormattedValueExpr fv | fv = n | index = 0 and result = fv.getValue() or index = 1 and result = fv.getFormatSpec() ) or // Yield: value (0) - index = 0 and result = n.(Ast::YieldNode).getValue() + index = 0 and result = n.(YieldExpr).getValue() or // YieldFrom: value (0) - index = 0 and result = n.(Ast::YieldFromNode).getValue() + index = 0 and result = n.(YieldFromExpr).getValue() or // Await: value (0) - index = 0 and result = n.(Ast::AwaitNode).getValue() + index = 0 and result = n.(AwaitExpr).getValue() or // ClassExpr: base classes left to right - result = n.(Ast::ClassExprNode).getBase(index) + result = n.(ClassDefExpr).getBase(index) or // FunctionExpr: defaults left to right, then kw defaults - exists(Ast::FunctionExprNode fe | fe = n | + exists(FunctionDefExpr fe | fe = n | result = fe.getDefault(index) or - result = - fe.getKwDefault(index - - count(Py::Expr d | d = fe.asExpr().(Py::FunctionExpr).getArgs().getADefault())) + result = fe.getKwDefault(index - fe.getNumberOfDefaults()) ) or // Lambda: defaults left to right, then kw defaults - exists(Ast::LambdaNode lam | lam = n | + exists(LambdaExpr lam | lam = n | result = lam.getDefault(index) or - result = - lam.getKwDefault(index - - count(Py::Expr d | d = lam.asExpr().(Py::Lambda).getArgs().getADefault())) + result = lam.getKwDefault(index - lam.getNumberOfDefaults()) ) or // LogicalNotExpr: operand (0) - index = 0 and result = n.(Ast::NotExprNode).getOperand() - or - // 2-operand BoolExpr: left (0), right (1) - exists(Ast::BoolExpr2Node be | be = n | - index = 0 and result = be.getLeftOperand() - or - index = 1 and result = be.getRightOperand() - ) + index = 0 and result = n.(LogicalNotExpr).getOperand() or - // Multi-operand BoolExpr (outermost): left (0), right (1) - exists(Ast::BoolExprOuterNode be | be = n | + // BinaryExpr (`and`/`or`): left (0), right (1) + exists(BinaryExpr be | be = n | index = 0 and result = be.getLeftOperand() or index = 1 and result = be.getRightOperand() ) - or - // Synthetic BoolExpr pair: left (0), right (1) - exists(Ast::BoolExprPairNode bp | bp = n | - index = 0 and result = bp.getLeftOperand() - or - index = 1 and result = bp.getRightOperand() - ) - } - - Callable getEnclosingCallable(AstNode node) { result = node.getEnclosingScope() } - - /** - * A callable: a function, class, or module scope. - * - * In Python, all three are executable scopes with statement bodies. - */ - class Callable extends Ast::ScopeNode { } - - /** Gets the body of callable `c`. */ - AstNode callableGetBody(Callable c) { result = c.getBody() } - - /** A statement. Includes both wrapped `Stmt` nodes and `StmtList` blocks. */ - class Stmt extends AstNode { - Stmt() { this instanceof Ast::StmtNode or this instanceof Ast::StmtListNode } - } - - /** An expression. Includes `TExprNode` and synthetic `TBoolExprPair` nodes. */ - class Expr extends AstNode { - Expr() { this instanceof Ast::ExprNode or this instanceof Ast::BoolExprPairNode } - } - - /** A block of statements, wrapping Python's `StmtList`. */ - class BlockStmt extends Stmt, Ast::StmtListNode { - /** Gets the `n`th (zero-based) statement in this block. */ - Stmt getStmt(int n) { result = Ast::StmtListNode.super.getItem(n) } - - /** Gets the last statement in this block. */ - Stmt getLastStmt() { result = Ast::StmtListNode.super.getLastItem() } - } - - /** An expression statement. */ - class ExprStmt extends Stmt, Ast::ExprStmtNode { - /** Gets the expression in this expression statement. */ - Expr getExpr() { result = this.getValue() } - } - - /** - * An `if` statement. - * - * Python's `elif` chains are represented as nested `If` nodes in the - * else branch's `StmtList`. The shared CFG library handles this naturally: - * `getElse()` returns the `BlockStmt` wrapping the else branch, and if that - * block contains a single `If`, the result is a chained conditional. - */ - class IfStmt extends Stmt, Ast::IfNode { - /** Gets the condition of this `if` statement. */ - Expr getCondition() { result = this.getTest() } - - /** Gets the `then` (true) branch of this `if` statement. */ - Stmt getThen() { result = Ast::IfNode.super.getBody() } - - /** Gets the `else` (false) branch of this `if` statement, if any. */ - Stmt getElse() { result = this.getOrelse() } - } - - // ===== Loop statements ===== - /** A loop statement. */ - class LoopStmt extends Stmt { - LoopStmt() { this instanceof Ast::WhileNode or this instanceof Ast::ForNode } - - /** Gets the body of this loop statement. */ - Stmt getBody() { none() } - } - - /** A `while` loop statement. */ - class WhileStmt extends LoopStmt instanceof Ast::WhileNode { - /** Gets the boolean condition of this `while` loop. */ - Expr getCondition() { result = this.(Ast::WhileNode).getTest() } - - override Stmt getBody() { result = this.(Ast::WhileNode).getBody() } - } - - /** A `do-while` loop statement. Python has no do-while construct. */ - class DoStmt extends LoopStmt { - DoStmt() { none() } - - Expr getCondition() { none() } - } - - /** A C-style `for` loop. Python has no C-style for loop. */ - class ForStmt extends LoopStmt { - ForStmt() { none() } - - Expr getInit(int index) { none() } - - Expr getCondition() { none() } - - Expr getUpdate(int index) { none() } - } - - /** A for-each loop (`for x in iterable:`). */ - class ForeachStmt extends LoopStmt { - ForeachStmt() { this instanceof Ast::ForNode } - - /** Gets the loop variable. */ - Expr getVariable() { result = this.(Ast::ForNode).getTarget() } - - /** Gets the collection being iterated. */ - Expr getCollection() { result = this.(Ast::ForNode).getIter() } - - override Stmt getBody() { result = this.(Ast::ForNode).getBody() } - } - - // ===== Abrupt completion statements ===== - /** A `break` statement. */ - class BreakStmt extends Stmt, Ast::BreakNode { } - - /** A `continue` statement. */ - class ContinueStmt extends Stmt, Ast::ContinueNode { } - - /** A `return` statement. */ - class ReturnStmt extends Stmt, Ast::ReturnNode { - /** Gets the expression being returned, if any. */ - Expr getExpr() { result = this.getValue() } - } - - /** A `raise` statement (mapped to `Throw`). */ - class Throw extends Stmt, Ast::RaiseNode { - /** Gets the expression being raised. */ - Expr getExpr() { result = this.getException() } - } - - /** A `goto` statement. Python has no goto. */ - class GotoStmt extends Stmt { - GotoStmt() { none() } - } - - // ===== Try/except ===== - /** A `try` statement. */ - class TryStmt extends Stmt { - TryStmt() { this instanceof Ast::TryNode } - - Stmt getBody() { result = this.(Ast::TryNode).getBody() } - - CatchClause getCatch(int index) { result = this.(Ast::TryNode).getHandler(index) } - - Stmt getFinally() { result = this.(Ast::TryNode).getFinalbody() } - } - - AstNode getTryElse(TryStmt try) { result = try.(Ast::TryNode).getOrelse() } - - /** An except clause in a try statement. */ - class CatchClause extends Stmt { - CatchClause() { this instanceof Ast::ExceptionHandlerNode } - - AstNode getVariable() { result = this.(Ast::ExceptionHandlerNode).getName() } - - Expr getCondition() { none() } - - Stmt getBody() { result = this.(Ast::ExceptionHandlerNode).getBody() } - } - - // ===== Switch/match ===== - /** A `match` statement, mapped to the shared CFG's `Switch`. */ - class Switch extends Stmt { - Switch() { this instanceof Ast::MatchStmtNode } - - Expr getExpr() { result = this.(Ast::MatchStmtNode).getSubject() } - - Case getCase(int index) { result = this.(Ast::MatchStmtNode).getCase(index) } - - Stmt getStmt(int index) { none() } - } - - /** A `case` clause in a match statement. */ - class Case extends Stmt { - Case() { this instanceof Ast::CaseNode } - - AstNode getAPattern() { result = this.(Ast::CaseNode).getPattern() } - - Expr getGuard() { result = this.(Ast::CaseNode).getGuard() } - - AstNode getBody() { result = this.(Ast::CaseNode).getBody() } - } - - /** A wildcard case (`case _:`). */ - class DefaultCase extends Case { - DefaultCase() { this.(Ast::CaseNode).isWildcard() } - } - - // ===== Expression types ===== - /** A conditional expression (`x if cond else y`). */ - class ConditionalExpr extends Expr, Ast::IfExpNode { - /** Gets the condition of this expression. */ - Expr getCondition() { result = this.getTest() } - - /** Gets the true branch of this expression. */ - Expr getThen() { result = Ast::IfExpNode.super.getBody() } - - /** Gets the false branch of this expression. */ - Expr getElse() { result = this.getOrelse() } - } - - /** - * A binary expression for the shared CFG. In Python, this covers - * `and`/`or` expressions (both real 2-operand and synthetic pairs). - */ - class BinaryExpr extends Expr { - BinaryExpr() { - this instanceof Ast::BoolExpr2Node or - this instanceof Ast::BoolExprOuterNode or - this instanceof Ast::BoolExprPairNode - } - - /** Gets the left operand. */ - Expr getLeftOperand() { - result = this.(Ast::BoolExpr2Node).getLeftOperand() - or - result = this.(Ast::BoolExprOuterNode).getLeftOperand() - or - result = this.(Ast::BoolExprPairNode).getLeftOperand() - } - - /** Gets the right operand. */ - Expr getRightOperand() { - result = this.(Ast::BoolExpr2Node).getRightOperand() - or - result = this.(Ast::BoolExprOuterNode).getRightOperand() - or - result = this.(Ast::BoolExprPairNode).getRightOperand() - } - } - - /** A short-circuiting logical `and` expression. */ - class LogicalAndExpr extends BinaryExpr { - LogicalAndExpr() { - this.(Ast::BoolExpr2Node).isAnd() or - this.(Ast::BoolExprOuterNode).isAnd() or - this.(Ast::BoolExprPairNode).isAnd() - } - } - - /** A short-circuiting logical `or` expression. */ - class LogicalOrExpr extends BinaryExpr { - LogicalOrExpr() { - this.(Ast::BoolExpr2Node).isOr() or - this.(Ast::BoolExprOuterNode).isOr() or - this.(Ast::BoolExprPairNode).isOr() - } - } - - /** A null-coalescing expression. Python has no null-coalescing operator. */ - class NullCoalescingExpr extends BinaryExpr { - NullCoalescingExpr() { none() } - } - - /** An assignment expression. Python has no assignment expressions in the BinaryExpr sense. */ - class Assignment extends BinaryExpr { - Assignment() { none() } - } - - /** A simple assignment expression. */ - class AssignExpr extends Assignment { } - - /** A compound assignment expression. */ - class CompoundAssignment extends Assignment { } - - /** A short-circuiting logical AND compound assignment. Python has no `&&=` operator. */ - class AssignLogicalAndExpr extends CompoundAssignment { } - - /** A short-circuiting logical OR compound assignment. Python has no `||=` operator. */ - class AssignLogicalOrExpr extends CompoundAssignment { } - - /** A short-circuiting null-coalescing compound assignment. Python has no `??=` operator. */ - class AssignNullCoalescingExpr extends CompoundAssignment { } - - /** A unary expression. Exists for the `not` subclass. */ - class UnaryExpr extends Expr { - UnaryExpr() { this instanceof Ast::NotExprNode } - - Expr getOperand() { result = this.(Ast::NotExprNode).getOperand() } - } - - /** A logical `not` expression. */ - class LogicalNotExpr extends UnaryExpr { } - - /** A boolean literal expression (`True` or `False`). */ - class BooleanLiteral extends Expr, Ast::BoolLiteralNode { - /** Gets the boolean value of this literal. */ - boolean getValue() { result = this.getBoolValue() } - } - - /** A pattern match expression. Python has no `instanceof`-style pattern match expr. */ - class PatternMatchExpr extends Expr { - PatternMatchExpr() { none() } - - Expr getExpr() { none() } - - AstNode getPattern() { none() } } } -private module Cfg0 = Make0; +private module Cfg0 = Make0; private import Cfg0 @@ -1268,32 +1118,32 @@ private module Input implements InputSig1, InputSig2 { class CallableBodyPartContext = Void; - predicate inConditionalContext(AstSigImpl::AstNode n, ConditionKind kind) { + predicate inConditionalContext(Ast::AstNode n, ConditionKind kind) { kind.isBoolean() and - n = any(Ast::AssertNode a).getTest() + n = any(Ast::AssertStmt a).getTest() } private string assertThrowTag() { result = "[assert-throw]" } - predicate additionalNode(AstSigImpl::AstNode n, string tag, NormalSuccessor t) { - n instanceof Ast::AssertNode and tag = assertThrowTag() and t instanceof DirectSuccessor + predicate additionalNode(Ast::AstNode n, string tag, NormalSuccessor t) { + n instanceof Ast::AssertStmt and tag = assertThrowTag() and t instanceof DirectSuccessor } predicate beginAbruptCompletion( - AstSigImpl::AstNode ast, PreControlFlowNode n, AbruptCompletion c, boolean always + Ast::AstNode ast, PreControlFlowNode n, AbruptCompletion c, boolean always ) { - ast instanceof Ast::AssertNode and + ast instanceof Ast::AssertStmt and n.isAdditional(ast, assertThrowTag()) and c.asSimpleAbruptCompletion() instanceof ExceptionSuccessor and always = true } - predicate endAbruptCompletion(AstSigImpl::AstNode ast, PreControlFlowNode n, AbruptCompletion c) { + predicate endAbruptCompletion(Ast::AstNode ast, PreControlFlowNode n, AbruptCompletion c) { none() } predicate step(PreControlFlowNode n1, PreControlFlowNode n2) { - exists(Ast::AssertNode assertStmt | + exists(Ast::AssertStmt assertStmt | n1.isBefore(assertStmt) and n2.isBefore(assertStmt.getTest()) or @@ -1314,18 +1164,18 @@ private module Input implements InputSig1, InputSig2 { or // While/else: when the condition is false, flow to the else block // (if present) before the after-while node. - exists(Ast::WhileNode w, Ast::StmtListNode orelse | orelse = w.getOrelse() | - n1.isAfterFalse(w.getTest()) and + exists(Ast::WhileStmt w, Ast::BlockStmt orelse | orelse = w.getElse() | + n1.isAfterFalse(w.getCondition()) and n2.isBefore(orelse) or n1.isAfter(orelse) and n2.isAfter(w) ) or - // For/else: when the collection is empty or the loop completes normally, - // flow through the else block before the after-for node. - exists(Ast::ForNode f, Ast::StmtListNode orelse | orelse = f.getOrelse() | - n1.isAfterValue(f.getIter(), any(EmptinessSuccessor t | t.getValue() = true)) and + // For/else: when the collection is empty or the loop completes + // normally, flow through the else block before the after-for node. + exists(Ast::ForeachStmt f, Ast::BlockStmt orelse | orelse = f.getElse() | + n1.isAfterValue(f.getCollection(), any(EmptinessSuccessor t | t.getValue() = true)) and n2.isBefore(orelse) or n1.isAfter(f.getBody()) and @@ -1341,13 +1191,13 @@ import CfgCachedStage import Public /** - * Maps a new-CFG AST wrapper node to the corresponding Python AST node, if any. + * Maps a CFG AST wrapper node to the corresponding Python AST node, if any. * Entry, exit, and synthetic nodes have no corresponding Python AST node. */ -Py::AstNode astNodeToPyNode(AstSigImpl::AstNode n) { - result = n.(Ast::ExprNode).asExpr() +Py::AstNode astNodeToPyNode(Ast::AstNode n) { + result = n.asExpr() or - result = n.(Ast::StmtNode).asStmt() + result = n.asStmt() or - result = n.(Ast::ScopeNode).asScope() + result = n.asScope() } From edfe91832bb5ab6dc37c7cdf1fdb5ce2b89ecd55 Mon Sep 17 00:00:00 2001 From: Copilot Date: Tue, 5 May 2026 14:25:43 +0000 Subject: [PATCH 32/44] Python: compact-renumber FunctionExpr/Lambda defaults `Args.getDefault(int)` and `Args.getKwDefault(int)` are indexed by argument position (with gaps for args without defaults), not by default position. The CFG `getChild` predicate for FunctionDefExpr and LambdaExpr therefore had gaps at low indices and collisions where defaults and kwdefaults overlapped, producing parallel edges before the FunctionExpr. Use `rank` to compact-renumber `getDefault(n)` and `getKwDefault(n)` in source order. Verified on a CPython database: removes ~536 `multipleSuccessors` consistency results (1340 -> 804); the rest are `for/else` and `while/else`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../controlflow/internal/AstNodeImpl.qll | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll index 8a86ffa08748..d6c63c6027cc 100644 --- a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll +++ b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll @@ -835,9 +835,22 @@ module Ast implements AstSig { FunctionDefExpr() { funcExpr = this.asExpr() } - Expr getDefault(int n) { result = TExpr(funcExpr.getArgs().getDefault(n)) } + /** + * Gets the `n`th default for a positional argument, in evaluation + * order. Note that `Args.getDefault(int)` is indexed by argument + * position (with gaps for arguments without defaults), so we must + * renumber here to obtain contiguous indices. + */ + Expr getDefault(int n) { + result = + TExpr(rank[n + 1](Py::Expr d, int i | d = funcExpr.getArgs().getDefault(i) | d order by i)) + } - Expr getKwDefault(int n) { result = TExpr(funcExpr.getArgs().getKwDefault(n)) } + /** Gets the `n`th default for a keyword-only argument, in evaluation order. */ + Expr getKwDefault(int n) { + result = + TExpr(rank[n + 1](Py::Expr d, int i | d = funcExpr.getArgs().getKwDefault(i) | d order by i)) + } int getNumberOfDefaults() { result = count(funcExpr.getArgs().getADefault()) } } @@ -848,9 +861,17 @@ module Ast implements AstSig { LambdaExpr() { lambda = this.asExpr() } - Expr getDefault(int n) { result = TExpr(lambda.getArgs().getDefault(n)) } + /** Gets the `n`th default for a positional argument, in evaluation order. */ + Expr getDefault(int n) { + result = + TExpr(rank[n + 1](Py::Expr d, int i | d = lambda.getArgs().getDefault(i) | d order by i)) + } - Expr getKwDefault(int n) { result = TExpr(lambda.getArgs().getKwDefault(n)) } + /** Gets the `n`th default for a keyword-only argument, in evaluation order. */ + Expr getKwDefault(int n) { + result = + TExpr(rank[n + 1](Py::Expr d, int i | d = lambda.getArgs().getKwDefault(i) | d order by i)) + } int getNumberOfDefaults() { result = count(lambda.getArgs().getADefault()) } } From 76724c53914c2133105eaccc21ebfc1502fa36ab Mon Sep 17 00:00:00 2001 From: Copilot Date: Tue, 5 May 2026 14:45:48 +0000 Subject: [PATCH 33/44] Shared CFG: support for-else and while-else loops Add two default predicates to AstSig: default AstNode getWhileElse(WhileStmt loop) { none() } default AstNode getForeachElse(ForeachStmt loop) { none() } When defined, the explicit-step rules for While/Do and Foreach route the loop's normal-completion exits through the else block before reaching the after-loop node: - WhileStmt: after-false condition -> before-else -> after-while (instead of directly after-while). - ForeachStmt: after-collection [empty] and the LoopHeader exit are both routed through before-else -> after-foreach. Python's Ast module overrides the predicates to return the synthetic BlockStmt for the orelse slot, replacing the previous customisations in Input::step. This eliminates parallel direct successors emitted by the previous Python-side step additions (verified: multipleSuccessors on a CPython database goes from 1340 to 0). Java and C# CFG tests are unaffected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../controlflow/internal/AstNodeImpl.qll | 33 +++++---------- .../NewCfgBranchTimestamps.expected | 6 --- .../codeql/controlflow/ControlFlowGraph.qll | 40 +++++++++++++++++-- 3 files changed, 47 insertions(+), 32 deletions(-) diff --git a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll index d6c63c6027cc..eb21cf32b1d6 100644 --- a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll +++ b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll @@ -411,6 +411,16 @@ module Ast implements AstSig { */ AstNode getTryElse(TryStmt try) { result = try.getElse() } + /** + * Gets the `else` branch of `while` loop `loop`, if any. + */ + AstNode getWhileElse(WhileStmt loop) { result = loop.getElse() } + + /** + * Gets the `else` branch of `for` loop `loop`, if any. + */ + AstNode getForeachElse(ForeachStmt loop) { result = loop.getElse() } + /** An exception handler (`except` or `except*`). */ class CatchClause extends Stmt { private Py::ExceptionHandler handler; @@ -1182,29 +1192,6 @@ private module Input implements InputSig1, InputSig2 { n1.isAfter(assertStmt.getMsg()) and n2.isAdditional(assertStmt, assertThrowTag()) ) - or - // While/else: when the condition is false, flow to the else block - // (if present) before the after-while node. - exists(Ast::WhileStmt w, Ast::BlockStmt orelse | orelse = w.getElse() | - n1.isAfterFalse(w.getCondition()) and - n2.isBefore(orelse) - or - n1.isAfter(orelse) and - n2.isAfter(w) - ) - or - // For/else: when the collection is empty or the loop completes - // normally, flow through the else block before the after-for node. - exists(Ast::ForeachStmt f, Ast::BlockStmt orelse | orelse = f.getElse() | - n1.isAfterValue(f.getCollection(), any(EmptinessSuccessor t | t.getValue() = true)) and - n2.isBefore(orelse) - or - n1.isAfter(f.getBody()) and - n2.isBefore(orelse) - or - n1.isAfter(orelse) and - n2.isAfter(f) - ) } } diff --git a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgBranchTimestamps.expected b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgBranchTimestamps.expected index fcc9a17aa746..89a93f41a01b 100644 --- a/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgBranchTimestamps.expected +++ b/python/ql/test/library-tests/ControlFlow/evaluation-order/NewCfgBranchTimestamps.expected @@ -54,8 +54,6 @@ | test_loops.py:53:12:53:38 | After Compare | Timestamp 4 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:51:1:51:29 | Function test_while_else_break | test_while_else_break | | test_loops.py:53:12:53:38 | After Compare | Timestamp 13 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:51:1:51:29 | Function test_while_else_break | test_while_else_break | | test_loops.py:53:12:53:38 | After Compare | Timestamp 13 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:51:1:51:29 | Function test_while_else_break | test_while_else_break | -| test_loops.py:53:12:53:38 | After Compare | Timestamp 16 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:51:1:51:29 | Function test_while_else_break | test_while_else_break | -| test_loops.py:53:12:53:38 | After Compare | Timestamp 16 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:51:1:51:29 | Function test_while_else_break | test_while_else_break | | test_loops.py:54:13:54:40 | After Compare | Timestamp 7 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:51:1:51:29 | Function test_while_else_break | test_while_else_break | | test_loops.py:54:13:54:40 | After Compare | Timestamp 7 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:51:1:51:29 | Function test_while_else_break | test_while_else_break | | test_loops.py:54:13:54:40 | After Compare | Timestamp 16 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:51:1:51:29 | Function test_while_else_break | test_while_else_break | @@ -158,8 +156,6 @@ | test_loops.py:111:14:111:43 | After List | Timestamp 4 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:110:1:110:27 | Function test_for_else_break | test_for_else_break | | test_loops.py:111:14:111:43 | After List | Timestamp 8 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:110:1:110:27 | Function test_for_else_break | test_for_else_break | | test_loops.py:111:14:111:43 | After List | Timestamp 8 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:110:1:110:27 | Function test_for_else_break | test_for_else_break | -| test_loops.py:111:14:111:43 | After List | Timestamp 11 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:110:1:110:27 | Function test_for_else_break | test_for_else_break | -| test_loops.py:111:14:111:43 | After List | Timestamp 11 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:110:1:110:27 | Function test_for_else_break | test_for_else_break | | test_loops.py:112:13:112:38 | After Compare | Timestamp 7 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:110:1:110:27 | Function test_for_else_break | test_for_else_break | | test_loops.py:112:13:112:38 | After Compare | Timestamp 7 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:110:1:110:27 | Function test_for_else_break | test_for_else_break | | test_loops.py:112:13:112:38 | After Compare | Timestamp 11 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:110:1:110:27 | Function test_for_else_break | test_for_else_break | @@ -168,8 +164,6 @@ | test_loops.py:114:9:114:9 | x | Timestamp 4 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:110:1:110:27 | Function test_for_else_break | test_for_else_break | | test_loops.py:114:9:114:9 | x | Timestamp 8 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:110:1:110:27 | Function test_for_else_break | test_for_else_break | | test_loops.py:114:9:114:9 | x | Timestamp 8 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:110:1:110:27 | Function test_for_else_break | test_for_else_break | -| test_loops.py:114:9:114:9 | x | Timestamp 11 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:110:1:110:27 | Function test_for_else_break | test_for_else_break | -| test_loops.py:114:9:114:9 | x | Timestamp 11 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:110:1:110:27 | Function test_for_else_break | test_for_else_break | | test_loops.py:123:14:123:33 | After List | Timestamp 21 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:122:1:122:25 | Function test_nested_loops | test_nested_loops | | test_loops.py:123:14:123:33 | After List | Timestamp 21 on true/false branch is missing a dead() annotation on the true successor in $@ | test_loops.py:122:1:122:25 | Function test_nested_loops | test_nested_loops | | test_loops.py:124:18:124:47 | After List | Timestamp 3 on true/false branch is missing a dead() annotation on the false successor in $@ | test_loops.py:122:1:122:25 | Function test_nested_loops | test_nested_loops | diff --git a/shared/controlflow/codeql/controlflow/ControlFlowGraph.qll b/shared/controlflow/codeql/controlflow/ControlFlowGraph.qll index 514a68cba474..617d21b23a58 100644 --- a/shared/controlflow/codeql/controlflow/ControlFlowGraph.qll +++ b/shared/controlflow/codeql/controlflow/ControlFlowGraph.qll @@ -211,6 +211,20 @@ signature module AstSig { */ default AstNode getTryElse(TryStmt try) { none() } + /** + * Gets the `else` block of this `while` loop statement, if any. + * + * Only some languages (e.g. Python) support `while-else` constructs. + */ + default AstNode getWhileElse(WhileStmt loop) { none() } + + /** + * Gets the `else` block of this `foreach` loop statement, if any. + * + * Only some languages (e.g. Python) support `for-else` constructs. + */ + default AstNode getForeachElse(ForeachStmt loop) { none() } + /** A catch clause in a try statement. */ class CatchClause extends AstNode { /** Gets the variable declared by this catch clause. */ @@ -1539,19 +1553,32 @@ module Make0 Ast> { n2.isBefore(loopstmt.getBody()) or n1.isAfterFalse(cond) and - n2.isAfter(loopstmt) + ( + n2.isBefore(getWhileElse(loopstmt)) + or + not exists(getWhileElse(loopstmt)) and n2.isAfter(loopstmt) + ) or n1.isAfter(loopstmt.getBody()) and n2.isAdditional(loopstmt, loopHeaderTag()) ) or + exists(WhileStmt whilestmt | + n1.isAfter(getWhileElse(whilestmt)) and + n2.isAfter(whilestmt) + ) + or exists(ForeachStmt foreachstmt | n1.isBefore(foreachstmt) and n2.isBefore(foreachstmt.getCollection()) or n1.isAfterValue(foreachstmt.getCollection(), any(EmptinessSuccessor t | t.getValue() = true)) and - n2.isAfter(foreachstmt) + ( + n2.isBefore(getForeachElse(foreachstmt)) + or + not exists(getForeachElse(foreachstmt)) and n2.isAfter(foreachstmt) + ) or n1.isAfterValue(foreachstmt.getCollection(), any(EmptinessSuccessor t | t.getValue() = false)) and @@ -1564,10 +1591,17 @@ module Make0 Ast> { n2.isAdditional(foreachstmt, loopHeaderTag()) or n1.isAdditional(foreachstmt, loopHeaderTag()) and - n2.isAfter(foreachstmt) + ( + n2.isBefore(getForeachElse(foreachstmt)) + or + not exists(getForeachElse(foreachstmt)) and n2.isAfter(foreachstmt) + ) or n1.isAdditional(foreachstmt, loopHeaderTag()) and n2.isBefore(foreachstmt.getVariable()) + or + n1.isAfter(getForeachElse(foreachstmt)) and + n2.isAfter(foreachstmt) ) or exists(ForStmt forstmt, PreControlFlowNode condentry | From 93112b2b75ca78986cb682221c76332931c2f740 Mon Sep 17 00:00:00 2001 From: Copilot Date: Tue, 5 May 2026 14:56:18 +0000 Subject: [PATCH 34/44] Python: include try-else in getChild for completion propagation The shared CFG library propagates abrupt completions from child to parent via getChild(parent, _) = child. Python's try.getElse() was wired into normal step rules but not listed in getChild(TryStmt, ...), so return/break/continue/raise statements occurring inside a try-else block had no parent path and ended up as dead-end CFG nodes. Add the else block at index -2 (alongside finally at -1). This affects only completion propagation; the normal-flow CFG is unchanged because TryStmt has explicit step rules. Verified on a CPython database: all 11 shared-CFG consistency queries now pass with 0 violations (deadEnd: 244 -> 0). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll index eb21cf32b1d6..da960060edf0 100644 --- a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll +++ b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll @@ -969,13 +969,15 @@ module Ast implements AstSig { index = 1 and result = r.getCause() ) or - // TryStmt: body (0), handlers (1..n), finally (-1) + // TryStmt: body (0), handlers (1..n), else (-2), finally (-1) exists(TryStmt t | t = n | index = 0 and result = t.getBody() or result = t.getCatch(index - 1) and index >= 1 or index = -1 and result = t.getFinally() + or + index = -2 and result = t.getElse() ) or // Switch (match): subject (0), cases (1..n) From 03b8e8fdde1ca9fb769f486afed9cbf7b923aa32 Mon Sep 17 00:00:00 2001 From: Copilot Date: Tue, 5 May 2026 15:09:19 +0000 Subject: [PATCH 35/44] Python: refactor getChild into per-class OO dispatch Replace the single ~240-line top-level getChild predicate with one override per AST class. AstNode declares a default AstNode getChild(int index) { none() } and each subclass with children overrides it (41 classes total). The top-level predicate becomes a one-line dispatch: AstNode getChild(AstNode n, int index) { result = n.getChild(index) } No behavioral change: NewCfg evaluation-order tests still pass at the same 22/24 baseline, and all 11 shared-CFG consistency queries still report 0 violations on CPython. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../controlflow/internal/AstNodeImpl.qll | 448 ++++++++---------- 1 file changed, 209 insertions(+), 239 deletions(-) diff --git a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll index da960060edf0..86fdf45e0baa 100644 --- a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll +++ b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll @@ -143,6 +143,13 @@ module Ast implements AstSig { /** Gets the underlying Python `Pattern`, if this node wraps one. */ Py::Pattern asPattern() { this = TPattern(result) } + + /** + * Gets the child of this AST node at the specified (zero-based) + * index, in evaluation order. Subclasses with children override + * this method. + */ + AstNode getChild(int index) { none() } } /** Gets the immediately enclosing callable that contains `node`. */ @@ -186,6 +193,8 @@ module Ast implements AstSig { /** Gets the last statement in this block. */ Stmt getLastStmt() { result = TStmt(getBodyStmtList(parent, slot).getLastItem()) } + + override AstNode getChild(int index) { result = this.getStmt(index) } } /** An expression statement. */ @@ -196,6 +205,8 @@ module Ast implements AstSig { /** Gets the expression in this expression statement. */ Expr getExpr() { result = TExpr(exprStmt.getValue()) } + + override AstNode getChild(int index) { index = 0 and result = this.getExpr() } } /** An assignment statement (`x = y = expr`). */ @@ -209,6 +220,12 @@ module Ast implements AstSig { Expr getTarget(int n) { result = TExpr(assign.getTarget(n)) } int getNumberOfTargets() { result = count(assign.getATarget()) } + + override AstNode getChild(int index) { + index = 0 and result = this.getValue() + or + result = this.getTarget(index - 1) and index >= 1 + } } /** An augmented assignment statement (`x += expr`). */ @@ -218,6 +235,8 @@ module Ast implements AstSig { AugAssignStmt() { augAssign = this.asStmt() } Expr getOperation() { result = TExpr(augAssign.getOperation()) } + + override AstNode getChild(int index) { index = 0 and result = this.getOperation() } } /** An assignment expression / walrus operator (`x := expr`). */ @@ -229,6 +248,12 @@ module Ast implements AstSig { Expr getValue() { result = TExpr(assignExpr.getValue()) } Expr getTarget() { result = TExpr(assignExpr.getTarget()) } + + override AstNode getChild(int index) { + index = 0 and result = this.getValue() + or + index = 1 and result = this.getTarget() + } } /** @@ -256,6 +281,14 @@ module Ast implements AstSig { /** Gets the `else` (false) branch, if any. */ Stmt getElse() { result = TBlockStmt(ifStmt, "orelse") } + + override AstNode getChild(int index) { + index = 0 and result = this.getCondition() + or + index = 1 and result = this.getThen() + or + index = 2 and result = this.getElse() + } } /** A loop statement. */ @@ -279,6 +312,14 @@ module Ast implements AstSig { /** Gets the `else` branch of this `while` loop, if any. */ Stmt getElse() { result = TBlockStmt(whileStmt, "orelse") } + + override AstNode getChild(int index) { + index = 0 and result = this.getCondition() + or + index = 1 and result = this.getBody() + or + index = 2 and result = this.getElse() + } } /** @@ -317,6 +358,16 @@ module Ast implements AstSig { /** Gets the `else` branch of this `for` loop, if any. */ Stmt getElse() { result = TBlockStmt(forStmt, "orelse") } + + override AstNode getChild(int index) { + index = 0 and result = this.getCollection() + or + index = 1 and result = this.getVariable() + or + index = 2 and result = this.getBody() + or + index = 3 and result = this.getElse() + } } /** A `break` statement. */ @@ -342,6 +393,8 @@ module Ast implements AstSig { /** Gets the expression being returned, if any. */ Expr getExpr() { result = TExpr(ret.getValue()) } + + override AstNode getChild(int index) { index = 0 and result = this.getExpr() } } /** A `raise` statement (mapped to `Throw`). */ @@ -355,6 +408,12 @@ module Ast implements AstSig { /** Gets the cause of this `raise`, if any. */ Expr getCause() { result = TExpr(raise.getCause()) } + + override AstNode getChild(int index) { + index = 0 and result = this.getExpr() + or + index = 1 and result = this.getCause() + } } /** A `with` statement. */ @@ -368,6 +427,14 @@ module Ast implements AstSig { Expr getOptionalVars() { result = TExpr(withStmt.getOptionalVars()) } Stmt getBody() { result = TBlockStmt(withStmt, "body") } + + override AstNode getChild(int index) { + index = 0 and result = this.getContextExpr() + or + index = 1 and result = this.getOptionalVars() + or + index = 2 and result = this.getBody() + } } /** An `assert` statement. */ @@ -379,6 +446,12 @@ module Ast implements AstSig { Expr getTest() { result = TExpr(assertStmt.getTest()) } Expr getMsg() { result = TExpr(assertStmt.getMsg()) } + + override AstNode getChild(int index) { + index = 0 and result = this.getTest() + or + index = 1 and result = this.getMsg() + } } /** A `delete` statement. */ @@ -388,6 +461,8 @@ module Ast implements AstSig { DeleteStmt() { del = this.asStmt() } Expr getTarget(int n) { result = TExpr(del.getTarget(n)) } + + override AstNode getChild(int index) { result = this.getTarget(index) } } /** A `try` statement. */ @@ -404,6 +479,16 @@ module Ast implements AstSig { Stmt getFinally() { result = TBlockStmt(tryStmt, "finally") } CatchClause getCatch(int index) { result = TStmt(tryStmt.getHandler(index)) } + + override AstNode getChild(int index) { + index = 0 and result = this.getBody() + or + result = this.getCatch(index - 1) and index >= 1 + or + index = -1 and result = this.getFinally() + or + index = -2 and result = this.getElse() + } } /** @@ -442,6 +527,14 @@ module Ast implements AstSig { or result = TBlockStmt(handler.(Py::ExceptGroupStmt), "body") } + + override AstNode getChild(int index) { + index = 0 and result = this.getType() + or + index = 1 and result = this.getVariable() + or + index = 2 and result = this.getBody() + } } /** A `match` statement, mapped to the shared CFG's `Switch`. */ @@ -455,6 +548,12 @@ module Ast implements AstSig { Case getCase(int index) { result = TStmt(matchStmt.getCase(index)) } Stmt getStmt(int index) { none() } + + override AstNode getChild(int index) { + index = 0 and result = this.getExpr() + or + result = this.getCase(index - 1) and index >= 1 + } } /** A `case` clause in a match statement. */ @@ -471,6 +570,14 @@ module Ast implements AstSig { /** Holds if this case is a wildcard pattern (`case _:`). */ predicate isWildcard() { caseStmt.getPattern() instanceof Py::MatchWildcardPattern } + + override AstNode getChild(int index) { + index = 0 and result = this.getAPattern() + or + index = 1 and result = this.getGuard() + or + index = 2 and result = this.getBody() + } } /** A wildcard case (`case _:`). */ @@ -492,6 +599,14 @@ module Ast implements AstSig { /** Gets the false branch of this expression. */ Expr getElse() { result = TExpr(ifExp.getOrelse()) } + + override AstNode getChild(int index) { + index = 0 and result = this.getCondition() + or + index = 1 and result = this.getThen() + or + index = 2 and result = this.getElse() + } } /** @@ -547,6 +662,12 @@ module Ast implements AstSig { result = TBoolExprPair(be, i + 1) ) } + + override AstNode getChild(int index) { + index = 0 and result = this.getLeftOperand() + or + index = 1 and result = this.getRightOperand() + } } /** A short-circuiting logical `and` expression. */ @@ -582,6 +703,8 @@ module Ast implements AstSig { /** Gets the operand of this unary expression. */ Expr getOperand() { result = TExpr(this.asExpr().(Py::UnaryExpr).getOperand()) } + + override AstNode getChild(int index) { index = 0 and result = this.getOperand() } } /** A logical `not` expression. */ @@ -633,6 +756,12 @@ module Ast implements AstSig { Expr getLeft() { result = TExpr(binExpr.getLeft()) } Expr getRight() { result = TExpr(binExpr.getRight()) } + + override AstNode getChild(int index) { + index = 0 and result = this.getLeft() + or + index = 1 and result = this.getRight() + } } /** A call expression (`func(args...)`). */ @@ -654,6 +783,15 @@ module Ast implements AstSig { } int getNumberOfNamedArgs() { result = count(call.getANamedArg()) } + + override AstNode getChild(int index) { + index = 0 and result = this.getFunc() + or + result = this.getPositionalArg(index - 1) and index >= 1 + or + result = this.getKeywordValue(index - 1 - this.getNumberOfPositionalArgs()) and + index >= 1 + this.getNumberOfPositionalArgs() + } } /** A subscript expression (`obj[index]`). */ @@ -665,6 +803,12 @@ module Ast implements AstSig { Expr getObject() { result = TExpr(sub.getObject()) } Expr getIndex() { result = TExpr(sub.getIndex()) } + + override AstNode getChild(int index) { + index = 0 and result = this.getObject() + or + index = 1 and result = this.getIndex() + } } /** An attribute access (`obj.name`). */ @@ -674,6 +818,8 @@ module Ast implements AstSig { AttributeExpr() { attr = this.asExpr() } Expr getObject() { result = TExpr(attr.getObject()) } + + override AstNode getChild(int index) { index = 0 and result = this.getObject() } } /** A tuple literal. */ @@ -683,6 +829,8 @@ module Ast implements AstSig { TupleExpr() { tuple = this.asExpr() } Expr getElt(int n) { result = TExpr(tuple.getElt(n)) } + + override AstNode getChild(int index) { result = this.getElt(index) } } /** A list literal. */ @@ -692,6 +840,8 @@ module Ast implements AstSig { ListExpr() { list = this.asExpr() } Expr getElt(int n) { result = TExpr(list.getElt(n)) } + + override AstNode getChild(int index) { result = this.getElt(index) } } /** A set literal. */ @@ -701,6 +851,8 @@ module Ast implements AstSig { SetExpr() { set = this.asExpr() } Expr getElt(int n) { result = TExpr(set.getElt(n)) } + + override AstNode getChild(int index) { result = this.getElt(index) } } /** A dict literal. */ @@ -718,6 +870,14 @@ module Ast implements AstSig { Expr getValue(int n) { result = TExpr(dict.getItem(n).(Py::KeyValuePair).getValue()) } int getNumberOfItems() { result = count(dict.getAnItem()) } + + override AstNode getChild(int index) { + exists(int item | + index = 2 * item and result = this.getKey(item) + or + index = 2 * item + 1 and result = this.getValue(item) + ) + } } /** A unary expression other than `not` (e.g., `-x`, `+x`, `~x`). */ @@ -727,6 +887,8 @@ module Ast implements AstSig { ArithUnaryExpr() { unaryExpr = this.asExpr() and not unaryExpr.getOp() instanceof Py::Not } Expr getOperand() { result = TExpr(unaryExpr.getOperand()) } + + override AstNode getChild(int index) { index = 0 and result = this.getOperand() } } /** @@ -748,6 +910,8 @@ module Ast implements AstSig { } Expr getIterable() { result = TExpr(iterable) } + + override AstNode getChild(int index) { index = 0 and result = this.getIterable() } } /** A comparison expression (`a < b`, `a < b < c`, etc.). */ @@ -759,6 +923,12 @@ module Ast implements AstSig { Expr getLeft() { result = TExpr(cmp.getLeft()) } Expr getComparator(int n) { result = TExpr(cmp.getComparator(n)) } + + override AstNode getChild(int index) { + index = 0 and result = this.getLeft() + or + result = this.getComparator(index - 1) and index >= 1 + } } /** A slice expression (`start:stop:step`). */ @@ -772,6 +942,14 @@ module Ast implements AstSig { Expr getStop() { result = TExpr(slice.getStop()) } Expr getStep() { result = TExpr(slice.getStep()) } + + override AstNode getChild(int index) { + index = 0 and result = this.getStart() + or + index = 1 and result = this.getStop() + or + index = 2 and result = this.getStep() + } } /** A starred expression (`*x`). */ @@ -781,6 +959,8 @@ module Ast implements AstSig { StarredExpr() { starred = this.asExpr() } Expr getValue() { result = TExpr(starred.getValue()) } + + override AstNode getChild(int index) { index = 0 and result = this.getValue() } } /** A formatted string literal (`f"...{expr}..."`). */ @@ -790,6 +970,8 @@ module Ast implements AstSig { FstringExpr() { fstring = this.asExpr() } Expr getValue(int n) { result = TExpr(fstring.getValue(n)) } + + override AstNode getChild(int index) { result = this.getValue(index) } } /** A formatted value inside an f-string (`{expr}` or `{expr:spec}`). */ @@ -801,6 +983,12 @@ module Ast implements AstSig { Expr getValue() { result = TExpr(fv.getValue()) } Expr getFormatSpec() { result = TExpr(fv.getFormatSpec()) } + + override AstNode getChild(int index) { + index = 0 and result = this.getValue() + or + index = 1 and result = this.getFormatSpec() + } } /** A `yield` expression. */ @@ -810,6 +998,8 @@ module Ast implements AstSig { YieldExpr() { yield = this.asExpr() } Expr getValue() { result = TExpr(yield.getValue()) } + + override AstNode getChild(int index) { index = 0 and result = this.getValue() } } /** A `yield from` expression. */ @@ -819,6 +1009,8 @@ module Ast implements AstSig { YieldFromExpr() { yieldFrom = this.asExpr() } Expr getValue() { result = TExpr(yieldFrom.getValue()) } + + override AstNode getChild(int index) { index = 0 and result = this.getValue() } } /** An `await` expression. */ @@ -828,6 +1020,8 @@ module Ast implements AstSig { AwaitExpr() { await = this.asExpr() } Expr getValue() { result = TExpr(await.getValue()) } + + override AstNode getChild(int index) { index = 0 and result = this.getValue() } } /** A class definition expression (has base classes evaluated at definition time). */ @@ -837,6 +1031,8 @@ module Ast implements AstSig { ClassDefExpr() { classExpr = this.asExpr() } Expr getBase(int n) { result = TExpr(classExpr.getBase(n)) } + + override AstNode getChild(int index) { result = this.getBase(index) } } /** A function definition expression (has default args evaluated at definition time). */ @@ -863,6 +1059,12 @@ module Ast implements AstSig { } int getNumberOfDefaults() { result = count(funcExpr.getArgs().getADefault()) } + + override AstNode getChild(int index) { + result = this.getDefault(index) + or + result = this.getKwDefault(index - this.getNumberOfDefaults()) + } } /** A lambda expression (has default args evaluated at definition time). */ @@ -884,248 +1086,16 @@ module Ast implements AstSig { } int getNumberOfDefaults() { result = count(lambda.getArgs().getADefault()) } - } - /** Gets the child of `n` at the specified (zero-based) index. */ - AstNode getChild(AstNode n, int index) { - // BlockStmt: indexed statements - result = n.(BlockStmt).getStmt(index) - or - // IfStmt: condition (0), then (1), else (2) - exists(IfStmt ifStmt | ifStmt = n | - index = 0 and result = ifStmt.getCondition() - or - index = 1 and result = ifStmt.getThen() - or - index = 2 and result = ifStmt.getElse() - ) - or - // ExprStmt: the expression (0) - index = 0 and result = n.(ExprStmt).getExpr() - or - // Assign: value (0), targets (1..n) - exists(AssignStmt a | a = n | - index = 0 and result = a.getValue() - or - result = a.getTarget(index - 1) and index >= 1 - ) - or - // AugAssign: the operation (0) - index = 0 and result = n.(AugAssignStmt).getOperation() - or - // Walrus (`x := expr`): value (0), target (1) - exists(NamedExpr ne | ne = n | - index = 0 and result = ne.getValue() - or - index = 1 and result = ne.getTarget() - ) - or - // WhileStmt: condition (0), body (1), orelse (2) - exists(WhileStmt w | w = n | - index = 0 and result = w.getCondition() - or - index = 1 and result = w.getBody() - or - index = 2 and result = w.getElse() - ) - or - // ForeachStmt: collection (0), variable (1), body (2), orelse (3) - exists(ForeachStmt f | f = n | - index = 0 and result = f.getCollection() - or - index = 1 and result = f.getVariable() - or - index = 2 and result = f.getBody() - or - index = 3 and result = f.getElse() - ) - or - // ReturnStmt: the value (0) - index = 0 and result = n.(ReturnStmt).getExpr() - or - // AssertStmt: test (0), message (1) - exists(AssertStmt a | a = n | - index = 0 and result = a.getTest() - or - index = 1 and result = a.getMsg() - ) - or - // DeleteStmt: targets left to right - result = n.(DeleteStmt).getTarget(index) - or - // WithStmt: context expr (0), optional vars (1), body (2) - exists(WithStmt w | w = n | - index = 0 and result = w.getContextExpr() - or - index = 1 and result = w.getOptionalVars() - or - index = 2 and result = w.getBody() - ) - or - // Throw (raise): exception (0), cause (1) - exists(Throw r | r = n | - index = 0 and result = r.getExpr() - or - index = 1 and result = r.getCause() - ) - or - // TryStmt: body (0), handlers (1..n), else (-2), finally (-1) - exists(TryStmt t | t = n | - index = 0 and result = t.getBody() - or - result = t.getCatch(index - 1) and index >= 1 - or - index = -1 and result = t.getFinally() - or - index = -2 and result = t.getElse() - ) - or - // Switch (match): subject (0), cases (1..n) - exists(Switch m | m = n | - index = 0 and result = m.getExpr() - or - result = m.getCase(index - 1) and index >= 1 - ) - or - // Case: pattern (0), guard (1), body (2) - exists(Case c | c = n | - index = 0 and result = c.getAPattern() - or - index = 1 and result = c.getGuard() - or - index = 2 and result = c.getBody() - ) - or - // CatchClause (except handler): type (0), name (1), body (2) - exists(CatchClause h | h = n | - index = 0 and result = h.getType() - or - index = 1 and result = h.getVariable() - or - index = 2 and result = h.getBody() - ) - or - // ConditionalExpr (IfExp): condition (0), then (1), else (2) - exists(ConditionalExpr ie | ie = n | - index = 0 and result = ie.getCondition() + override AstNode getChild(int index) { + result = this.getDefault(index) or - index = 1 and result = ie.getThen() - or - index = 2 and result = ie.getElse() - ) - or - // Call: func (0), positional args (1..n), keyword values (n+1..n+k) - exists(CallExpr call | call = n | - index = 0 and result = call.getFunc() - or - result = call.getPositionalArg(index - 1) and index >= 1 - or - result = call.getKeywordValue(index - 1 - call.getNumberOfPositionalArgs()) and - index >= 1 + call.getNumberOfPositionalArgs() - ) - or - // Python BinaryExpr (arithmetic, bitwise, matmul, etc.): left (0), right (1) - exists(ArithBinaryExpr be | be = n | - index = 0 and result = be.getLeft() - or - index = 1 and result = be.getRight() - ) - or - // Subscript (obj[index]): object (0), index (1) - exists(SubscriptExpr sub | sub = n | - index = 0 and result = sub.getObject() - or - index = 1 and result = sub.getIndex() - ) - or - // Attribute (obj.name): object (0) - index = 0 and result = n.(AttributeExpr).getObject() - or - // Comprehension/generator: iterable (0) - index = 0 and result = n.(Comprehension).getIterable() - or - // Tuple, List, Set: elements left to right - result = n.(TupleExpr).getElt(index) - or - result = n.(ListExpr).getElt(index) - or - result = n.(SetExpr).getElt(index) - or - // Dict: key(0), value(0), key(1), value(1), ... - exists(DictExpr d, int item | d = n | - index = 2 * item and result = d.getKey(item) - or - index = 2 * item + 1 and result = d.getValue(item) - ) - or - // Arithmetic unary (-x, +x, ~x): operand (0) - index = 0 and result = n.(ArithUnaryExpr).getOperand() - or - // Compare (a < b < c): left (0), comparators (1..n) - exists(CompareExpr cmp | cmp = n | - index = 0 and result = cmp.getLeft() - or - result = cmp.getComparator(index - 1) and index >= 1 - ) - or - // Slice (start:stop:step): start (0), stop (1), step (2) - exists(SliceExpr sl | sl = n | - index = 0 and result = sl.getStart() - or - index = 1 and result = sl.getStop() - or - index = 2 and result = sl.getStep() - ) - or - // Starred (*x): value (0) - index = 0 and result = n.(StarredExpr).getValue() - or - // Fstring: values left to right - result = n.(FstringExpr).getValue(index) - or - // FormattedValue ({expr} or {expr:spec}): value (0), format spec (1) - exists(FormattedValueExpr fv | fv = n | - index = 0 and result = fv.getValue() - or - index = 1 and result = fv.getFormatSpec() - ) - or - // Yield: value (0) - index = 0 and result = n.(YieldExpr).getValue() - or - // YieldFrom: value (0) - index = 0 and result = n.(YieldFromExpr).getValue() - or - // Await: value (0) - index = 0 and result = n.(AwaitExpr).getValue() - or - // ClassExpr: base classes left to right - result = n.(ClassDefExpr).getBase(index) - or - // FunctionExpr: defaults left to right, then kw defaults - exists(FunctionDefExpr fe | fe = n | - result = fe.getDefault(index) - or - result = fe.getKwDefault(index - fe.getNumberOfDefaults()) - ) - or - // Lambda: defaults left to right, then kw defaults - exists(LambdaExpr lam | lam = n | - result = lam.getDefault(index) - or - result = lam.getKwDefault(index - lam.getNumberOfDefaults()) - ) - or - // LogicalNotExpr: operand (0) - index = 0 and result = n.(LogicalNotExpr).getOperand() - or - // BinaryExpr (`and`/`or`): left (0), right (1) - exists(BinaryExpr be | be = n | - index = 0 and result = be.getLeftOperand() - or - index = 1 and result = be.getRightOperand() - ) + result = this.getKwDefault(index - this.getNumberOfDefaults()) + } } + + /** Gets the child of `n` at the specified (zero-based) index. */ + AstNode getChild(AstNode n, int index) { result = n.getChild(index) } } private module Cfg0 = Make0; From 9781ee8d662fabc7577297767007cc5fd6ba81d8 Mon Sep 17 00:00:00 2001 From: Copilot Date: Tue, 5 May 2026 15:26:20 +0000 Subject: [PATCH 36/44] Python: adapt to new shared CFG signature Main added two new requirements to AstSig: - A 'Parameter' class with a 'getDefaultValue()' method, plus a 'callableGetParameter(Callable, int)' predicate. - A 'CallableContext' class in InputSig1, replacing the previous 'CallableBodyPartContext'. Add stub implementations: 'Parameter' is empty (none()) and 'callableGetParameter' returns nothing, mirroring Java's TODO. Rename 'CallableBodyPartContext = Void' to 'CallableContext = Void' in the Python Input module. NewCfg evaluation-order tests still pass at the 22/24 baseline; all 11 shared-CFG consistency queries still report 0 violations on CPython. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../python/controlflow/internal/AstNodeImpl.qll | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll index 86fdf45e0baa..fab2d1cd2e5b 100644 --- a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll +++ b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll @@ -165,6 +165,20 @@ module Ast implements AstSig { /** Gets the body of callable `c`. */ AstNode callableGetBody(Callable c) { result = TBlockStmt(c.asScope(), "body") } + /** + * A parameter of a callable. + * + * TODO: Implement in order to include parameters in the CFG. + */ + class Parameter extends AstNode { + Parameter() { none() } + + Expr getDefaultValue() { none() } + } + + /** Gets the `index`th parameter of callable `c`. */ + Parameter callableGetParameter(Callable c, int index) { none() } + /** A statement. */ class Stmt extends AstNode { Stmt() { this instanceof TStmt or this instanceof TBlockStmt } @@ -1119,7 +1133,7 @@ private module Input implements InputSig1, InputSig2 { string toString() { result = "label" } } - class CallableBodyPartContext = Void; + class CallableContext = Void; predicate inConditionalContext(Ast::AstNode n, ConditionKind kind) { kind.isBoolean() and From f2151fe23270f834959bfe5aeac9c15d8f82fae5 Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 7 May 2026 11:06:40 +0000 Subject: [PATCH 37/44] Python: dispatch toString/getLocation/getEnclosingCallable per branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the three big disjunctive predicates on AstNode with empty defaults plus per-newtype-branch override classes: AstNode.toString() { none() } AstNode.getLocation() { none() } AstNode.getEnclosingCallable() { none() } Six private subclasses (one per newtype branch — TStmt, TExpr, TScope, TPattern, TBoolExprPair, TBlockStmt) override these with the branch-specific implementation. This mirrors the per-class dispatch already used for getChild. No behaviour change: all 24 NewCfg evaluation-order tests pass and all 11 shared-CFG consistency queries still report 0 violations on CPython. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../controlflow/internal/AstNodeImpl.qll | 141 +++++++++++------- 1 file changed, 91 insertions(+), 50 deletions(-) diff --git a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll index fab2d1cd2e5b..fc6fdd68c2c9 100644 --- a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll +++ b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll @@ -77,60 +77,13 @@ module Ast implements AstSig { /** An AST node visible to the shared CFG. */ class AstNode extends TAstNode { /** Gets a textual representation of this AST node. */ - string toString() { - exists(Py::Stmt s | this = TStmt(s) and result = s.toString()) - or - exists(Py::Expr e | this = TExpr(e) and result = e.toString()) - or - exists(Py::Scope sc | this = TScope(sc) and result = sc.toString()) - or - exists(Py::Pattern p | this = TPattern(p) and result = p.toString()) - or - exists(Py::BoolExpr be | this = TBoolExprPair(be, _) and result = be.getOperator()) - or - exists(string slot | this = TBlockStmt(_, slot) and result = "block:" + slot) - } + string toString() { none() } /** Gets the location of this AST node. */ - Py::Location getLocation() { - exists(Py::Stmt s | this = TStmt(s) and result = s.getLocation()) - or - exists(Py::Expr e | this = TExpr(e) and result = e.getLocation()) - or - exists(Py::Scope sc | this = TScope(sc) and result = sc.getLocation()) - or - exists(Py::Pattern p | this = TPattern(p) and result = p.getLocation()) - or - exists(Py::BoolExpr be, int index | - this = TBoolExprPair(be, index) and result = be.getValue(index).getLocation() - ) - or - // BlockStmt has no native location; approximate with the first - // item's location. - exists(Py::AstNode parent, string slot | - this = TBlockStmt(parent, slot) and - result = getBodyStmtList(parent, slot).getItem(0).getLocation() - ) - } + Py::Location getLocation() { none() } /** Gets the enclosing callable that contains this node, if any. */ - Callable getEnclosingCallable() { - exists(Py::Stmt s | this = TStmt(s) and result.asScope() = s.getScope()) - or - exists(Py::Expr e | this = TExpr(e) and result.asScope() = e.getScope()) - or - exists(Py::Scope sc | this = TScope(sc) and result.asScope() = sc.getEnclosingScope()) - or - exists(Py::Pattern p | this = TPattern(p) and result.asScope() = p.getScope()) - or - exists(Py::BoolExpr be | this = TBoolExprPair(be, _) and result.asScope() = be.getScope()) - or - exists(Py::AstNode parent | this = TBlockStmt(parent, _) | - result.asScope() = parent.(Py::Scope) - or - result.asScope() = parent.(Py::Stmt).getScope() - ) - } + Callable getEnclosingCallable() { none() } /** Gets the underlying Python `Stmt`, if this node wraps one. */ Py::Stmt asStmt() { this = TStmt(result) } @@ -152,6 +105,94 @@ module Ast implements AstSig { AstNode getChild(int index) { none() } } + /** Implementation of `AstNode` predicates for `TStmt` nodes. */ + private class TStmtAstNode extends AstNode, TStmt { + private Py::Stmt s; + + TStmtAstNode() { this = TStmt(s) } + + override string toString() { result = s.toString() } + + override Py::Location getLocation() { result = s.getLocation() } + + override Callable getEnclosingCallable() { result.asScope() = s.getScope() } + } + + /** Implementation of `AstNode` predicates for `TExpr` nodes. */ + private class TExprAstNode extends AstNode, TExpr { + private Py::Expr e; + + TExprAstNode() { this = TExpr(e) } + + override string toString() { result = e.toString() } + + override Py::Location getLocation() { result = e.getLocation() } + + override Callable getEnclosingCallable() { result.asScope() = e.getScope() } + } + + /** Implementation of `AstNode` predicates for `TScope` nodes. */ + private class TScopeAstNode extends AstNode, TScope { + private Py::Scope sc; + + TScopeAstNode() { this = TScope(sc) } + + override string toString() { result = sc.toString() } + + override Py::Location getLocation() { result = sc.getLocation() } + + override Callable getEnclosingCallable() { result.asScope() = sc.getEnclosingScope() } + } + + /** Implementation of `AstNode` predicates for `TPattern` nodes. */ + private class TPatternAstNode extends AstNode, TPattern { + private Py::Pattern p; + + TPatternAstNode() { this = TPattern(p) } + + override string toString() { result = p.toString() } + + override Py::Location getLocation() { result = p.getLocation() } + + override Callable getEnclosingCallable() { result.asScope() = p.getScope() } + } + + /** Implementation of `AstNode` predicates for synthetic `TBoolExprPair` nodes. */ + private class TBoolExprPairAstNode extends AstNode, TBoolExprPair { + private Py::BoolExpr be; + private int index; + + TBoolExprPairAstNode() { this = TBoolExprPair(be, index) } + + override string toString() { result = be.getOperator() } + + override Py::Location getLocation() { result = be.getValue(index).getLocation() } + + override Callable getEnclosingCallable() { result.asScope() = be.getScope() } + } + + /** Implementation of `AstNode` predicates for synthetic `TBlockStmt` nodes. */ + private class TBlockStmtAstNode extends AstNode, TBlockStmt { + private Py::AstNode parent; + private string slot; + + TBlockStmtAstNode() { this = TBlockStmt(parent, slot) } + + override string toString() { result = "block:" + slot } + + // BlockStmt has no native location; approximate with the first + // item's location. + override Py::Location getLocation() { + result = getBodyStmtList(parent, slot).getItem(0).getLocation() + } + + override Callable getEnclosingCallable() { + result.asScope() = parent.(Py::Scope) + or + result.asScope() = parent.(Py::Stmt).getScope() + } + } + /** Gets the immediately enclosing callable that contains `node`. */ Callable getEnclosingCallable(AstNode node) { result = node.getEnclosingCallable() } From 77e36d3cfa06d022630045cbcacda2dc918e12f3 Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 7 May 2026 11:31:07 +0000 Subject: [PATCH 38/44] Python: merge T*AstNode wrappers into matching public classes Five of the six per-newtype-branch wrapper classes had a natural public class corresponding to that branch: TStmtAstNode -> Stmt (TStmt subset; BlockStmt overrides for TBlockStmt) TExprAstNode -> Expr (TExpr subset; BoolExprPair overrides for TBoolExprPair) TScopeAstNode -> Callable (= TScope exactly) TPatternAstNode -> Pattern (= TPattern exactly) TBlockStmtAstNode -> BlockStmt (= TBlockStmt exactly) Move toString/getLocation/getEnclosingCallable onto these classes and delete the wrappers. The sixth wrapper (TBoolExprPair) has no exact public counterpart - BinaryExpr is broader, including TExpr-branch BoolExprs - so it remains as a small private class, renamed BoolExprPair. No behaviour change: all 24 NewCfg evaluation-order tests pass; all 11 shared-CFG consistency queries report 0 violations on CPython. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../controlflow/internal/AstNodeImpl.qll | 132 +++++++----------- 1 file changed, 54 insertions(+), 78 deletions(-) diff --git a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll index fc6fdd68c2c9..212f1c200536 100644 --- a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll +++ b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll @@ -105,64 +105,12 @@ module Ast implements AstSig { AstNode getChild(int index) { none() } } - /** Implementation of `AstNode` predicates for `TStmt` nodes. */ - private class TStmtAstNode extends AstNode, TStmt { - private Py::Stmt s; - - TStmtAstNode() { this = TStmt(s) } - - override string toString() { result = s.toString() } - - override Py::Location getLocation() { result = s.getLocation() } - - override Callable getEnclosingCallable() { result.asScope() = s.getScope() } - } - - /** Implementation of `AstNode` predicates for `TExpr` nodes. */ - private class TExprAstNode extends AstNode, TExpr { - private Py::Expr e; - - TExprAstNode() { this = TExpr(e) } - - override string toString() { result = e.toString() } - - override Py::Location getLocation() { result = e.getLocation() } - - override Callable getEnclosingCallable() { result.asScope() = e.getScope() } - } - - /** Implementation of `AstNode` predicates for `TScope` nodes. */ - private class TScopeAstNode extends AstNode, TScope { - private Py::Scope sc; - - TScopeAstNode() { this = TScope(sc) } - - override string toString() { result = sc.toString() } - - override Py::Location getLocation() { result = sc.getLocation() } - - override Callable getEnclosingCallable() { result.asScope() = sc.getEnclosingScope() } - } - - /** Implementation of `AstNode` predicates for `TPattern` nodes. */ - private class TPatternAstNode extends AstNode, TPattern { - private Py::Pattern p; - - TPatternAstNode() { this = TPattern(p) } - - override string toString() { result = p.toString() } - - override Py::Location getLocation() { result = p.getLocation() } - - override Callable getEnclosingCallable() { result.asScope() = p.getScope() } - } - /** Implementation of `AstNode` predicates for synthetic `TBoolExprPair` nodes. */ - private class TBoolExprPairAstNode extends AstNode, TBoolExprPair { + private class BoolExprPair extends Expr, TBoolExprPair { private Py::BoolExpr be; private int index; - TBoolExprPairAstNode() { this = TBoolExprPair(be, index) } + BoolExprPair() { this = TBoolExprPair(be, index) } override string toString() { result = be.getOperator() } @@ -171,28 +119,6 @@ module Ast implements AstSig { override Callable getEnclosingCallable() { result.asScope() = be.getScope() } } - /** Implementation of `AstNode` predicates for synthetic `TBlockStmt` nodes. */ - private class TBlockStmtAstNode extends AstNode, TBlockStmt { - private Py::AstNode parent; - private string slot; - - TBlockStmtAstNode() { this = TBlockStmt(parent, slot) } - - override string toString() { result = "block:" + slot } - - // BlockStmt has no native location; approximate with the first - // item's location. - override Py::Location getLocation() { - result = getBodyStmtList(parent, slot).getItem(0).getLocation() - } - - override Callable getEnclosingCallable() { - result.asScope() = parent.(Py::Scope) - or - result.asScope() = parent.(Py::Stmt).getScope() - } - } - /** Gets the immediately enclosing callable that contains `node`. */ Callable getEnclosingCallable(AstNode node) { result = node.getEnclosingCallable() } @@ -201,7 +127,17 @@ module Ast implements AstSig { * * In Python, all three are executable scopes with statement bodies. */ - class Callable extends AstNode, TScope { } + class Callable extends AstNode, TScope { + private Py::Scope sc; + + Callable() { this = TScope(sc) } + + override string toString() { result = sc.toString() } + + override Py::Location getLocation() { result = sc.getLocation() } + + override Callable getEnclosingCallable() { result.asScope() = sc.getEnclosingScope() } + } /** Gets the body of callable `c`. */ AstNode callableGetBody(Callable c) { result = TBlockStmt(c.asScope(), "body") } @@ -223,15 +159,41 @@ module Ast implements AstSig { /** A statement. */ class Stmt extends AstNode { Stmt() { this instanceof TStmt or this instanceof TBlockStmt } + + // For `TStmt` instances, delegate to the wrapped Python statement. + // `BlockStmt` (the only `TBlockStmt` subclass) provides its own overrides. + override string toString() { result = this.asStmt().toString() } + + override Py::Location getLocation() { result = this.asStmt().getLocation() } + + override Callable getEnclosingCallable() { result.asScope() = this.asStmt().getScope() } } /** An expression. */ class Expr extends AstNode { Expr() { this instanceof TExpr or this instanceof TBoolExprPair } + + // For `TExpr` instances, delegate to the wrapped Python expression. + // `BoolExprPair` (the only `TBoolExprPair` subclass) provides its own overrides. + override string toString() { result = this.asExpr().toString() } + + override Py::Location getLocation() { result = this.asExpr().getLocation() } + + override Callable getEnclosingCallable() { result.asScope() = this.asExpr().getScope() } } /** A pattern in a `match` statement. */ - additional class Pattern extends AstNode, TPattern { } + additional class Pattern extends AstNode, TPattern { + private Py::Pattern p; + + Pattern() { this = TPattern(p) } + + override string toString() { result = p.toString() } + + override Py::Location getLocation() { result = p.getLocation() } + + override Callable getEnclosingCallable() { result.asScope() = p.getScope() } + } /** * A block statement, modeling the body of a parent AST node as a @@ -249,6 +211,20 @@ module Ast implements AstSig { /** Gets the last statement in this block. */ Stmt getLastStmt() { result = TStmt(getBodyStmtList(parent, slot).getLastItem()) } + override string toString() { result = "block:" + slot } + + // BlockStmt has no native location; approximate with the first + // item's location. + override Py::Location getLocation() { + result = getBodyStmtList(parent, slot).getItem(0).getLocation() + } + + override Callable getEnclosingCallable() { + result.asScope() = parent.(Py::Scope) + or + result.asScope() = parent.(Py::Stmt).getScope() + } + override AstNode getChild(int index) { result = this.getStmt(index) } } From 250b63c216f050a91053b4739800c84301364ff2 Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 7 May 2026 12:04:54 +0000 Subject: [PATCH 39/44] Python: unify Py::BoolExpr handling via TBoolExprPair Previously a Py::BoolExpr appeared in two newtype branches: as TExpr(be) (the outermost pair) and TBoolExprPair(be, i) for inner pairs of 3+ operand expressions. This forced BinaryExpr/LogicalAndExpr/LogicalOrExpr to disjoin two cases, and the synthetic-pair handling spanned multiple layers. Restrict TExpr to non-BoolExpr Py::Expr, and extend TBoolExprPair to cover every operand pair (index 0..n-2). Now every Py::BoolExpr is represented uniformly as TBoolExprPair(_, 0) for the whole expression and TBoolExprPair(_, i) for inner pairs. Extend AstNode.asExpr() to also recover the underlying Py::BoolExpr from TBoolExprPair(_, 0). This makes asExpr() the inverse of construction: every 'result = TExpr(e)' turns into 'result.asExpr() = e', which works uniformly for BoolExprs and non-BoolExprs alike. Consequences: - BinaryExpr now extends TBoolExprPair directly with a single uniform rule for left/right operands. - LogicalAndExpr/LogicalOrExpr are one-line char preds via getBoolExpr(). - The private BoolExprPair wrapper class folds into BinaryExpr. - 60+ leaf wrappers now read 'result.asExpr() = py_expr' instead of 'result = TExpr(py_expr)'. No behaviour change: all 24 NewCfg evaluation-order tests pass; all 11 shared-CFG consistency queries report 0 violations on CPython. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../controlflow/internal/AstNodeImpl.qll | 254 ++++++++---------- 1 file changed, 108 insertions(+), 146 deletions(-) diff --git a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll index 212f1c200536..bcf8f4f64705 100644 --- a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll +++ b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll @@ -55,18 +55,18 @@ module Ast implements AstSig { private newtype TAstNode = TStmt(Py::Stmt s) or - TExpr(Py::Expr e) or + TExpr(Py::Expr e) { not e instanceof Py::BoolExpr } or TScope(Py::Scope sc) or TPattern(Py::Pattern p) or /** - * A synthetic intermediate node in a multi-operand `and`/`or` + * A synthetic node representing an operand pair of an `and`/`or` * expression. For `a and b and c` (operands 0, 1, 2) we model the - * operation as a right-nested tree where the inner pair at index 1 - * represents `b and c` and is the right operand of the outer pair. - * The outermost pair (index 0) is represented by the underlying - * `Py::BoolExpr` itself via `TExpr`. + * operation as a right-nested tree: pair 0 represents the whole + * expression with left=a and right=pair 1; pair 1 represents + * `b and c` with left=b and right=c. Each Python `Py::BoolExpr` + * with `n` operands has `n - 1` such pairs (indices `0 .. n - 2`). */ - TBoolExprPair(Py::BoolExpr be, int index) { index = [1 .. count(be.getAValue()) - 2] } or + TBoolExprPair(Py::BoolExpr be, int index) { index = [0 .. count(be.getAValue()) - 2] } or /** * A synthetic block statement, identifying one body slot of the * `parent` AST node. The `slot` string disambiguates among multiple @@ -88,8 +88,17 @@ module Ast implements AstSig { /** Gets the underlying Python `Stmt`, if this node wraps one. */ Py::Stmt asStmt() { this = TStmt(result) } - /** Gets the underlying Python `Expr`, if this node wraps one. */ - Py::Expr asExpr() { this = TExpr(result) } + /** + * Gets the underlying Python `Expr`, if this node wraps one. Boolean + * expressions are represented by `TBoolExprPair(_, 0)`; this + * predicate also recovers the underlying `Py::BoolExpr` from such a + * representation. + */ + Py::Expr asExpr() { + this = TExpr(result) + or + this = TBoolExprPair(result, 0) + } /** Gets the underlying Python `Scope`, if this node wraps one. */ Py::Scope asScope() { this = TScope(result) } @@ -105,20 +114,6 @@ module Ast implements AstSig { AstNode getChild(int index) { none() } } - /** Implementation of `AstNode` predicates for synthetic `TBoolExprPair` nodes. */ - private class BoolExprPair extends Expr, TBoolExprPair { - private Py::BoolExpr be; - private int index; - - BoolExprPair() { this = TBoolExprPair(be, index) } - - override string toString() { result = be.getOperator() } - - override Py::Location getLocation() { result = be.getValue(index).getLocation() } - - override Callable getEnclosingCallable() { result.asScope() = be.getScope() } - } - /** Gets the immediately enclosing callable that contains `node`. */ Callable getEnclosingCallable(AstNode node) { result = node.getEnclosingCallable() } @@ -174,7 +169,7 @@ module Ast implements AstSig { Expr() { this instanceof TExpr or this instanceof TBoolExprPair } // For `TExpr` instances, delegate to the wrapped Python expression. - // `BoolExprPair` (the only `TBoolExprPair` subclass) provides its own overrides. + // `BinaryExpr` (the only `TBoolExprPair` subclass) provides its own overrides. override string toString() { result = this.asExpr().toString() } override Py::Location getLocation() { result = this.asExpr().getLocation() } @@ -235,7 +230,7 @@ module Ast implements AstSig { ExprStmt() { exprStmt = this.asStmt() } /** Gets the expression in this expression statement. */ - Expr getExpr() { result = TExpr(exprStmt.getValue()) } + Expr getExpr() { result.asExpr() = exprStmt.getValue() } override AstNode getChild(int index) { index = 0 and result = this.getExpr() } } @@ -246,9 +241,9 @@ module Ast implements AstSig { AssignStmt() { assign = this.asStmt() } - Expr getValue() { result = TExpr(assign.getValue()) } + Expr getValue() { result.asExpr() = assign.getValue() } - Expr getTarget(int n) { result = TExpr(assign.getTarget(n)) } + Expr getTarget(int n) { result.asExpr() = assign.getTarget(n) } int getNumberOfTargets() { result = count(assign.getATarget()) } @@ -265,7 +260,7 @@ module Ast implements AstSig { AugAssignStmt() { augAssign = this.asStmt() } - Expr getOperation() { result = TExpr(augAssign.getOperation()) } + Expr getOperation() { result.asExpr() = augAssign.getOperation() } override AstNode getChild(int index) { index = 0 and result = this.getOperation() } } @@ -276,9 +271,9 @@ module Ast implements AstSig { NamedExpr() { assignExpr = this.asExpr() } - Expr getValue() { result = TExpr(assignExpr.getValue()) } + Expr getValue() { result.asExpr() = assignExpr.getValue() } - Expr getTarget() { result = TExpr(assignExpr.getTarget()) } + Expr getTarget() { result.asExpr() = assignExpr.getTarget() } override AstNode getChild(int index) { index = 0 and result = this.getValue() @@ -305,7 +300,7 @@ module Ast implements AstSig { Py::If asIf() { result = ifStmt } /** Gets the condition of this `if` statement. */ - Expr getCondition() { result = TExpr(ifStmt.getTest()) } + Expr getCondition() { result.asExpr() = ifStmt.getTest() } /** Gets the `then` (true) branch of this `if` statement. */ Stmt getThen() { result = TBlockStmt(ifStmt, "body") } @@ -337,7 +332,7 @@ module Ast implements AstSig { WhileStmt() { whileStmt = this.asStmt() } /** Gets the boolean condition of this `while` loop. */ - Expr getCondition() { result = TExpr(whileStmt.getTest()) } + Expr getCondition() { result.asExpr() = whileStmt.getTest() } override Stmt getBody() { result = TBlockStmt(whileStmt, "body") } @@ -380,10 +375,10 @@ module Ast implements AstSig { ForeachStmt() { forStmt = this.asStmt() } /** Gets the loop variable. */ - Expr getVariable() { result = TExpr(forStmt.getTarget()) } + Expr getVariable() { result.asExpr() = forStmt.getTarget() } /** Gets the collection being iterated. */ - Expr getCollection() { result = TExpr(forStmt.getIter()) } + Expr getCollection() { result.asExpr() = forStmt.getIter() } override Stmt getBody() { result = TBlockStmt(forStmt, "body") } @@ -423,7 +418,7 @@ module Ast implements AstSig { ReturnStmt() { ret = this.asStmt() } /** Gets the expression being returned, if any. */ - Expr getExpr() { result = TExpr(ret.getValue()) } + Expr getExpr() { result.asExpr() = ret.getValue() } override AstNode getChild(int index) { index = 0 and result = this.getExpr() } } @@ -435,10 +430,10 @@ module Ast implements AstSig { Throw() { raise = this.asStmt() } /** Gets the expression being raised. */ - Expr getExpr() { result = TExpr(raise.getException()) } + Expr getExpr() { result.asExpr() = raise.getException() } /** Gets the cause of this `raise`, if any. */ - Expr getCause() { result = TExpr(raise.getCause()) } + Expr getCause() { result.asExpr() = raise.getCause() } override AstNode getChild(int index) { index = 0 and result = this.getExpr() @@ -453,9 +448,9 @@ module Ast implements AstSig { WithStmt() { withStmt = this.asStmt() } - Expr getContextExpr() { result = TExpr(withStmt.getContextExpr()) } + Expr getContextExpr() { result.asExpr() = withStmt.getContextExpr() } - Expr getOptionalVars() { result = TExpr(withStmt.getOptionalVars()) } + Expr getOptionalVars() { result.asExpr() = withStmt.getOptionalVars() } Stmt getBody() { result = TBlockStmt(withStmt, "body") } @@ -474,9 +469,9 @@ module Ast implements AstSig { AssertStmt() { assertStmt = this.asStmt() } - Expr getTest() { result = TExpr(assertStmt.getTest()) } + Expr getTest() { result.asExpr() = assertStmt.getTest() } - Expr getMsg() { result = TExpr(assertStmt.getMsg()) } + Expr getMsg() { result.asExpr() = assertStmt.getMsg() } override AstNode getChild(int index) { index = 0 and result = this.getTest() @@ -491,7 +486,7 @@ module Ast implements AstSig { DeleteStmt() { del = this.asStmt() } - Expr getTarget(int n) { result = TExpr(del.getTarget(n)) } + Expr getTarget(int n) { result.asExpr() = del.getTarget(n) } override AstNode getChild(int index) { result = this.getTarget(index) } } @@ -544,10 +539,10 @@ module Ast implements AstSig { CatchClause() { handler = this.asStmt() } /** Gets the type expression of this exception handler. */ - Expr getType() { result = TExpr(handler.getType()) } + Expr getType() { result.asExpr() = handler.getType() } /** Gets the variable name of this exception handler, if any. */ - AstNode getVariable() { result = TExpr(handler.getName()) } + AstNode getVariable() { result.asExpr() = handler.getName() } /** Holds: catch clauses do not have a `Condition` in Python's model. */ Expr getCondition() { none() } @@ -574,7 +569,7 @@ module Ast implements AstSig { Switch() { matchStmt = this.asStmt() } - Expr getExpr() { result = TExpr(matchStmt.getSubject()) } + Expr getExpr() { result.asExpr() = matchStmt.getSubject() } Case getCase(int index) { result = TStmt(matchStmt.getCase(index)) } @@ -595,7 +590,7 @@ module Ast implements AstSig { AstNode getAPattern() { result = TPattern(caseStmt.getPattern()) } - Expr getGuard() { result = TExpr(caseStmt.getGuard().(Py::Guard).getTest()) } + Expr getGuard() { result.asExpr() = caseStmt.getGuard().(Py::Guard).getTest() } AstNode getBody() { result = TBlockStmt(caseStmt, "body") } @@ -623,13 +618,13 @@ module Ast implements AstSig { ConditionalExpr() { ifExp = this.asExpr() } /** Gets the condition of this expression. */ - Expr getCondition() { result = TExpr(ifExp.getTest()) } + Expr getCondition() { result.asExpr() = ifExp.getTest() } /** Gets the true branch of this expression. */ - Expr getThen() { result = TExpr(ifExp.getBody()) } + Expr getThen() { result.asExpr() = ifExp.getBody() } /** Gets the false branch of this expression. */ - Expr getElse() { result = TExpr(ifExp.getOrelse()) } + Expr getElse() { result.asExpr() = ifExp.getOrelse() } override AstNode getChild(int index) { index = 0 and result = this.getCondition() @@ -641,84 +636,51 @@ module Ast implements AstSig { } /** - * A binary expression for the shared CFG. In Python, this covers - * `and`/`or` expressions (both real 2-operand and synthetic pairs). + * A binary expression for the shared CFG. In Python, this covers all + * `and`/`or` expression operand pairs. */ - class BinaryExpr extends Expr { - BinaryExpr() { - exists(Py::BoolExpr be | this = TExpr(be) and count(be.getAValue()) >= 2) - or - this instanceof TBoolExprPair - } + class BinaryExpr extends Expr, TBoolExprPair { + private Py::BoolExpr be; + private int index; + + BinaryExpr() { this = TBoolExprPair(be, index) } + + /** Gets the underlying Python `BoolExpr`. */ + Py::BoolExpr getBoolExpr() { result = be } + + override string toString() { result = be.getOperator() } + + override Py::Location getLocation() { result = be.getValue(index).getLocation() } + + override Callable getEnclosingCallable() { result.asScope() = be.getScope() } /** Gets the left operand of this binary expression. */ - Expr getLeftOperand() { - exists(Py::BoolExpr be | this = TExpr(be) and result = TExpr(be.getValue(0))) - or - exists(Py::BoolExpr be, int i | - this = TBoolExprPair(be, i) and result = TExpr(be.getValue(i)) - ) - } + Expr getLeftOperand() { result.asExpr() = be.getValue(index) } /** Gets the right operand of this binary expression. */ Expr getRightOperand() { - // 2-operand BoolExpr: right operand is value(1). - exists(Py::BoolExpr be | - this = TExpr(be) and - count(be.getAValue()) = 2 and - result = TExpr(be.getValue(1)) - ) + // Last pair: right operand is the final value. + index = count(be.getAValue()) - 2 and result.asExpr() = be.getValue(index + 1) or - // 3+ operand BoolExpr (outermost): right operand is the synthetic - // pair at index 1. - exists(Py::BoolExpr be | - this = TExpr(be) and - count(be.getAValue()) > 2 and - result = TBoolExprPair(be, 1) - ) - or - // Last synthetic pair: right operand is the final value. - exists(Py::BoolExpr be, int i, int n | - this = TBoolExprPair(be, i) and - n = count(be.getAValue()) and - i = n - 2 and - result = TExpr(be.getValue(i + 1)) - ) - or - // Non-last synthetic pair: right operand is the next pair. - exists(Py::BoolExpr be, int i, int n | - this = TBoolExprPair(be, i) and - n = count(be.getAValue()) and - i < n - 2 and - result = TBoolExprPair(be, i + 1) - ) + // Non-last pair: right operand is the next synthetic pair. + index < count(be.getAValue()) - 2 and result = TBoolExprPair(be, index + 1) } - override AstNode getChild(int index) { - index = 0 and result = this.getLeftOperand() + override AstNode getChild(int childIndex) { + childIndex = 0 and result = this.getLeftOperand() or - index = 1 and result = this.getRightOperand() + childIndex = 1 and result = this.getRightOperand() } } /** A short-circuiting logical `and` expression. */ class LogicalAndExpr extends BinaryExpr { - LogicalAndExpr() { - exists(Py::BoolExpr be | - be.getOp() instanceof Py::And and - (this = TExpr(be) or this = TBoolExprPair(be, _)) - ) - } + LogicalAndExpr() { this.getBoolExpr().getOp() instanceof Py::And } } /** A short-circuiting logical `or` expression. */ class LogicalOrExpr extends BinaryExpr { - LogicalOrExpr() { - exists(Py::BoolExpr be | - be.getOp() instanceof Py::Or and - (this = TExpr(be) or this = TBoolExprPair(be, _)) - ) - } + LogicalOrExpr() { this.getBoolExpr().getOp() instanceof Py::Or } } /** A null-coalescing expression. Python has no null-coalescing operator. */ @@ -733,7 +695,7 @@ module Ast implements AstSig { UnaryExpr() { this.asExpr().(Py::UnaryExpr).getOp() instanceof Py::Not } /** Gets the operand of this unary expression. */ - Expr getOperand() { result = TExpr(this.asExpr().(Py::UnaryExpr).getOperand()) } + Expr getOperand() { result.asExpr() = this.asExpr().(Py::UnaryExpr).getOperand() } override AstNode getChild(int index) { index = 0 and result = this.getOperand() } } @@ -784,9 +746,9 @@ module Ast implements AstSig { ArithBinaryExpr() { binExpr = this.asExpr() } - Expr getLeft() { result = TExpr(binExpr.getLeft()) } + Expr getLeft() { result.asExpr() = binExpr.getLeft() } - Expr getRight() { result = TExpr(binExpr.getRight()) } + Expr getRight() { result.asExpr() = binExpr.getRight() } override AstNode getChild(int index) { index = 0 and result = this.getLeft() @@ -801,16 +763,16 @@ module Ast implements AstSig { CallExpr() { call = this.asExpr() } - Expr getFunc() { result = TExpr(call.getFunc()) } + Expr getFunc() { result.asExpr() = call.getFunc() } - Expr getPositionalArg(int n) { result = TExpr(call.getPositionalArg(n)) } + Expr getPositionalArg(int n) { result.asExpr() = call.getPositionalArg(n) } int getNumberOfPositionalArgs() { result = count(call.getAPositionalArg()) } Expr getKeywordValue(int n) { - result = TExpr(call.getNamedArg(n).(Py::Keyword).getValue()) + result.asExpr() = call.getNamedArg(n).(Py::Keyword).getValue() or - result = TExpr(call.getNamedArg(n).(Py::DictUnpacking).getValue()) + result.asExpr() = call.getNamedArg(n).(Py::DictUnpacking).getValue() } int getNumberOfNamedArgs() { result = count(call.getANamedArg()) } @@ -831,9 +793,9 @@ module Ast implements AstSig { SubscriptExpr() { sub = this.asExpr() } - Expr getObject() { result = TExpr(sub.getObject()) } + Expr getObject() { result.asExpr() = sub.getObject() } - Expr getIndex() { result = TExpr(sub.getIndex()) } + Expr getIndex() { result.asExpr() = sub.getIndex() } override AstNode getChild(int index) { index = 0 and result = this.getObject() @@ -848,7 +810,7 @@ module Ast implements AstSig { AttributeExpr() { attr = this.asExpr() } - Expr getObject() { result = TExpr(attr.getObject()) } + Expr getObject() { result.asExpr() = attr.getObject() } override AstNode getChild(int index) { index = 0 and result = this.getObject() } } @@ -859,7 +821,7 @@ module Ast implements AstSig { TupleExpr() { tuple = this.asExpr() } - Expr getElt(int n) { result = TExpr(tuple.getElt(n)) } + Expr getElt(int n) { result.asExpr() = tuple.getElt(n) } override AstNode getChild(int index) { result = this.getElt(index) } } @@ -870,7 +832,7 @@ module Ast implements AstSig { ListExpr() { list = this.asExpr() } - Expr getElt(int n) { result = TExpr(list.getElt(n)) } + Expr getElt(int n) { result.asExpr() = list.getElt(n) } override AstNode getChild(int index) { result = this.getElt(index) } } @@ -881,7 +843,7 @@ module Ast implements AstSig { SetExpr() { set = this.asExpr() } - Expr getElt(int n) { result = TExpr(set.getElt(n)) } + Expr getElt(int n) { result.asExpr() = set.getElt(n) } override AstNode getChild(int index) { result = this.getElt(index) } } @@ -896,9 +858,9 @@ module Ast implements AstSig { * Gets the key of the `n`th item (at child index `2*n`); the value is * at child index `2*n + 1`. */ - Expr getKey(int n) { result = TExpr(dict.getItem(n).(Py::KeyValuePair).getKey()) } + Expr getKey(int n) { result.asExpr() = dict.getItem(n).(Py::KeyValuePair).getKey() } - Expr getValue(int n) { result = TExpr(dict.getItem(n).(Py::KeyValuePair).getValue()) } + Expr getValue(int n) { result.asExpr() = dict.getItem(n).(Py::KeyValuePair).getValue() } int getNumberOfItems() { result = count(dict.getAnItem()) } @@ -917,7 +879,7 @@ module Ast implements AstSig { ArithUnaryExpr() { unaryExpr = this.asExpr() and not unaryExpr.getOp() instanceof Py::Not } - Expr getOperand() { result = TExpr(unaryExpr.getOperand()) } + Expr getOperand() { result.asExpr() = unaryExpr.getOperand() } override AstNode getChild(int index) { index = 0 and result = this.getOperand() } } @@ -940,7 +902,7 @@ module Ast implements AstSig { iterable = this.asExpr().(Py::GeneratorExp).getIterable() } - Expr getIterable() { result = TExpr(iterable) } + Expr getIterable() { result.asExpr() = iterable } override AstNode getChild(int index) { index = 0 and result = this.getIterable() } } @@ -951,9 +913,9 @@ module Ast implements AstSig { CompareExpr() { cmp = this.asExpr() } - Expr getLeft() { result = TExpr(cmp.getLeft()) } + Expr getLeft() { result.asExpr() = cmp.getLeft() } - Expr getComparator(int n) { result = TExpr(cmp.getComparator(n)) } + Expr getComparator(int n) { result.asExpr() = cmp.getComparator(n) } override AstNode getChild(int index) { index = 0 and result = this.getLeft() @@ -968,11 +930,11 @@ module Ast implements AstSig { SliceExpr() { slice = this.asExpr() } - Expr getStart() { result = TExpr(slice.getStart()) } + Expr getStart() { result.asExpr() = slice.getStart() } - Expr getStop() { result = TExpr(slice.getStop()) } + Expr getStop() { result.asExpr() = slice.getStop() } - Expr getStep() { result = TExpr(slice.getStep()) } + Expr getStep() { result.asExpr() = slice.getStep() } override AstNode getChild(int index) { index = 0 and result = this.getStart() @@ -989,7 +951,7 @@ module Ast implements AstSig { StarredExpr() { starred = this.asExpr() } - Expr getValue() { result = TExpr(starred.getValue()) } + Expr getValue() { result.asExpr() = starred.getValue() } override AstNode getChild(int index) { index = 0 and result = this.getValue() } } @@ -1000,7 +962,7 @@ module Ast implements AstSig { FstringExpr() { fstring = this.asExpr() } - Expr getValue(int n) { result = TExpr(fstring.getValue(n)) } + Expr getValue(int n) { result.asExpr() = fstring.getValue(n) } override AstNode getChild(int index) { result = this.getValue(index) } } @@ -1011,9 +973,9 @@ module Ast implements AstSig { FormattedValueExpr() { fv = this.asExpr() } - Expr getValue() { result = TExpr(fv.getValue()) } + Expr getValue() { result.asExpr() = fv.getValue() } - Expr getFormatSpec() { result = TExpr(fv.getFormatSpec()) } + Expr getFormatSpec() { result.asExpr() = fv.getFormatSpec() } override AstNode getChild(int index) { index = 0 and result = this.getValue() @@ -1028,7 +990,7 @@ module Ast implements AstSig { YieldExpr() { yield = this.asExpr() } - Expr getValue() { result = TExpr(yield.getValue()) } + Expr getValue() { result.asExpr() = yield.getValue() } override AstNode getChild(int index) { index = 0 and result = this.getValue() } } @@ -1039,7 +1001,7 @@ module Ast implements AstSig { YieldFromExpr() { yieldFrom = this.asExpr() } - Expr getValue() { result = TExpr(yieldFrom.getValue()) } + Expr getValue() { result.asExpr() = yieldFrom.getValue() } override AstNode getChild(int index) { index = 0 and result = this.getValue() } } @@ -1050,7 +1012,7 @@ module Ast implements AstSig { AwaitExpr() { await = this.asExpr() } - Expr getValue() { result = TExpr(await.getValue()) } + Expr getValue() { result.asExpr() = await.getValue() } override AstNode getChild(int index) { index = 0 and result = this.getValue() } } @@ -1061,7 +1023,7 @@ module Ast implements AstSig { ClassDefExpr() { classExpr = this.asExpr() } - Expr getBase(int n) { result = TExpr(classExpr.getBase(n)) } + Expr getBase(int n) { result.asExpr() = classExpr.getBase(n) } override AstNode getChild(int index) { result = this.getBase(index) } } @@ -1079,14 +1041,14 @@ module Ast implements AstSig { * renumber here to obtain contiguous indices. */ Expr getDefault(int n) { - result = - TExpr(rank[n + 1](Py::Expr d, int i | d = funcExpr.getArgs().getDefault(i) | d order by i)) + result.asExpr() = + rank[n + 1](Py::Expr d, int i | d = funcExpr.getArgs().getDefault(i) | d order by i) } /** Gets the `n`th default for a keyword-only argument, in evaluation order. */ Expr getKwDefault(int n) { - result = - TExpr(rank[n + 1](Py::Expr d, int i | d = funcExpr.getArgs().getKwDefault(i) | d order by i)) + result.asExpr() = + rank[n + 1](Py::Expr d, int i | d = funcExpr.getArgs().getKwDefault(i) | d order by i) } int getNumberOfDefaults() { result = count(funcExpr.getArgs().getADefault()) } @@ -1106,14 +1068,14 @@ module Ast implements AstSig { /** Gets the `n`th default for a positional argument, in evaluation order. */ Expr getDefault(int n) { - result = - TExpr(rank[n + 1](Py::Expr d, int i | d = lambda.getArgs().getDefault(i) | d order by i)) + result.asExpr() = + rank[n + 1](Py::Expr d, int i | d = lambda.getArgs().getDefault(i) | d order by i) } /** Gets the `n`th default for a keyword-only argument, in evaluation order. */ Expr getKwDefault(int n) { - result = - TExpr(rank[n + 1](Py::Expr d, int i | d = lambda.getArgs().getKwDefault(i) | d order by i)) + result.asExpr() = + rank[n + 1](Py::Expr d, int i | d = lambda.getArgs().getKwDefault(i) | d order by i) } int getNumberOfDefaults() { result = count(lambda.getArgs().getADefault()) } From 06e9bbc3a646708fa4b30e14b5dfb7b51b1707fd Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 7 May 2026 12:54:02 +0000 Subject: [PATCH 40/44] Python: index TBlockStmt by Py::StmtList instead of (parent, slot) Replace the two-key TBlockStmt(Py::AstNode parent, string slot) newtype branch with the simpler TBlockStmt(Py::StmtList sl). Each Py::StmtList that represents an imperative block (function/class/module body, if/ while/for branch, try/except/finally body, case body, except/except* body) becomes one BlockStmt directly. The slot string disappears; toString just defers to Py::StmtList.toString() ('StmtList'). The newtype branch keeps an explicit characteristic predicate listing the slots that count as block bodies. This excludes Try.getHandlers(), which is a Py::StmtList of ExceptStmt items already iterated by the shared library's Try logic via getCatch(int) - including it would produce parallel CFG edges (verified: a permissive TBlockStmt(Py::StmtList sl) version regressed CPython to 1720 multipleSuccessors and 584 deadEnds before this restriction). Drops the getBodyStmtList helper. Caller sites now use the StmtList accessor directly: TBlockStmt(ifStmt.getBody()), TBlockStmt(tryStmt.getFinalbody()), etc. No behaviour change: all 24 NewCfg evaluation-order tests pass; all 11 shared-CFG consistency queries report 0 violations on CPython. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../controlflow/internal/AstNodeImpl.qll | 124 ++++++++---------- 1 file changed, 58 insertions(+), 66 deletions(-) diff --git a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll index bcf8f4f64705..ccd363565721 100644 --- a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll +++ b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll @@ -17,42 +17,6 @@ private import codeql.util.Void /** Provides the Python implementation of the shared CFG `AstSig`. */ module Ast implements AstSig { - /** - * Maps a `(parent, slot)` pair to the `Py::StmtList` that holds the items - * of the `BlockStmt` for that slot. The slot string distinguishes between - * the multiple bodies that some parents have (e.g. `if` has `body` and - * `orelse`). - */ - private Py::StmtList getBodyStmtList(Py::AstNode parent, string slot) { - result = parent.(Py::Scope).getBody() and slot = "body" - or - result = parent.(Py::If).getBody() and slot = "body" - or - result = parent.(Py::If).getOrelse() and slot = "orelse" - or - result = parent.(Py::While).getBody() and slot = "body" - or - result = parent.(Py::While).getOrelse() and slot = "orelse" - or - result = parent.(Py::For).getBody() and slot = "body" - or - result = parent.(Py::For).getOrelse() and slot = "orelse" - or - result = parent.(Py::With).getBody() and slot = "body" - or - result = parent.(Py::Try).getBody() and slot = "body" - or - result = parent.(Py::Try).getOrelse() and slot = "orelse" - or - result = parent.(Py::Try).getFinalbody() and slot = "finally" - or - result = parent.(Py::Case).getBody() and slot = "body" - or - result = parent.(Py::ExceptStmt).getBody() and slot = "body" - or - result = parent.(Py::ExceptGroupStmt).getBody() and slot = "body" - } - private newtype TAstNode = TStmt(Py::Stmt s) or TExpr(Py::Expr e) { not e instanceof Py::BoolExpr } or @@ -68,11 +32,42 @@ module Ast implements AstSig { */ TBoolExprPair(Py::BoolExpr be, int index) { index = [0 .. count(be.getAValue()) - 2] } or /** - * A synthetic block statement, identifying one body slot of the - * `parent` AST node. The `slot` string disambiguates among multiple - * bodies of the same parent (`"body"`, `"orelse"`, `"finally"`). + * A synthetic block statement, wrapping a `Py::StmtList`. Each list of + * statements that represents an imperative block (a function/class/module + * body, an `if`/`while`/`for` branch, a `try`/`except`/`finally` body, + * etc.) becomes one `BlockStmt` node in the CFG. Lists used in other + * roles (e.g. `Try.getHandlers()`, which is iterated as catch clauses) + * are excluded. */ - TBlockStmt(Py::AstNode parent, string slot) { exists(getBodyStmtList(parent, slot)) } + TBlockStmt(Py::StmtList sl) { + sl = any(Py::Scope p).getBody() + or + sl = any(Py::If p).getBody() + or + sl = any(Py::If p).getOrelse() + or + sl = any(Py::While p).getBody() + or + sl = any(Py::While p).getOrelse() + or + sl = any(Py::For p).getBody() + or + sl = any(Py::For p).getOrelse() + or + sl = any(Py::With p).getBody() + or + sl = any(Py::Try p).getBody() + or + sl = any(Py::Try p).getOrelse() + or + sl = any(Py::Try p).getFinalbody() + or + sl = any(Py::Case p).getBody() + or + sl = any(Py::ExceptStmt p).getBody() + or + sl = any(Py::ExceptGroupStmt p).getBody() + } /** An AST node visible to the shared CFG. */ class AstNode extends TAstNode { @@ -135,7 +130,7 @@ module Ast implements AstSig { } /** Gets the body of callable `c`. */ - AstNode callableGetBody(Callable c) { result = TBlockStmt(c.asScope(), "body") } + AstNode callableGetBody(Callable c) { result = TBlockStmt(c.asScope().getBody()) } /** * A parameter of a callable. @@ -195,29 +190,26 @@ module Ast implements AstSig { * sequence of statements. */ class BlockStmt extends Stmt, TBlockStmt { - private Py::AstNode parent; - private string slot; + private Py::StmtList sl; - BlockStmt() { this = TBlockStmt(parent, slot) } + BlockStmt() { this = TBlockStmt(sl) } /** Gets the `n`th (zero-based) statement in this block. */ - Stmt getStmt(int n) { result = TStmt(getBodyStmtList(parent, slot).getItem(n)) } + Stmt getStmt(int n) { result.asStmt() = sl.getItem(n) } /** Gets the last statement in this block. */ - Stmt getLastStmt() { result = TStmt(getBodyStmtList(parent, slot).getLastItem()) } + Stmt getLastStmt() { result.asStmt() = sl.getLastItem() } - override string toString() { result = "block:" + slot } + override string toString() { result = sl.toString() } - // BlockStmt has no native location; approximate with the first + // `Py::StmtList` has no native location; approximate with the first // item's location. - override Py::Location getLocation() { - result = getBodyStmtList(parent, slot).getItem(0).getLocation() - } + override Py::Location getLocation() { result = sl.getItem(0).getLocation() } override Callable getEnclosingCallable() { - result.asScope() = parent.(Py::Scope) + result.asScope() = sl.getParent().(Py::Scope) or - result.asScope() = parent.(Py::Stmt).getScope() + result.asScope() = sl.getParent().(Py::Stmt).getScope() } override AstNode getChild(int index) { result = this.getStmt(index) } @@ -303,10 +295,10 @@ module Ast implements AstSig { Expr getCondition() { result.asExpr() = ifStmt.getTest() } /** Gets the `then` (true) branch of this `if` statement. */ - Stmt getThen() { result = TBlockStmt(ifStmt, "body") } + Stmt getThen() { result = TBlockStmt(ifStmt.getBody()) } /** Gets the `else` (false) branch, if any. */ - Stmt getElse() { result = TBlockStmt(ifStmt, "orelse") } + Stmt getElse() { result = TBlockStmt(ifStmt.getOrelse()) } override AstNode getChild(int index) { index = 0 and result = this.getCondition() @@ -334,10 +326,10 @@ module Ast implements AstSig { /** Gets the boolean condition of this `while` loop. */ Expr getCondition() { result.asExpr() = whileStmt.getTest() } - override Stmt getBody() { result = TBlockStmt(whileStmt, "body") } + override Stmt getBody() { result = TBlockStmt(whileStmt.getBody()) } /** Gets the `else` branch of this `while` loop, if any. */ - Stmt getElse() { result = TBlockStmt(whileStmt, "orelse") } + Stmt getElse() { result = TBlockStmt(whileStmt.getOrelse()) } override AstNode getChild(int index) { index = 0 and result = this.getCondition() @@ -380,10 +372,10 @@ module Ast implements AstSig { /** Gets the collection being iterated. */ Expr getCollection() { result.asExpr() = forStmt.getIter() } - override Stmt getBody() { result = TBlockStmt(forStmt, "body") } + override Stmt getBody() { result = TBlockStmt(forStmt.getBody()) } /** Gets the `else` branch of this `for` loop, if any. */ - Stmt getElse() { result = TBlockStmt(forStmt, "orelse") } + Stmt getElse() { result = TBlockStmt(forStmt.getOrelse()) } override AstNode getChild(int index) { index = 0 and result = this.getCollection() @@ -452,7 +444,7 @@ module Ast implements AstSig { Expr getOptionalVars() { result.asExpr() = withStmt.getOptionalVars() } - Stmt getBody() { result = TBlockStmt(withStmt, "body") } + Stmt getBody() { result = TBlockStmt(withStmt.getBody()) } override AstNode getChild(int index) { index = 0 and result = this.getContextExpr() @@ -497,12 +489,12 @@ module Ast implements AstSig { TryStmt() { tryStmt = this.asStmt() } - Stmt getBody() { result = TBlockStmt(tryStmt, "body") } + Stmt getBody() { result = TBlockStmt(tryStmt.getBody()) } /** Gets the `else` branch of this `try` statement, if any. */ - Stmt getElse() { result = TBlockStmt(tryStmt, "orelse") } + Stmt getElse() { result = TBlockStmt(tryStmt.getOrelse()) } - Stmt getFinally() { result = TBlockStmt(tryStmt, "finally") } + Stmt getFinally() { result = TBlockStmt(tryStmt.getFinalbody()) } CatchClause getCatch(int index) { result = TStmt(tryStmt.getHandler(index)) } @@ -549,9 +541,9 @@ module Ast implements AstSig { /** Gets the body of this exception handler. */ Stmt getBody() { - result = TBlockStmt(handler.(Py::ExceptStmt), "body") + result = TBlockStmt(handler.(Py::ExceptStmt).getBody()) or - result = TBlockStmt(handler.(Py::ExceptGroupStmt), "body") + result = TBlockStmt(handler.(Py::ExceptGroupStmt).getBody()) } override AstNode getChild(int index) { @@ -592,7 +584,7 @@ module Ast implements AstSig { Expr getGuard() { result.asExpr() = caseStmt.getGuard().(Py::Guard).getTest() } - AstNode getBody() { result = TBlockStmt(caseStmt, "body") } + AstNode getBody() { result = TBlockStmt(caseStmt.getBody()) } /** Holds if this case is a wildcard pattern (`case _:`). */ predicate isWildcard() { caseStmt.getPattern() instanceof Py::MatchWildcardPattern } From 00c742f5ff3563d1d3eeaeff979234257b210bc6 Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 7 May 2026 14:14:16 +0000 Subject: [PATCH 41/44] Python: document why Assignment subclasses are empty Explain that the shared library's Assignment / CompoundAssignment hierarchy extends BinaryExpr, so it cannot host Python's statement- level assignment forms (Assign, AugAssign), and that Python has no short-circuiting compound operators (&&=, ||=, ??=) so all subclasses remain empty. No behaviour change; doc comments only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../controlflow/internal/AstNodeImpl.qll | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll index ccd363565721..3fa8adbe6b2b 100644 --- a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll +++ b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll @@ -695,19 +695,41 @@ module Ast implements AstSig { /** A logical `not` expression. */ class LogicalNotExpr extends UnaryExpr { } - /** An assignment expression. Python's walrus is modelled separately. */ + /** + * An assignment expression. + * + * Empty in Python: `x = y` and `x += y` are statements (`AssignStmt` and + * `AugAssignStmt`), not expressions, and the walrus `x := y` is modeled + * separately as `NamedExpr`. The shared library's `Assignment` extends + * `BinaryExpr`, so it cannot share instances with our `Stmt`-based + * assignment forms. + */ class Assignment extends BinaryExpr { Assignment() { none() } } + /** A simple assignment expression. Empty in Python (see `Assignment`). */ class AssignExpr extends Assignment { } + /** A compound assignment expression. Empty in Python (see `Assignment`). */ class CompoundAssignment extends Assignment { } + /** + * A short-circuiting logical AND compound assignment expression (`&&=`). + * Python has no such operator. + */ class AssignLogicalAndExpr extends CompoundAssignment { } + /** + * A short-circuiting logical OR compound assignment expression (`||=`). + * Python has no such operator. + */ class AssignLogicalOrExpr extends CompoundAssignment { } + /** + * A short-circuiting null-coalescing compound assignment expression + * (`??=`). Python has no such operator. + */ class AssignNullCoalescingExpr extends CompoundAssignment { } /** A boolean literal expression (`True` or `False`). */ From ed1709eb4a092b96ffa8e32d68a32b12f4bc262d Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 7 May 2026 16:58:43 +0000 Subject: [PATCH 42/44] Python: use private-abstract + final-alias pattern for AstNode Convert AstNode from a concrete class with empty default predicates into a private abstract class plus a final alias, matching the pattern used in cpp/.../EdgeKind.qll and cpp/.../IRVariable.qll: abstract private class AstNodeImpl extends TAstNode { abstract string toString(); abstract Py::Location getLocation(); abstract Callable getEnclosingCallable(); ... } final class AstNode = AstNodeImpl; This makes the compiler enforce that every concrete subclass implements toString/getLocation/getEnclosingCallable, replacing the brittle 'empty default + per-branch override' arrangement. Sister classes inside the module now extend AstNodeImpl instead of AstNode (which is final and cannot be extended). The empty Parameter stub gains explicit none() overrides for the three abstract members, since QL requires them statically even when the class has no instances. No behaviour change: all 24 NewCfg evaluation-order tests pass; all 11 shared-CFG consistency queries report 0 violations on CPython. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../controlflow/internal/AstNodeImpl.qll | 37 ++++++++++++++----- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll index 3fa8adbe6b2b..a0fb1c7bf727 100644 --- a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll +++ b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll @@ -69,16 +69,24 @@ module Ast implements AstSig { sl = any(Py::ExceptGroupStmt p).getBody() } - /** An AST node visible to the shared CFG. */ - class AstNode extends TAstNode { + /** + * An AST node visible to the shared CFG. + * + * This is the abstract implementation class. It enforces that each + * concrete subclass provides `toString`, `getLocation`, and + * `getEnclosingCallable` (one subclass per `TAstNode` newtype branch). + * The public alias `AstNode` is what users (and the `AstSig` signature) + * see; subclasses inside this module extend `AstNodeImpl` directly. + */ + abstract private class AstNodeImpl extends TAstNode { /** Gets a textual representation of this AST node. */ - string toString() { none() } + abstract string toString(); /** Gets the location of this AST node. */ - Py::Location getLocation() { none() } + abstract Py::Location getLocation(); /** Gets the enclosing callable that contains this node, if any. */ - Callable getEnclosingCallable() { none() } + abstract Callable getEnclosingCallable(); /** Gets the underlying Python `Stmt`, if this node wraps one. */ Py::Stmt asStmt() { this = TStmt(result) } @@ -109,6 +117,9 @@ module Ast implements AstSig { AstNode getChild(int index) { none() } } + /** An AST node visible to the shared CFG. */ + final class AstNode = AstNodeImpl; + /** Gets the immediately enclosing callable that contains `node`. */ Callable getEnclosingCallable(AstNode node) { result = node.getEnclosingCallable() } @@ -117,7 +128,7 @@ module Ast implements AstSig { * * In Python, all three are executable scopes with statement bodies. */ - class Callable extends AstNode, TScope { + class Callable extends AstNodeImpl, TScope { private Py::Scope sc; Callable() { this = TScope(sc) } @@ -137,9 +148,15 @@ module Ast implements AstSig { * * TODO: Implement in order to include parameters in the CFG. */ - class Parameter extends AstNode { + class Parameter extends AstNodeImpl { Parameter() { none() } + override string toString() { none() } + + override Py::Location getLocation() { none() } + + override Callable getEnclosingCallable() { none() } + Expr getDefaultValue() { none() } } @@ -147,7 +164,7 @@ module Ast implements AstSig { Parameter callableGetParameter(Callable c, int index) { none() } /** A statement. */ - class Stmt extends AstNode { + class Stmt extends AstNodeImpl { Stmt() { this instanceof TStmt or this instanceof TBlockStmt } // For `TStmt` instances, delegate to the wrapped Python statement. @@ -160,7 +177,7 @@ module Ast implements AstSig { } /** An expression. */ - class Expr extends AstNode { + class Expr extends AstNodeImpl { Expr() { this instanceof TExpr or this instanceof TBoolExprPair } // For `TExpr` instances, delegate to the wrapped Python expression. @@ -173,7 +190,7 @@ module Ast implements AstSig { } /** A pattern in a `match` statement. */ - additional class Pattern extends AstNode, TPattern { + additional class Pattern extends AstNodeImpl, TPattern { private Py::Pattern p; Pattern() { this = TPattern(p) } From c9445f74c2d598bf5430209977b1a064b22f9be5 Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 7 May 2026 17:08:10 +0000 Subject: [PATCH 43/44] Python: introduce TStmt union via newtype-branch alias Rename the TStmt newtype branch to TPyStmt, and add a private union type alias private class TStmt = TPyStmt or TBlockStmt; This lets the public Stmt class use TStmt directly in its extends clause: class Stmt extends AstNodeImpl, TStmt { ... } instead of the previous class Stmt extends AstNodeImpl { Stmt() { this instanceof TStmt or this instanceof TBlockStmt } ... } The same pattern is used in cpp/.../TInstruction.qll and rust/.../Synth.qll. No behaviour change: all 24 NewCfg evaluation-order tests pass; all 11 shared-CFG consistency queries report 0 violations on CPython. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../controlflow/internal/AstNodeImpl.qll | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll index a0fb1c7bf727..a4ea95755659 100644 --- a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll +++ b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll @@ -18,7 +18,7 @@ private import codeql.util.Void /** Provides the Python implementation of the shared CFG `AstSig`. */ module Ast implements AstSig { private newtype TAstNode = - TStmt(Py::Stmt s) or + TPyStmt(Py::Stmt s) or TExpr(Py::Expr e) { not e instanceof Py::BoolExpr } or TScope(Py::Scope sc) or TPattern(Py::Pattern p) or @@ -69,6 +69,13 @@ module Ast implements AstSig { sl = any(Py::ExceptGroupStmt p).getBody() } + /** + * The union of `TPyStmt` (wrapping `Py::Stmt`) and `TBlockStmt` (wrapping + * `Py::StmtList`). Both represent the kinds of node that can appear in + * a `Stmt` position in the CFG. + */ + private class TStmt = TPyStmt or TBlockStmt; + /** * An AST node visible to the shared CFG. * @@ -89,7 +96,7 @@ module Ast implements AstSig { abstract Callable getEnclosingCallable(); /** Gets the underlying Python `Stmt`, if this node wraps one. */ - Py::Stmt asStmt() { this = TStmt(result) } + Py::Stmt asStmt() { this = TPyStmt(result) } /** * Gets the underlying Python `Expr`, if this node wraps one. Boolean @@ -164,10 +171,8 @@ module Ast implements AstSig { Parameter callableGetParameter(Callable c, int index) { none() } /** A statement. */ - class Stmt extends AstNodeImpl { - Stmt() { this instanceof TStmt or this instanceof TBlockStmt } - - // For `TStmt` instances, delegate to the wrapped Python statement. + class Stmt extends AstNodeImpl, TStmt { + // For `TPyStmt` instances, delegate to the wrapped Python statement. // `BlockStmt` (the only `TBlockStmt` subclass) provides its own overrides. override string toString() { result = this.asStmt().toString() } @@ -513,7 +518,7 @@ module Ast implements AstSig { Stmt getFinally() { result = TBlockStmt(tryStmt.getFinalbody()) } - CatchClause getCatch(int index) { result = TStmt(tryStmt.getHandler(index)) } + CatchClause getCatch(int index) { result = TPyStmt(tryStmt.getHandler(index)) } override AstNode getChild(int index) { index = 0 and result = this.getBody() @@ -580,7 +585,7 @@ module Ast implements AstSig { Expr getExpr() { result.asExpr() = matchStmt.getSubject() } - Case getCase(int index) { result = TStmt(matchStmt.getCase(index)) } + Case getCase(int index) { result = TPyStmt(matchStmt.getCase(index)) } Stmt getStmt(int index) { none() } From b682877968e0957a848579ef2226ef071d7bfe16 Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 7 May 2026 17:13:50 +0000 Subject: [PATCH 44/44] Python: simplify TBlockStmt char pred via exclusion list Replace the 14-disjunct allow-list with a 2-conjunct exclusion list. Of the 17 Py::StmtList getters in AstGenerated.qll, only Try.getHandlers() and MatchStmt.getCases() should not be wrapped as BlockStmts (they are iterated individually by the shared library's Try/Switch logic via getCatch(int) and getCase(int)). All other StmtLists are imperative block bodies. No behaviour change: all 24 NewCfg evaluation-order tests pass; all 11 shared-CFG consistency queries report 0 violations on CPython. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../controlflow/internal/AstNodeImpl.qll | 37 ++++--------------- 1 file changed, 7 insertions(+), 30 deletions(-) diff --git a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll index a4ea95755659..2c43802948dd 100644 --- a/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll +++ b/python/ql/lib/semmle/python/controlflow/internal/AstNodeImpl.qll @@ -35,38 +35,15 @@ module Ast implements AstSig { * A synthetic block statement, wrapping a `Py::StmtList`. Each list of * statements that represents an imperative block (a function/class/module * body, an `if`/`while`/`for` branch, a `try`/`except`/`finally` body, - * etc.) becomes one `BlockStmt` node in the CFG. Lists used in other - * roles (e.g. `Try.getHandlers()`, which is iterated as catch clauses) - * are excluded. + * etc.) becomes one `BlockStmt` node in the CFG. `Py::StmtList`s used + * in other roles - `Try.getHandlers()` (iterated via `getCatch`) and + * `MatchStmt.getCases()` (iterated via `getCase`) - are excluded, as + * the shared library's `Try`/`Switch` logic walks their items + * individually. */ TBlockStmt(Py::StmtList sl) { - sl = any(Py::Scope p).getBody() - or - sl = any(Py::If p).getBody() - or - sl = any(Py::If p).getOrelse() - or - sl = any(Py::While p).getBody() - or - sl = any(Py::While p).getOrelse() - or - sl = any(Py::For p).getBody() - or - sl = any(Py::For p).getOrelse() - or - sl = any(Py::With p).getBody() - or - sl = any(Py::Try p).getBody() - or - sl = any(Py::Try p).getOrelse() - or - sl = any(Py::Try p).getFinalbody() - or - sl = any(Py::Case p).getBody() - or - sl = any(Py::ExceptStmt p).getBody() - or - sl = any(Py::ExceptGroupStmt p).getBody() + not sl = any(Py::Try t).getHandlers() and + not sl = any(Py::MatchStmt m).getCases() } /**