From b46d74df0f6d0d0022eae79597e4639fd50dee7f Mon Sep 17 00:00:00 2001 From: Taus Date: Thu, 16 Apr 2026 14:38:34 +0000 Subject: [PATCH 01/26] 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 3a4cd5a9cfd795f6c46d69e61f18cde863983516 Mon Sep 17 00:00:00 2001 From: Taus Date: Thu, 16 Apr 2026 15:59:21 +0000 Subject: [PATCH 02/26] 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 2f1c2df23919c35fbd5225f9e04592650732dfd5 Mon Sep 17 00:00:00 2001 From: Taus Date: Thu, 16 Apr 2026 16:10:03 +0000 Subject: [PATCH 03/26] 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 e7a7f5b5d42716237cf48af2123b1b1b148f19b0 Mon Sep 17 00:00:00 2001 From: Taus Date: Thu, 16 Apr 2026 16:12:31 +0000 Subject: [PATCH 04/26] 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 9f1c5e54897c7db36c4f8de7d2ae594b177b63a1 Mon Sep 17 00:00:00 2001 From: Taus Date: Thu, 16 Apr 2026 16:14:56 +0000 Subject: [PATCH 05/26] 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 60bde08f529f6e181655548dc5759469e0119128 Mon Sep 17 00:00:00 2001 From: Taus Date: Mon, 20 Apr 2026 16:12:25 +0000 Subject: [PATCH 06/26] 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 e3867e856054feab749f7a30232959100adc75fc Mon Sep 17 00:00:00 2001 From: Taus Date: Thu, 16 Apr 2026 14:31:51 +0000 Subject: [PATCH 07/26] 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 8d8597ea9158..00dde24e2118 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 b960131833a812c69913f9915ffa15d497502b08 Mon Sep 17 00:00:00 2001 From: Taus Date: Mon, 20 Apr 2026 11:34:05 +0000 Subject: [PATCH 08/26] 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 abf8f85b1e0fb7d405a47bfd872c63931eea9206 Mon Sep 17 00:00:00 2001 From: Taus Date: Mon, 20 Apr 2026 11:50:51 +0000 Subject: [PATCH 09/26] 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 e398f14f33d0e406ad1161b5601c21061bbd5a52 Mon Sep 17 00:00:00 2001 From: Taus Date: Mon, 20 Apr 2026 16:13:44 +0000 Subject: [PATCH 10/26] 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 3bcd5a1826763e0a77f3e3ed67bc1c7e84fca92d Mon Sep 17 00:00:00 2001 From: Taus Date: Tue, 21 Apr 2026 13:54:26 +0000 Subject: [PATCH 11/26] 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 87ad9d99da91bf9b5b55798c8c2c0f71e0a4ede3 Mon Sep 17 00:00:00 2001 From: Taus Date: Tue, 21 Apr 2026 13:55:57 +0000 Subject: [PATCH 12/26] 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 5e0cf89a6b75205a95c3254d8baf523e650342e0 Mon Sep 17 00:00:00 2001 From: Taus Date: Tue, 21 Apr 2026 14:28:04 +0000 Subject: [PATCH 13/26] 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 efdd5511bcaac70d3675b5cf2b77714bead4c2bf Mon Sep 17 00:00:00 2001 From: Taus Date: Tue, 21 Apr 2026 14:40:24 +0000 Subject: [PATCH 14/26] 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 d8024ec74b30720ba7a218c9cfbc2be09c859687 Mon Sep 17 00:00:00 2001 From: Taus Date: Tue, 21 Apr 2026 14:45:21 +0000 Subject: [PATCH 15/26] 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 ae9b73be60e713692533720dfb81edb476bffd65 Mon Sep 17 00:00:00 2001 From: Taus Date: Tue, 21 Apr 2026 14:49:13 +0000 Subject: [PATCH 16/26] 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 e780bbec4a494ca7eb0c91f6550471e03b8e81ff Mon Sep 17 00:00:00 2001 From: Taus Date: Tue, 21 Apr 2026 14:53:26 +0000 Subject: [PATCH 17/26] 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 5b738b35e42adcf49c0923886b76786865cb09eb Mon Sep 17 00:00:00 2001 From: Taus Date: Tue, 21 Apr 2026 15:02:11 +0000 Subject: [PATCH 18/26] 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 a22dc14d6c16ce60148e36d710688a5e5cc1e568 Mon Sep 17 00:00:00 2001 From: Taus Date: Tue, 21 Apr 2026 15:05:48 +0000 Subject: [PATCH 19/26] 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 afb0a143691aa070d0fe8e19e30bc4250189af9b Mon Sep 17 00:00:00 2001 From: Taus Date: Tue, 21 Apr 2026 15:09:40 +0000 Subject: [PATCH 20/26] 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 f9a05adceb364d59869f5392ae13fa3cceb930c1 Mon Sep 17 00:00:00 2001 From: Taus Date: Tue, 21 Apr 2026 15:18:27 +0000 Subject: [PATCH 21/26] 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 df8fa3a926811f4a400eb8244de7ec1b727ae220 Mon Sep 17 00:00:00 2001 From: Taus Date: Tue, 21 Apr 2026 15:28:11 +0000 Subject: [PATCH 22/26] 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 ee870a250cf2ac08ccea9d068ce0b51c217f5051 Mon Sep 17 00:00:00 2001 From: Taus Date: Tue, 21 Apr 2026 15:36:54 +0000 Subject: [PATCH 23/26] 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 b753cfbb97b64ad8a2c925eb226e06c89ff15e17 Mon Sep 17 00:00:00 2001 From: Taus Date: Tue, 21 Apr 2026 16:00:46 +0000 Subject: [PATCH 24/26] 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 5a9f584934548e386d9ea0951787fda09b6433bf Mon Sep 17 00:00:00 2001 From: Taus Date: Tue, 21 Apr 2026 16:03:38 +0000 Subject: [PATCH 25/26] 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 f4de471394851607c0284aab82653944a8535ac2 Mon Sep 17 00:00:00 2001 From: Taus Date: Tue, 21 Apr 2026 16:19:00 +0000 Subject: [PATCH 26/26] 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*() ) }