Skip to content

Commit 641d967

Browse files
ssomayyajulathanhnguyen-awskeyboardDrummer-botaqjune-awsjoehendrix
authored
Python -> Laurel translation: unmodeled method calls to havoc non-receiver arguments too (strata-org#1019)
*Description of changes:* Havocs locals passed as arguments to unmodeled calls. The call is a black box that could have side effects on its arguments, and (non-composite) locals are not reachable via heap havoc + havoc'ing receiver. Adds a test case (xs2.unknown_method_that_may_modify_arguments(ys)) to test_havoc_callee_after_hole_call exercising the new behavior. By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice. --------- Co-authored-by: thanhnguyen-aws <ntson@amazon.com> Co-authored-by: keyboardDrummer-bot <keyboardDrummer-bot@users.noreply.github.com> Co-authored-by: Juneyoung Lee <136006969+aqjune-aws@users.noreply.github.com> Co-authored-by: Joe Hendrix <joehx@amazon.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: keyboardDrummer-bot <keyboarddrummer.bot@gmail.com> Co-authored-by: olivier-aws <obouisso@amazon.com> Co-authored-by: Robin Salkeld <salkeldr@amazon.com>
1 parent 564634a commit 641d967

6 files changed

Lines changed: 103 additions & 6 deletions

File tree

Strata/Languages/Laurel/LaurelToCoreTranslator.lean

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,9 @@ def translateStmt (stmt : StmtExprMd)
430430
| .InstanceCall .. =>
431431
-- Instance method call: havoc the target variable
432432
return [Core.Statement.havoc ident md]
433+
| .Hole _ _ =>
434+
-- Hole RHS: havoc the target (unmodeled call side-effect).
435+
return [Core.Statement.havoc ident md]
433436
| _ =>
434437
let coreExpr ← translateExpr value
435438
return [Core.Statement.set ident coreExpr md]
@@ -504,6 +507,10 @@ def translateStmt (stmt : StmtExprMd)
504507
return [Imperative.Stmt.loop (.det condExpr) decreasingExprCore invExprs bodyStmts md]
505508
| .Exit target =>
506509
return [Imperative.Stmt.exit (some target) md]
510+
| .Hole _ _ =>
511+
-- Hole in statement position: treat as havoc (no-op).
512+
-- This can occur when an unmodeled call's Block is flattened.
513+
return []
507514
| _ =>
508515
-- Expression in statement position: preserve as an unused variable init
509516
exprAsUnusedInit stmt md

Strata/Languages/Python/PythonToLaurel.lean

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1156,7 +1156,35 @@ partial def translateCall (ctx : TranslationContext)
11561156
| .Attribute range _ attr _ => (attr.val, range)
11571157
| _ => (funcName, .none)
11581158
throwUserError range s!"Unknown method '{methodName}'"
1159-
return mkStmtExprMd .Hole
1159+
-- Havoc the receiver and Any-typed arguments since the unmodeled call
1160+
-- may mutate them and value-typed locals are not reachable via heap havoc.
1161+
-- Note: composite-typed arguments are NOT havoc'd here. If the unmodeled
1162+
-- call mutates a composite's fields, the heap should be havoc'd, but that
1163+
-- requires coordination with HeapParameterization and is out of scope.
1164+
let receiverHavoc := match f with
1165+
| .Attribute _ (.Name _ receiverName _) _ _ =>
1166+
if receiverName.val ∈ ctx.variableTypes.unzip.1 then
1167+
[mkStmtExprMd (StmtExpr.Assign
1168+
[mkStmtExprMd (StmtExpr.Identifier receiverName.val)]
1169+
(mkStmtExprMd .Hole))]
1170+
else []
1171+
| _ => []
1172+
let argHavoc := args.flatMap fun arg =>
1173+
if let .Name _ n _ := arg then
1174+
match ctx.variableTypes.find? (λ v => Prod.fst v == n.val) with
1175+
| some (varName, ty) =>
1176+
if ty == PyLauType.Any then
1177+
[mkStmtExprMd (StmtExpr.Assign
1178+
[mkStmtExprMd (StmtExpr.Identifier varName)]
1179+
(mkStmtExprMd (.Hole false none)))]
1180+
else []
1181+
| _ => []
1182+
else []
1183+
let havocStmts := receiverHavoc ++ argHavoc
1184+
if havocStmts.isEmpty then
1185+
return mkStmtExprMd .Hole
1186+
else
1187+
return mkStmtExprMd (.Block (havocStmts ++ [mkStmtExprMd .Hole]) none)
11601188
-- Step 3: translate the resolved call
11611189
let methodName := match f with
11621190
| .Attribute _ _ attr _ => attr.val
@@ -1737,7 +1765,9 @@ partial def translateStmt (ctx : TranslationContext) (s : Python.stmt SourceRang
17371765
| _ => return (ctx, exceptionCheck ++ [expr])
17381766
-- Unmodeled call: skip exception checks (no model to check against),
17391767
-- but havoc maybe_except since the call could throw.
1768+
-- Unmodeled call: havoc is now handled in translateCall via Block.
17401769
| .Hole => return (ctx, [expr] ++ holeExceptHavoc)
1770+
| .Block _ _ => return (ctx, [expr] ++ holeExceptHavoc)
17411771
| _ => return (ctx, exceptionCheck ++ [expr])
17421772

17431773
| .Import _ _ | .ImportFrom _ _ _ _ |.Pass _ => return (ctx, [])
@@ -1838,8 +1868,20 @@ partial def translateStmt (ctx : TranslationContext) (s : Python.stmt SourceRang
18381868
-- and Any_iter_index is only called inside the loop body where that condition is satisfied,
18391869
-- so it is sound to not put it inside AnyMaybeExceptionList
18401870
| .For _ target iter body _orelse _ => do
1841-
-- The iterator expression (we abstract it away)
1842-
let iterExpr ← translateExpr ctx iter
1871+
-- The iterator expression (we abstract it away).
1872+
-- When the expression contains side-effect statements (e.g. a block with
1873+
-- receiver havoc from an unmodeled method call), bind it to a temporary
1874+
-- variable so the side effects execute once and the clean variable
1875+
-- reference can be used in PIn / Any_len / the while condition.
1876+
-- This mirrors Python semantics where the iterator is evaluated once.
1877+
let iterRaw ← translateExpr ctx iter
1878+
let (iterPreamble, iterExpr) := match iterRaw.val with
1879+
| .Block (_ :: _ :: _) _ =>
1880+
let varName := s!"$for_iter_{iter.toAst.ann.start.byteIdx}"
1881+
let varDecl := mkStmtExprMd (StmtExpr.LocalVariable varName AnyTy (some iterRaw))
1882+
let varRef := mkStmtExprMd (StmtExpr.Identifier varName)
1883+
([varDecl], varRef)
1884+
| _ => ([], iterRaw)
18431885
if let .Call _ (.Name _ {val:= "range",..} _) _ _ := iter then
18441886
if let .StaticCall "range" _ := iterExpr.val then
18451887
pure ()
@@ -1897,7 +1939,7 @@ partial def translateStmt (ctx : TranslationContext) (s : Python.stmt SourceRang
18971939
let loopStmt := mkStmtExprMdWithLoc (StmtExpr.While counterLtLen [] none innerBlock) md
18981940
let loopBlock := mkStmtExprMdWithLoc (StmtExpr.Block [loopStmt] (some breakLabel)) md
18991941
let (preamble, _) := getExceptionCheckPreamble ctx iterExpr s!"$for_iter_{iter.toAst.ann.start.byteIdx}"
1900-
return (finalCtx, preamble ++ [counterDecl] ++ [loopBlock])
1942+
return (finalCtx, iterPreamble ++ preamble ++ [counterDecl] ++ [loopBlock])
19011943

19021944
| .Break _ =>
19031945
match ctx.loopBreakLabel with
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
\[ERROR\] procedure '__main__': expected 1 arguments, got 0
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
test_havoc_callee_after_hole_call.py(8, 0): ❓ unknown - expected unknown because xs should be havocked
2+
test_havoc_callee_after_hole_call.py(12, 0): ❓ unknown - expected unknown because xs should be havocked
3+
test_havoc_callee_after_hole_call.py(16, 0): ✔️ always true if reached - chained call: receiver not havocked (chained attribute is not a Name)
4+
test_havoc_callee_after_hole_call.py(20, 0): ✔️ always true if reached - unrelated variable: nothing should be havocked
5+
test_havoc_callee_after_hole_call.py(25, 0): ✔️ always true if reached - composite arg: heap not havocked (out of scope)
6+
test_havoc_callee_after_hole_call.py(30, 0): ❓ unknown - expected unknown because argument locals should be havocked
7+
test_havoc_callee_after_hole_call.py(36, 0): ❓ unknown - assume_assume(1193)_calls_PIn_0
8+
test_havoc_callee_after_hole_call.py(37, 4): ✔️ always true if reached - for-loop over unmodeled iterator should not crash
9+
DETAIL: 4 passed, 0 failed, 4 inconclusive
10+
RESULT: Inconclusive

StrataTest/Languages/Python/expected_laurel/test_missing_models.expected

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
test_missing_models.py(12, 4): ❓ unknown (pass on 2 paths, unknown on 2 paths) - (Origin_test_helper_procedure_Requires)req_name_is_foo
1+
test_missing_models.py(12, 4): ❓ unknown (pass on 2 paths, unknown on 1 path) - (Origin_test_helper_procedure_Requires)req_name_is_foo
22
test_missing_models.py(12, 4): ✅ pass - (Origin_test_helper_procedure_Requires)req_opt_name_none_or_str
33
test_missing_models.py(12, 4): ✅ pass - (Origin_test_helper_procedure_Requires)req_opt_name_none_or_bar
4-
test_missing_models.py(15, 4): ❓ unknown (pass on 2 paths, unknown on 2 paths) - (Origin_test_helper_procedure_Requires)req_name_is_foo
4+
test_missing_models.py(15, 4): ❓ unknown (pass on 2 paths, unknown on 1 path) - (Origin_test_helper_procedure_Requires)req_name_is_foo
55
test_missing_models.py(15, 4): ✅ pass - (Origin_test_helper_procedure_Requires)req_opt_name_none_or_str
66
test_missing_models.py(15, 4): ✅ pass - (Origin_test_helper_procedure_Requires)req_opt_name_none_or_bar
77
test_missing_models.py(8, 4): ❓ unknown - init_calls_Any_get_0
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# strata-args: --check-mode bugFinding --check-level full
2+
class MyClass:
3+
def __init__(self, n: int):
4+
self.val : int = n
5+
6+
xs: list[int] = [1, 2]
7+
xs.some_unmodeled_call_1(3)
8+
assert xs == [1, 2], "expected unknown because xs should be havocked"
9+
10+
xs = [1,2]
11+
ys: list[int] = xs.some_unmodeled_call_2()
12+
assert xs == [1, 2], "expected unknown because xs should be havocked"
13+
14+
xs = [1,2]
15+
xs.some_unmodeled_call_3.some_unmodeled_call_4()
16+
assert xs == [1, 2], "chained call: receiver not havocked (chained attribute is not a Name)"
17+
18+
xs = [1,2]
19+
some_function().some_unmodeled_call_5()
20+
assert xs == [1, 2], "unrelated variable: nothing should be havocked"
21+
22+
a : MyClass = MyClass(2)
23+
a.val = 1
24+
some_unmodeled_call_6(a)
25+
assert a.val == 1, "composite arg: heap not havocked (out of scope)"
26+
27+
xs2: list[int] = [1, 2]
28+
ys2: list[int] = []
29+
xs2.unknown_method_that_may_modify_arguments(ys2)
30+
assert ys2 == [], "expected unknown because argument locals should be havocked"
31+
32+
# Regression: unmodeled method call as for-loop iterator.
33+
# The receiver havoc must be lifted out of the iterator expression so
34+
# the block does not end up in expression position inside the assume.
35+
response: dict = {"messages": []}
36+
for msg in response.unmodeled_iter_method():
37+
assert True, "for-loop over unmodeled iterator should not crash"

0 commit comments

Comments
 (0)