From 35bd9edf1ac34df0612eba2370423521198ffb1c Mon Sep 17 00:00:00 2001 From: Francois Chagnon Date: Tue, 13 Oct 2026 15:35:45 -0400 Subject: [PATCH 1/2] Preserve keyword calls in bounded expression reconstruction --- src/decompiler.rs | 46 +++++++++++++++++-- tests/fixtures/keyword_call_arm_boundary.py | 5 ++ tests/fixtures/keyword_conditional_calls.py | 22 +++++++++ tests/integration.rs | 30 ++++++++++++ tests/pyc/keyword_call_arm_boundary.3.13.pyc | Bin 0 -> 416 bytes tests/pyc/keyword_call_arm_boundary.3.14.pyc | Bin 0 -> 426 bytes tests/pyc/keyword_conditional_calls.3.13.pyc | Bin 0 -> 1128 bytes tests/pyc/keyword_conditional_calls.3.14.pyc | Bin 0 -> 1149 bytes 8 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/keyword_call_arm_boundary.py create mode 100644 tests/fixtures/keyword_conditional_calls.py create mode 100644 tests/pyc/keyword_call_arm_boundary.3.13.pyc create mode 100644 tests/pyc/keyword_call_arm_boundary.3.14.pyc create mode 100644 tests/pyc/keyword_conditional_calls.3.13.pyc create mode 100644 tests/pyc/keyword_conditional_calls.3.14.pyc diff --git a/src/decompiler.rs b/src/decompiler.rs index 33a3465d..f2a42da8 100644 --- a/src/decompiler.rs +++ b/src/decompiler.rs @@ -28791,6 +28791,15 @@ return None; if st_t.len() != st_e.len() || st_t.is_empty() { return None; } + // CALL_KW support must not make an arm-local callable/argument + // prefix look like a preload from before the condition. Merging + // only its final slot would hoist that prefix across the truth + // test. Decline this shape until the whole arm can be preserved. + if st_t.len() != base.len() + 1 + && self.instrs[tstart..=re].iter().any(|ins| ins.op == Op::CALL_KW) + { + return None; + } let slot_t = st_t.last()?.clone(); let slot_e = st_e.last()?.clone(); // compare the UNDER-stack only — the tops are the slot values @@ -28917,11 +28926,19 @@ return None; ( Expr::Call { func: fa, args: aa, keywords: ka, star_args: sa, star_kwargs: ska }, Expr::Call { func: fb, args: ab, keywords: kb, star_args: sb, star_kwargs: skb }, - ) if ka.is_empty() && kb.is_empty() && sa.is_none() && sb.is_none() && ska.is_none() && skb.is_none() && aa.len() == ab.len() => { + ) if sa.is_none() && sb.is_none() && ska.is_none() && skb.is_none() + && aa.len() == ab.len() && ka.len() == kb.len() + && ka.iter().zip(kb).all(|((a, _), (b, _))| a == b) => { let mut slots: Vec<(&ExprRef, &ExprRef)> = vec![(fa, fb)]; for (x, y) in aa.iter().zip(ab.iter()) { slots.push((x, y)); } + // CALL_KW can now reach this merge through the bounded + // walker. Keep keyword values in the same ordered slot + // model as positional values, preserving the call itself. + for ((_, x), (_, y)) in ka.iter().zip(kb) { + slots.push((x, y)); + } let mut merged_idx: Option = None; let mut merged_val: Option = None; for (i, (x, y)) in slots.iter().enumerate() { @@ -28940,8 +28957,9 @@ return None; reps[idx] = val; Some(Rc::new(Expr::Call { func: reps[0].clone(), - args: reps[1..].to_vec(), - keywords: Vec::new(), + args: reps[1..1 + aa.len()].to_vec(), + keywords: ka.iter().zip(&reps[1 + aa.len()..]) + .map(|((name, _), value)| (name.clone(), value.clone())).collect(), star_args: None, star_kwargs: None, })) @@ -29111,6 +29129,28 @@ return None; star_kwargs: None, })); } + Op::CALL_KW => { + let names = st.pop()?; + let Expr::Const(names) = &*names else { return None; }; + let PyObject::Tuple(names) = &**names else { return None; }; + let names: Option> = names.iter().map(|value| match &**value { + PyObject::Str(name) => Some(name.clone()), _ => None, + }).collect(); + let names = names?; + if names.len() > arg || st.len() < arg + 1 { return None; } + let all = st.split_off(st.len() - arg); + let positional = arg - names.len(); + let args = all[..positional].to_vec(); + let keywords = names.into_iter().zip(all[positional..].iter().cloned()) + .map(|(name, value)| (Some(name), value)).collect(); + let is_null = |value: &ExprRef| matches!(&**value, Expr::Name(name) if name == "\u{0}null"); + let mut callable = st.pop()?; + if is_null(&callable) { callable = st.pop()?; } + if st.last().map_or(false, is_null) { st.pop(); } + st.push(Rc::new(Expr::Call { + func: callable, args, keywords, star_args: None, star_kwargs: None, + })); + } Op::BINARY_OP => { let r = pop1(&mut st); let l = pop1(&mut st); diff --git a/tests/fixtures/keyword_call_arm_boundary.py b/tests/fixtures/keyword_call_arm_boundary.py new file mode 100644 index 00000000..1dab94ec --- /dev/null +++ b/tests/fixtures/keyword_call_arm_boundary.py @@ -0,0 +1,5 @@ +# Resolving owner.build belongs after the condition. Reconstructing this as +# outer(owner.build(amount=1 if value else 2)) changes descriptor evaluation +# order. The bounded merger must decline rather than report a clean result. +def select(value, owner, outer): + return outer(owner.build(amount=1) if value else owner.build(amount=2)) diff --git a/tests/fixtures/keyword_conditional_calls.py b/tests/fixtures/keyword_conditional_calls.py new file mode 100644 index 00000000..044becfc --- /dev/null +++ b/tests/fixtures/keyword_conditional_calls.py @@ -0,0 +1,22 @@ +# Versioned .pyc files are consumed by tests/integration.rs. + +# CALL_KW starts in Python 3.13. + +def case_0(value, build, other): + return build("reject" if value else "accept", amount=0) + + +def case_1(value, build, other): + return build("prefix", amount=0 if value else 1) + + +def case_2(value, build, other): + return build(0 if value else 1, amount=value) + + +def case_9(value, build, other): + return build("prefix", amount=1 if value else 2, last=other(value)) + + +def case_11(value, build, other): + return other(build(amount=1) if value else build(amount=2, other=3)) diff --git a/tests/integration.rs b/tests/integration.rs index f4af5082..2bd8d538 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -169,3 +169,33 @@ fn py314_hello() { "py3.14 hello", ); } + +#[test] +fn keyword_calls_survive_conditional_value_reconstruction() { + for version in ["3.13", "3.14"] { + let (src, clean) = decompile_fixture(&format!("keyword_conditional_calls.{version}.pyc")); + assert!(clean, "Python {version}: output marked incomplete:\n{src}"); + assert_contains(&src, &[ + r#"def case_0(value, build, other): + return build('reject' if value else 'accept', amount=0)"#, + r#"def case_1(value, build, other): + return build('prefix', amount=0 if value else 1)"#, + r#"def case_2(value, build, other): + return build(0 if value else 1, amount=value)"#, + r#"def case_9(value, build, other): + return build('prefix', amount=1 if value else 2, last=other(value))"#, + r#"def case_11(value, build, other): + return other(build(amount=1) if value else build(amount=2, other=3))"#, + ], version); + } +} + +#[test] +fn keyword_call_merge_does_not_hoist_an_arm_local_callable() { + for version in ["3.13", "3.14"] { + let (src, clean) = decompile_fixture(&format!("keyword_call_arm_boundary.{version}.pyc")); + assert!(!clean, "Python {version}: an unproved call merge must remain incomplete:\n{src}"); + assert!(!src.contains("owner.build(amount=1 if value else 2)"), + "Python {version}: callable resolution moved before the condition:\n{src}"); + } +} diff --git a/tests/pyc/keyword_call_arm_boundary.3.13.pyc b/tests/pyc/keyword_call_arm_boundary.3.13.pyc new file mode 100644 index 0000000000000000000000000000000000000000..02c090f0fa53e27161920af14907308ee58b4a33 GIT binary patch literal 416 zcmey&%ge>gz`#(qd=CHI__ex^fowQngt9(!0U1*nf*GV3iWt%vHJOq@T3~<;$N=#_ zCjg15453U)4517fKsE>@3NtW-G6um+f(U}iV8&o3OGZ1?$pqoDCY5I9q-ZkVVl7L|DNVh_nqQumS_Gs@OHzx9K@L<=C7=40qL-YkxFNn}*paup% yO-8U0D;bJ-fZQTbXx`$m$<0qG%}KQ@;sbI)UMdDz^MRR>k?}JJBcl%^STO(!IafRY literal 0 HcmV?d00001 diff --git a/tests/pyc/keyword_call_arm_boundary.3.14.pyc b/tests/pyc/keyword_call_arm_boundary.3.14.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6506f42ce968ae8ed913909438e98320baf82daa GIT binary patch literal 426 zcmdPqTZlX-=wL5igJn@=Y;F@&hv?BjXniMn)e-uwnpWJ665` literal 0 HcmV?d00001 diff --git a/tests/pyc/keyword_conditional_calls.3.13.pyc b/tests/pyc/keyword_conditional_calls.3.13.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d453af6544c579390f0c25189549fc9f45879051 GIT binary patch literal 1128 zcma)5J#W)c6n)QM4KV=%swiI~sRF_Rk)~>fs8yv7C}9agRwjnXI1dQ8arA5h-S`0v z=}^Rk%2G8FLLG}(`V$f{;gu;HYE}mBeRk5IrWJ2EKJVk+ckX@1!&E9M6GHyb_g|mX zzpVWNJ{~k?!+9WE#3WYXK8Y&Q+5AOhMks6&RHG&+6 z1-m9SA+{vthzXe@W~kLr4=Zs7{kkRH{IwQe5X`m2J*I zBF9Sy*lDgIBoNL3jLP9Dn8Y#*{1QyW?iCw{DdXKvJfpyZm#lBwXDTVDZJ1%erqJ&!(fK2J4gk5|D zj;TrLqhjn$=&PI~;2NHWI3aWZM~NYE%or+;<@u*KwZyTP81DVk)X&+y?6+G7>i2tn zz1&mEr)XYyAsYFM5bI}_3z*I}p(W;?UC%SwY@(r*XaT)w9@RO>T z5e_aC_;omh58&bND!j4tU~~4PwR!W0Ug;^7Zm_?0@VK8ZM^jNx)lh}?g|Wo*iEfBo w(S>NG$b*TMb+-{XY?a@DHGBXs1Mpj>lpZE2T{%opT0~exsL&zcLn`k13qKIe>i_@% literal 0 HcmV?d00001 diff --git a/tests/pyc/keyword_conditional_calls.3.14.pyc b/tests/pyc/keyword_conditional_calls.3.14.pyc new file mode 100644 index 0000000000000000000000000000000000000000..891c547ce7aac86653bf9b4481c64e94b075e038 GIT binary patch literal 1149 zcma)5&1(}u6rb6zrd?Yau(WDGV>Gf>d zs`mIN*u#NitA7cM7BbNy@{x%Fu_*ycrVJ>X3ZP=D8DwY<-)t~cFu32p0nkEEaT=A; zl{i|RMkob)TF8`cO3}{@>^|uma;Q=Ak~n^-*^WaReg}iK=>>JagF14kzP8SvyjZ0vvU9~^h2Njw9#JLOZN1&uClh17+8G>qoeCBh)iCuIbLysR}A&xQN0!( z_Y5i28k7KE${Elp4v>Wg712}R#7y%v2@Z4-u`>XIX?tlq*h=^GLRTs5#xtFR=+Qin zzZVO5k2ACIq;E(Euq*lxnk)1)EaXicHS_~%CwEqRdcLdVw}#fgM0x!}K)1k%1T@i5 zh(SZ~Lu^T9iHoZcOBSf}JQyHKiOz!olLk|$xHj`o?(A*42rGGaCp-H&y_^1Wd0+i{ zwWk-mO7R5340X}vSKxkz9-n)g5_WSeS^-@ziIRaLFCYp9|;6E&%+NF~)~87_T2DFka=b!l8u!0N%&qoWD^|#=-yq literal 0 HcmV?d00001 From cb9df40359250b3c4bbc72f1ca1dade6671541a4 Mon Sep 17 00:00:00 2001 From: Francois Chagnon Date: Tue, 13 Oct 2026 18:33:56 -0400 Subject: [PATCH 2/2] Add harness coverage for the reconstruction fix --- .../cases/b_keyword_conditional_calls.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 tests/behavior/cases/b_keyword_conditional_calls.py diff --git a/tests/behavior/cases/b_keyword_conditional_calls.py b/tests/behavior/cases/b_keyword_conditional_calls.py new file mode 100644 index 00000000..ad3f42fe --- /dev/null +++ b/tests/behavior/cases/b_keyword_conditional_calls.py @@ -0,0 +1,25 @@ +# MIN_VERSION: 3.13 + + +def case_0(value, build, other): + return build("reject" if value else "accept", amount=0) + + +def case_1(value, build, other): + return build("prefix", amount=0 if value else 1) + + +def case_9(value, build, other): + return build("prefix", amount=1 if value else 2, last=other(value)) + + +def build(*args, **kwargs): + print("build", args, sorted(kwargs.items())) + return args +def other(value): + print("other", value) + return 8 +print(case_0(False, build, other)) +print(case_0(True, build, other)) +print(case_1(False, build, other)) +print(case_9(True, build, other))