From 398f21cad262cd66f780ce16437393c9ea23aeb9 Mon Sep 17 00:00:00 2001 From: Francois Chagnon Date: Tue, 13 Oct 2026 15:35:45 -0400 Subject: [PATCH 1/2] Keep guarded queries inside a bounded else suite --- src/decompiler.rs | 20 ++++++++ tests/fixtures/guarded_else_merge.py | 67 ++++++++++++++++++++++++++ tests/integration.rs | 65 +++++++++++++++++++++++++ tests/pyc/guarded_else_merge.3.11.pyc | Bin 0 -> 2285 bytes tests/pyc/guarded_else_merge.3.12.pyc | Bin 0 -> 2104 bytes tests/pyc/guarded_else_merge.3.13.pyc | Bin 0 -> 2212 bytes tests/pyc/guarded_else_merge.3.14.pyc | Bin 0 -> 2291 bytes 7 files changed, 152 insertions(+) create mode 100644 tests/fixtures/guarded_else_merge.py create mode 100644 tests/pyc/guarded_else_merge.3.11.pyc create mode 100644 tests/pyc/guarded_else_merge.3.12.pyc create mode 100644 tests/pyc/guarded_else_merge.3.13.pyc create mode 100644 tests/pyc/guarded_else_merge.3.14.pyc diff --git a/src/decompiler.rs b/src/decompiler.rs index 33a3465d..00db2001 100644 --- a/src/decompiler.rs +++ b/src/decompiler.rs @@ -31768,10 +31768,30 @@ return None; // elif test would create a nested If at the same target, so keep // the Else open in that case. if let Some(top) = self.blocks.last() { + // A modern Else opened immediately after the then arm's + // unconditional skip has an authoritative finite span. A guard + // inside that span can share its merge without ending the Else: + // closing it here ejects the guarded query result and may read + // its local on the other arm before assignment. Keep older and + // inferred/try-else boundaries on their existing path. + let guarded_else_merge = self.version.at_least(3, 11) + && top.kind == BlockType::Else + && self.cur_offset > top.start + && self.cur_offset < top.end + && self.idx_of.get(&top.start).map_or(false, |&si| { + si > 0 && { + let skip = &self.instrs[si - 1]; + !skip.is_backward + && skip.end() == top.start + && skip.target == Some(top.end) + && matches!(skip.op, Op::JUMP_FORWARD | Op::JUMP | Op::JUMP_ABSOLUTE) + } + }); if matches!(top.kind, BlockType::Else | BlockType::TryElse) && top.end == target && !top.is_elif && self.cur_offset != top.start + && !guarded_else_merge { self.close_blocks_at(target); } diff --git a/tests/fixtures/guarded_else_merge.py b/tests/fixtures/guarded_else_merge.py new file mode 100644 index 00000000..96521e49 --- /dev/null +++ b/tests/fixtures/guarded_else_merge.py @@ -0,0 +1,67 @@ +# Versioned .pyc files are consumed by tests/integration.rs. + +# The finite else-span guard applies to Python 3.11+. + +async def awaited(owner): + value = None + active = owner.active + if active: + value = 1 + else: + row = await owner.fetch() + if row and row[0] is not None: + value = float(row[0]) + owner.record(value) + return value + + +async def synchronous(owner): + value = None + active = owner.active + if active: + value = float(1) + else: + row = owner.fetch() + if row and row[0] is not None: + value = 2 + owner.record(value) + return value + + +async def attribute(owner): + value = None + active = owner.active + if active: + value = float(active['total']) + else: + row = owner.row + if row and row[0] is not None: + value = float(row[0]) + owner.record(value) + return value + + +async def following_else_work(owner): + value = None + active = owner.active + if active: + value = 1 + else: + row = await owner.fetch() + if row and row[0] is not None: + value = 2 + owner.record('queried') + owner.record(value) + return value + + +async def independent(owner): + value = None + row = None + if owner.active: + value = 1 + else: + row = await owner.fetch() + if row and row[0] is not None: + value = 2 + return value diff --git a/tests/integration.rs b/tests/integration.rs index f4af5082..3a67b988 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -169,3 +169,68 @@ fn py314_hello() { "py3.14 hello", ); } + +#[test] +fn finite_else_span_keeps_its_inner_guard() { + for version in ["3.11", "3.12", "3.13", "3.14"] { + let (src, clean) = decompile_fixture(&format!("guarded_else_merge.{version}.pyc")); + assert!(clean, "Python {version}: output marked incomplete:\n{src}"); + assert_contains(&src, &[ + r#"async def awaited(owner): + value = None + active = owner.active + if active: + value = 1 + else: + row = await owner.fetch() + if row and row[0] is not None: + value = float(row[0]) + owner.record(value) + return value"#, + r#"async def synchronous(owner): + value = None + active = owner.active + if active: + value = float(1) + else: + row = owner.fetch() + if row and row[0] is not None: + value = 2 + owner.record(value) + return value"#, + r#"async def attribute(owner): + value = None + active = owner.active + if active: + value = float(active['total']) + else: + row = owner.row + if row and row[0] is not None: + value = float(row[0]) + owner.record(value) + return value"#, + r#"async def following_else_work(owner): + value = None + active = owner.active + if active: + value = 1 + else: + row = await owner.fetch() + if row and row[0] is not None: + value = 2 + owner.record('queried') + owner.record(value) + return value"#, + r#"async def independent(owner): + value = None + row = None + if owner.active: + value = 1 + else: + row = await owner.fetch() + if row and row[0] is not None: + value = 2 + return value"#, + ], version); + } +} diff --git a/tests/pyc/guarded_else_merge.3.11.pyc b/tests/pyc/guarded_else_merge.3.11.pyc new file mode 100644 index 0000000000000000000000000000000000000000..226ca4082145932e73a5d99859a66cc0486839d1 GIT binary patch literal 2285 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 literal 0 HcmV?d00001 diff --git a/tests/pyc/guarded_else_merge.3.14.pyc b/tests/pyc/guarded_else_merge.3.14.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6ea01b6c1346ef0a9f8b725781308b2d561c8ae2 GIT binary patch literal 2291 zcmbuA&u<$=6vttF`r zp)Dvmw10tdBHH)}5OFClxe*5c3GjDWVdLg#@%Qjf`0~cSI7`z1SM^_u@&vn3IP15_yyys%g6+P)Q8ztX5aQUh^WU z4-WaDI5H#HaP#uq8}#Qfh$r^q_&Blu3&x3J544@J&qylm%$C+!2ZmGAu3c!4!paFT@nqEQ{-(l`O^upix}rO~?x&E$`jDZ~T^=Y>?8c zx|h@OsdHosXEtZ&F4AAgMW_du2!;df4;Uu`1_aI0c=Z)Z6vI;=_1zSxmVOX@@koR*;PtlB=qSO?0LMjuqawg@dXR)Hq!g#8Ic3yIQ-`|a z!#+8L1Ek?$=*pUrE9Kc$E_%D@*(Qn)c_vdzpGP9J@A;{=L}}kYr^B?5k6_BblR<)} literal 0 HcmV?d00001 From 03e682633343956b389f13667d6392cfde00dbb2 Mon Sep 17 00:00:00 2001 From: Francois Chagnon Date: Tue, 13 Oct 2026 18:33:47 -0400 Subject: [PATCH 2/2] Add harness coverage for the reconstruction fix --- tests/behavior/cases/b_guarded_else_merge.py | 29 ++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 tests/behavior/cases/b_guarded_else_merge.py diff --git a/tests/behavior/cases/b_guarded_else_merge.py b/tests/behavior/cases/b_guarded_else_merge.py new file mode 100644 index 00000000..a2692415 --- /dev/null +++ b/tests/behavior/cases/b_guarded_else_merge.py @@ -0,0 +1,29 @@ +# MIN_VERSION: 3.11 +import asyncio + +async def awaited(owner): + value = None + active = owner.active + if active: + value = 1 + else: + row = await owner.fetch() + if row and row[0] is not None: + value = float(row[0]) + owner.record(value) + return value + + +class Owner: + def __init__(self, active, row): + self.active = active + self.row = row + async def fetch(self): + print("fetch", self.row) + return self.row + def record(self, value): + print("record", value) +print(asyncio.run(awaited(Owner(True, (9,))))) +print(asyncio.run(awaited(Owner(False, (9,))))) +print(asyncio.run(awaited(Owner(False, (None,))))) +print(asyncio.run(awaited(Owner(False, None))))