From 2e7183b7b5f5cc08e5afbe0c701a1ee5a96ae7e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 5 Oct 2026 16:29:46 +0000 Subject: [PATCH 1/4] Fix mapped arguments parameter cells in arrow captures --- changelog.d/argsarrow-mapped-arguments.md | 2 + crates/perry-codegen/src/boxed_vars.rs | 10 +- .../src/boxed_vars_mapped_tests.rs | 131 +++++++++++++ .../src/lower/unrebound_params_tests.rs | 25 +++ .../test_gap_argsarrow_mapped_sloppy.cts | 174 ++++++++++++++++++ 5 files changed, 340 insertions(+), 2 deletions(-) create mode 100644 changelog.d/argsarrow-mapped-arguments.md create mode 100644 crates/perry-codegen/src/boxed_vars_mapped_tests.rs create mode 100644 test-files/test_gap_argsarrow_mapped_sloppy.cts diff --git a/changelog.d/argsarrow-mapped-arguments.md b/changelog.d/argsarrow-mapped-arguments.md new file mode 100644 index 0000000000..8513591237 --- /dev/null +++ b/changelog.d/argsarrow-mapped-arguments.md @@ -0,0 +1,2 @@ +- Sloppy functions with simple parameters now use the same mapped-arguments parameter cells in their body and in nested arrow captures. The module-wide boxing analysis previously omitted cells allocated by the arguments prologue: capture creation forwarded a cell pointer, while the arrow body treated it as an ordinary value. Record these cells through the existing arguments-elision proof, including function expressions, without materializing read-only arguments objects. +- Add Node-compared CommonJS coverage for both aliasing directions, nested and returned arrows, escaped arguments, compound receiver snapshots, strict and non-simple parameters, missing arguments, changed length, and deleted indices. Unit tests cover shared capture representation, preserved elision, and invalidation of every parameter's compound-assignment shortcut. diff --git a/crates/perry-codegen/src/boxed_vars.rs b/crates/perry-codegen/src/boxed_vars.rs index 2886f1a521..0e15881833 100644 --- a/crates/perry-codegen/src/boxed_vars.rs +++ b/crates/perry-codegen/src/boxed_vars.rs @@ -60,8 +60,9 @@ pub(crate) fn collect_boxed_vars(stmts: &[perry_hir::Stmt]) -> HashSet { /// /// A param is boxed when it is referenced inside some closure in `body` /// AND mutated — either inside a closure or in the enclosing scope. The -/// synthesized `arguments` param is excluded: it carries its own -/// mapped-box handling (`materialize_arguments_object`). +/// synthesized `arguments` param is excluded, but its mapped parameters +/// share cells even without a syntactic local write. Publish those cells +/// module-wide so capture creation and closure bodies agree on their storage. pub(crate) fn collect_boxed_param_ids( params: &[perry_hir::Param], body: &[perry_hir::Stmt], @@ -70,6 +71,7 @@ pub(crate) fn collect_boxed_param_ids( if params.is_empty() { return out; } + crate::codegen::arguments::add_arguments_mapped_boxes(params, Some(body), &mut out); let mut closure_refs: HashSet = HashSet::new(); let mut closure_writes: HashSet = HashSet::new(); collect_closure_refs_and_writes_in_stmts(body, &mut closure_refs, &mut closure_writes); @@ -1857,3 +1859,7 @@ mod tests { assert_eq!(refine_type_from_init_simple(&Expr::ProcessExit(None)), None); } } + +#[cfg(test)] +#[path = "boxed_vars_mapped_tests.rs"] +mod mapped_arguments_tests; diff --git a/crates/perry-codegen/src/boxed_vars_mapped_tests.rs b/crates/perry-codegen/src/boxed_vars_mapped_tests.rs new file mode 100644 index 0000000000..8a70581a19 --- /dev/null +++ b/crates/perry-codegen/src/boxed_vars_mapped_tests.rs @@ -0,0 +1,131 @@ +use super::*; +use perry_hir::types::Type; +use perry_hir::{ArgumentsObjectMeta, Expr, Function, Module, Param, Stmt}; + +fn params(mapped: bool) -> Vec { + let param = |id, name: &str| Param { + id, + name: name.into(), + ty: Type::Any, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + }; + let mut args = param(3, "arguments"); + args.arguments_object = Some(ArgumentsObjectMeta { + strict: !mapped, + simple_parameters: true, + mapped_parameter_ids: if mapped { vec![(0, 1), (1, 2)] } else { vec![] }, + restricted_callee: !mapped, + }); + vec![param(1, "a"), param(2, "b"), args] +} + +fn closure(id: u32, params: Vec, body: Vec, captures: Vec) -> Expr { + Expr::Closure { + func_id: id, + params, + return_type: Type::Any, + body, + captures, + mutable_captures: Vec::new(), + captures_this: false, + captures_new_target: false, + enclosing_class: None, + is_arrow: true, + is_async: false, + is_generator: false, + is_strict: false, + } +} + +fn write() -> Stmt { + Stmt::Expr(Expr::IndexSet { + object: Box::new(Expr::LocalGet(3)), + index: Box::new(Expr::Integer(0)), + value: Box::new(Expr::LocalGet(2)), + }) +} + +fn module(params: Vec, body: Vec) -> Module { + let mut m = Module::new("mapped"); + m.functions.push(Function { + id: 10, + name: "outer".into(), + type_params: Vec::new(), + params, + return_type: Type::Any, + body, + is_async: false, + is_generator: false, + is_strict: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }); + m +} + +#[test] +fn mapped_cells_are_known_to_all_capture_bodies_without_local_writes() { + let body = vec![Stmt::Expr(closure( + 11, + vec![], + vec![Stmt::Expr(closure(12, vec![], vec![write()], vec![2, 3]))], + vec![2, 3], + ))]; + let boxed = + crate::codegen::boxed_locals::collect_module_boxed_vars(&module(params(true), body)); + assert_eq!(boxed, HashSet::from([1, 2])); +} + +#[test] +fn function_expression_mapped_cells_reach_nested_arrows() { + let outer = closure( + 11, + params(true), + vec![Stmt::Expr(closure(12, vec![], vec![write()], vec![2, 3]))], + vec![], + ); + let mut m = Module::new("expression"); + m.init.push(Stmt::Expr(outer)); + assert_eq!( + crate::codegen::boxed_locals::collect_module_boxed_vars(&m), + HashSet::from([1, 2]) + ); +} + +#[test] +fn escaped_arguments_publish_cells_but_unmapped_arguments_do_not() { + let body = vec![Stmt::Return(Some(Expr::LocalGet(3)))]; + assert_eq!( + collect_boxed_param_ids(¶ms(true), &body), + HashSet::from([1, 2]) + ); + assert!(collect_boxed_param_ids(¶ms(false), &body).is_empty()); + let mut nonsimple = params(false); + let meta = nonsimple[2].arguments_object.as_mut().unwrap(); + meta.strict = false; + meta.simple_parameters = false; + assert!(collect_boxed_param_ids(&nonsimple, &body).is_empty()); +} + +#[test] +fn elided_length_and_index_reads_keep_ordinary_parameter_slots() { + for read in [ + Expr::PropertyGet { + object: Box::new(Expr::LocalGet(3)), + property: "length".into(), + byte_offset: 0, + }, + Expr::IndexGet { + object: Box::new(Expr::LocalGet(3)), + index: Box::new(Expr::Integer(0)), + }, + ] { + assert!(collect_boxed_param_ids(¶ms(true), &[Stmt::Return(Some(read))]).is_empty()); + } +} diff --git a/crates/perry-hir/src/lower/unrebound_params_tests.rs b/crates/perry-hir/src/lower/unrebound_params_tests.rs index 25cab55243..2e54136cc4 100644 --- a/crates/perry-hir/src/lower/unrebound_params_tests.rs +++ b/crates/perry-hir/src/lower/unrebound_params_tests.rs @@ -233,3 +233,28 @@ fn shadowing_or_unrelated_names_do_not_matter() { function(&m, "f").body ); } + +#[test] +fn arguments_mentions_invalidate_every_parameter_not_just_index_zero() { + let m = lower( + "function direct(a, b, c) { const args = arguments; a.x += 1; b.x += 1; c.x += 1; }\n\ + function arrows(a, b, c) { const g = () => () => arguments; a.x += 1; b.x += 1; c.x += 1; }\n\ + function strict(a, b, c) { \"use strict\"; const args = arguments; a.x += 1; b.x += 1; c.x += 1; }\n\ + module.exports = { direct, arrows, strict };\n", + "arguments_all_params.cts", + ); + for name in ["direct", "arrows", "strict"] { + let f = function(&m, name); + assert_eq!(temps(&f.body, "base").len(), 3, "{name}"); + let objects = written_objects(&f.body); + for param in f.params.iter().filter(|p| p.arguments_object.is_none()) { + assert!( + !objects + .iter() + .any(|o| matches!(o, Expr::LocalGet(id) if *id == param.id)), + "{name}: {} needs a receiver snapshot", + param.name + ); + } + } +} diff --git a/test-files/test_gap_argsarrow_mapped_sloppy.cts b/test-files/test_gap_argsarrow_mapped_sloppy.cts new file mode 100644 index 0000000000..b1d120ac34 --- /dev/null +++ b/test-files/test_gap_argsarrow_mapped_sloppy.cts @@ -0,0 +1,174 @@ +// Sloppy CommonJS: mapped arguments share parameter cells, including arrows. +// Compare byte-for-byte with the pinned Node oracle. +function poke(args, value) { args[0] = value; return 1; } +function body(a, b) { + arguments[0] = b; + console.log("body", a === b, arguments[0] === b); + a = 17; + console.log("reverse", arguments[0], a); +} +body(1, 2); +function arrow(a, b) { + a.n += 1; + const write = () => { arguments[0] = b; }; + write(); + a.n += 1000; + return a === b; +} +{ + const x = { n: 1 }, y = { n: 50 }; + console.log("arrow", arrow(x, y), x.n, y.n); +} +function nested(a, b) { + const read = () => a; + const write = () => () => { arguments[0] = b; }; + write()(); + console.log("nested", a === b, read() === b, arguments[0] === b); + a = 29; + console.log("nested-reverse", (() => arguments[0])(), read()); +} +nested(1, 2); +function escaped(a, b) { + const read = () => a; + poke(arguments, b); + console.log("helper", a === b, read() === b); + a = 31; + const args = arguments; + console.log("helper-reverse", ((v) => v[0])(args)); +} +escaped(1, 2); +// Compound assignment must preserve the receiver from before RHS/key writes. +function rhs(a, b) { + a.n += (() => { arguments[0] = b; return 1; })(); + a.n += 1000; + return a === b; +} +function key(a, b) { + const write = () => { arguments[0] = b; return "n"; }; + a[write()] *= 10; + return a === b; +} +function passed(a, b) { + for (let i = 0; i < 2; i++) a[i] += poke(arguments, b); + return a === b; +} +{ + const x = { n: 1 }, y = { n: 50 }; + console.log("rhs", rhs(x, y), x.n, y.n); +} +{ + const x = { n: 2 }, y = { n: 7 }; + console.log("key", key(x, y), x.n, y.n); +} +{ + const x = [1, 2], y = [10, 20]; + console.log("passed", passed(x, y), x.join(","), y.join(",")); +} +function strict(a, b) { + "use strict"; + (() => { arguments[0] = b; })(); + console.log("strict", a, arguments[0]); + a = 41; + console.log("strict-reverse", arguments[0]); +} +strict(1, 2); +function defaulted(a, b = 2) { + (() => { arguments[0] = b; })(); + console.log("default", a, arguments[0], arguments.length); + a = 43; + console.log("default-reverse", arguments[0]); +} +defaulted(1); +function rest(a, ...b) { + (() => { arguments[0] = b[0]; })(); + console.log("rest", a, arguments[0]); + a = 47; + console.log("rest-reverse", arguments[0]); +} +rest(1, 2); +function destructured(a, { value }) { + (() => { arguments[0] = value; })(); + console.log("destructured", a, arguments[0]); + a = 53; + console.log("destructured-reverse", arguments[0]); +} +destructured(1, { value: 2 }); +function missing(a, b) { + arguments.length = 2; // Changing length cannot create mappings. + (() => { arguments[1] = 59; })(); + console.log("missing", b === undefined, arguments[1]); + b = 61; + console.log("missing-reverse", arguments[1]); + arguments[0] = 67; + console.log("present", a); +} +missing(1); +function noArgs(a) { + (() => { arguments[0] = 71; })(); + console.log("no-args", a === undefined, arguments[0]); + a = 73; + console.log("no-args-reverse", arguments[0]); +} +noArgs(); +function deleted(a, b) { + delete arguments[0]; + (() => { arguments[0] = b; })(); + console.log("deleted", a, arguments[0]); + a = 79; + console.log("deleted-reverse", arguments[0]); +} +deleted(1, 2); +function shrunk(a) { + arguments.length = 0; // Supplied indices keep their mapping. + arguments[0] = 83; + console.log("shrunk", a, arguments.length); +} +shrunk(1); +const expression = function(a, b) { + const read = () => a; + (() => () => { arguments[0] = b; })()(); + return [a === b, read() === b, arguments[0] === b].join(","); +}; +console.log("expression", expression(1, 2)); +const holder = { + method(a, b) { + (() => { arguments[0] = b; })(); + return a === b; + } +}; +console.log("method", holder.method(1, 2)); +// Both directions still work after the enclosing call returns. +function keep(a) { + return { + write: (v) => { arguments[0] = v; }, + read: () => a, + set: (v) => { a = v; }, + arg: () => arguments[0] + }; +} +const saved = keep(1); +saved.write(89); +console.log("returned", saved.read(), saved.arg()); +saved.set(97); +console.log("returned-reverse", saved.read(), saved.arg()); + +function every(a, b, c) { + const write = () => { + arguments[0] = b; + arguments[1] = c; + arguments[2] = 101; + }; + write(); + console.log("every", a, b, c, arguments[0], arguments[1], arguments[2]); +} +every(1, 2, 3); +function duplicate(a, a) { + const write = () => { arguments[0] = 103; }; + write(); + console.log("duplicate-first", a, arguments[0], arguments[1]); + arguments[1] = 107; + console.log("duplicate-last", a, arguments[0], arguments[1]); + a = 109; + console.log("duplicate-reverse", arguments[0], arguments[1]); +} +duplicate(1, 2); From cd67a20456cee8d3085b1e37da1b5b85a0f0c564 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 5 Oct 2026 16:41:14 +0000 Subject: [PATCH 2/4] Preserve argument mappings through method and scope lowering --- changelog.d/argsarrow-mapped-arguments.md | 2 + .../src/boxed_vars_mapped_tests.rs | 36 +++++++++ .../perry-codegen/src/scope_env/analysis.rs | 12 +-- crates/perry-codegen/src/scope_env/mod.rs | 33 +++++---- crates/perry-codegen/src/scope_env/pass.rs | 20 ++++- crates/perry-hir/src/lower/const_fold_fn.rs | 4 + crates/perry-hir/src/lower/expr_object.rs | 60 ++++++++------- .../src/lower/unrebound_params_tests.rs | 74 +++++++++++++++++++ ...est_gap_argsarrow_function_ctor_sloppy.cts | 21 ++++++ .../test_gap_argsarrow_methods_sloppy.cts | 55 ++++++++++++++ .../test_gap_argsarrow_redeclared_sloppy.cts | 16 ++++ 11 files changed, 285 insertions(+), 48 deletions(-) create mode 100644 test-files/test_gap_argsarrow_function_ctor_sloppy.cts create mode 100644 test-files/test_gap_argsarrow_methods_sloppy.cts create mode 100644 test-files/test_gap_argsarrow_redeclared_sloppy.cts diff --git a/changelog.d/argsarrow-mapped-arguments.md b/changelog.d/argsarrow-mapped-arguments.md index 8513591237..5e8e901aed 100644 --- a/changelog.d/argsarrow-mapped-arguments.md +++ b/changelog.d/argsarrow-mapped-arguments.md @@ -1,2 +1,4 @@ - Sloppy functions with simple parameters now use the same mapped-arguments parameter cells in their body and in nested arrow captures. The module-wide boxing analysis previously omitted cells allocated by the arguments prologue: capture creation forwarded a cell pointer, while the arrow body treated it as an ordinary value. Record these cells through the existing arguments-elision proof, including function expressions, without materializing read-only arguments objects. - Add Node-compared CommonJS coverage for both aliasing directions, nested and returned arrows, escaped arguments, compound receiver snapshots, strict and non-simple parameters, missing arguments, changed length, and deleted indices. Unit tests cover shared capture representation, preserved elision, and invalidation of every parameter's compound-assignment shortcut. + +- Apply ordinary function strictness and simple-parameter rules to object methods, bind their arguments before parameter defaults, and prevent scope grouping from replacing a parameter's prologue cell when a var declaration reuses its binding. Function-constructor bodies now begin with their own sloppy strict-mode context unless their source contains a strict directive. diff --git a/crates/perry-codegen/src/boxed_vars_mapped_tests.rs b/crates/perry-codegen/src/boxed_vars_mapped_tests.rs index 8a70581a19..ed40da5b08 100644 --- a/crates/perry-codegen/src/boxed_vars_mapped_tests.rs +++ b/crates/perry-codegen/src/boxed_vars_mapped_tests.rs @@ -129,3 +129,39 @@ fn elided_length_and_index_reads_keep_ordinary_parameter_slots() { assert!(collect_boxed_param_ids(¶ms(true), &[Stmt::Return(Some(read))]).is_empty()); } } + +#[test] +fn redeclared_parameters_keep_prologue_cells_in_named_and_expression_bodies() { + let body = vec![ + Stmt::Let { + id: 1, + name: "a".into(), + ty: Type::Any, + mutable: true, + init: Some(Expr::Integer(5)), + }, + Stmt::Return(Some(Expr::LocalGet(3))), + ]; + let mut named = module(params(true), body.clone()); + crate::scope_env::group_scope_boxes(&mut named); + assert!(matches!(named.functions[0].body[0], Stmt::Let { .. })); + // Codegen must decline even a preallocation handed in by another pass. + named.functions[0] + .body + .insert(0, Stmt::PreallocateBoxes(vec![1])); + let boxed = crate::codegen::boxed_locals::collect_module_boxed_vars(&named); + assert!( + crate::scope_env::ScopeMap::build(&named, &boxed, &HashMap::new()) + .slot(1) + .is_none() + ); + let mut expression = Module::new("redeclared_expression"); + expression + .init + .push(Stmt::Expr(closure(11, params(true), body, vec![]))); + crate::scope_env::group_scope_boxes(&mut expression); + let Stmt::Expr(Expr::Closure { body, .. }) = &expression.init[0] else { + panic!("closure retained") + }; + assert!(matches!(body[0], Stmt::Let { .. })); +} diff --git a/crates/perry-codegen/src/scope_env/analysis.rs b/crates/perry-codegen/src/scope_env/analysis.rs index 735a8f44c8..86ab714206 100644 --- a/crates/perry-codegen/src/scope_env/analysis.rs +++ b/crates/perry-codegen/src/scope_env/analysis.rs @@ -14,7 +14,7 @@ use std::collections::{BTreeSet, HashMap, HashSet}; -use perry_hir::{Expr, Module as HirModule, Stmt}; +use perry_hir::{Expr, Module as HirModule, Param, Stmt}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) enum DeclKind { @@ -341,7 +341,7 @@ pub(super) fn stmt_exprs<'a>(s: &'a Stmt, f: &mut dyn FnMut(&'a Expr)) { /// Call `f` with the body of every closure literal directly in this body's /// statements (not inside another closure's body — the caller recurses). -pub(super) fn for_each_closure_in_stmts(stmts: &[Stmt], f: &mut dyn FnMut(&[Stmt])) { +pub(super) fn for_each_closure_in_stmts(stmts: &[Stmt], f: &mut dyn FnMut(&[Param], &[Stmt])) { for_each_stmt_shallow(stmts, &mut |s| { stmt_exprs(s, &mut |e| for_each_closure_in_expr(e, f)); }); @@ -349,9 +349,9 @@ pub(super) fn for_each_closure_in_stmts(stmts: &[Stmt], f: &mut dyn FnMut(&[Stmt /// Call `f` with the body of every closure literal in `e`, not descending into /// the bodies themselves (param defaults of a closure are searched). -pub(super) fn for_each_closure_in_expr(e: &Expr, f: &mut dyn FnMut(&[Stmt])) { - if let Expr::Closure { body, .. } = e { - f(body); +pub(super) fn for_each_closure_in_expr(e: &Expr, f: &mut dyn FnMut(&[Param], &[Stmt])) { + if let Expr::Closure { params, body, .. } = e { + f(params, body); } perry_hir::walker::walk_expr_children(e, &mut |child| for_each_closure_in_expr(child, f)); } @@ -359,7 +359,7 @@ pub(super) fn for_each_closure_in_expr(e: &Expr, f: &mut dyn FnMut(&[Stmt])) { /// How many preallocation statements in the whole module name each id. pub(super) fn prealloc_counts(hir: &HirModule) -> HashMap { let mut counts: HashMap = HashMap::new(); - super::for_each_body(hir, &mut |stmts: &[Stmt]| { + super::for_each_body(hir, &mut |_: &[Param], stmts: &[Stmt]| { for_each_stmt_shallow(stmts, &mut |s| { if let Stmt::PreallocateBoxes(ids) | Stmt::PreallocateTdzBoxes(ids) = s { for id in ids { diff --git a/crates/perry-codegen/src/scope_env/mod.rs b/crates/perry-codegen/src/scope_env/mod.rs index 61086bf92f..1e83a355ab 100644 --- a/crates/perry-codegen/src/scope_env/mod.rs +++ b/crates/perry-codegen/src/scope_env/mod.rs @@ -64,7 +64,7 @@ pub mod pass; use std::collections::{HashMap, HashSet}; -use perry_hir::{Expr, Module as HirModule, Stmt}; +use perry_hir::{Expr, Module as HirModule, Param, Stmt}; pub use pass::group_scope_boxes; @@ -158,13 +158,14 @@ impl ScopeMap { return map; } let counts = analysis::prealloc_counts(hir); - for_each_body(hir, &mut |stmts: &[Stmt]| { + for_each_body(hir, &mut |params: &[Param], stmts: &[Stmt]| { let mut interest = HashSet::new(); analysis::collect_shallow_prealloc_ids(stmts, &mut interest); interest.retain(|id| { counts.get(id) == Some(&1) && module_boxed_vars.contains(id) && !module_globals.contains_key(id) + && !params.iter().any(|p| p.id == *id) }); if interest.is_empty() { return; @@ -228,11 +229,11 @@ pub(crate) fn compact_root_slots( /// Visit every function-like body in the module: top-level functions, class /// members, the module init, and every closure body nested anywhere in them. -pub(crate) fn for_each_body(hir: &HirModule, f: &mut dyn FnMut(&[Stmt])) { - let mut roots: Vec<&[Stmt]> = vec![&hir.init]; +pub(crate) fn for_each_body(hir: &HirModule, f: &mut dyn FnMut(&[Param], &[Stmt])) { + let mut roots: Vec<(&[Param], &[Stmt])> = vec![(&[], &hir.init)]; let mut root_exprs: Vec<&Expr> = Vec::new(); for func in &hir.functions { - roots.push(&func.body); + roots.push((&func.params, &func.body)); push_param_defaults(&func.params, &mut root_exprs); } for c in &hir.classes { @@ -245,7 +246,7 @@ pub(crate) fn for_each_body(hir: &HirModule, f: &mut dyn FnMut(&[Stmt])) { .chain(c.computed_members.iter().map(|m| &m.function)) .chain(c.constructor.iter()) { - roots.push(&m.body); + roots.push((&m.params, &m.body)); push_param_defaults(&m.params, &mut root_exprs); } for field in c.fields.iter().chain(c.static_fields.iter()) { @@ -259,18 +260,24 @@ pub(crate) fn for_each_body(hir: &HirModule, f: &mut dyn FnMut(&[Stmt])) { for g in &hir.globals { root_exprs.extend(g.init.iter()); } - for stmts in roots { - f(stmts); - analysis::for_each_closure_in_stmts(stmts, &mut |body| for_each_body_in_closure(body, f)); + for (params, stmts) in roots { + f(params, stmts); + analysis::for_each_closure_in_stmts(stmts, &mut |params, body| { + for_each_body_in_closure(params, body, f) + }); } for e in root_exprs { - analysis::for_each_closure_in_expr(e, &mut |body| for_each_body_in_closure(body, f)); + analysis::for_each_closure_in_expr(e, &mut |params, body| { + for_each_body_in_closure(params, body, f) + }); } } -fn for_each_body_in_closure(body: &[Stmt], f: &mut dyn FnMut(&[Stmt])) { - f(body); - analysis::for_each_closure_in_stmts(body, &mut |inner| for_each_body_in_closure(inner, f)); +fn for_each_body_in_closure(params: &[Param], body: &[Stmt], f: &mut dyn FnMut(&[Param], &[Stmt])) { + f(params, body); + analysis::for_each_closure_in_stmts(body, &mut |params, inner| { + for_each_body_in_closure(params, inner, f) + }); } fn push_param_defaults<'a>(params: &'a [perry_hir::Param], out: &mut Vec<&'a Expr>) { diff --git a/crates/perry-codegen/src/scope_env/pass.rs b/crates/perry-codegen/src/scope_env/pass.rs index e0aa97bb90..67d6283f3e 100644 --- a/crates/perry-codegen/src/scope_env/pass.rs +++ b/crates/perry-codegen/src/scope_env/pass.rs @@ -9,7 +9,7 @@ use std::collections::{BTreeMap, HashMap, HashSet}; -use perry_hir::{Expr, Module as HirModule, Stmt}; +use perry_hir::{Expr, Module as HirModule, Param, Stmt}; use super::analysis::{self, DeclKind}; @@ -34,13 +34,20 @@ pub fn group_scope_boxes(hir: &mut HirModule) { let mut edits: HashMap = HashMap::new(); let mut grouped: HashSet = HashSet::new(); let init_ptr = hir.init.as_ptr() as usize; - super::for_each_body(hir, &mut |stmts: &[Stmt]| { + super::for_each_body(hir, &mut |params: &[Param], stmts: &[Stmt]| { // Module-scope bindings that closures capture are globalized by // codegen and already have shared storage; leave the init body alone. if stmts.as_ptr() as usize == init_ptr { return; } - plan_body(stmts, &module_boxed, &counts, &mut edits, &mut grouped); + plan_body( + params, + stmts, + &module_boxed, + &counts, + &mut edits, + &mut grouped, + ); }); if grouped.is_empty() { return; @@ -49,6 +56,7 @@ pub fn group_scope_boxes(hir: &mut HirModule) { } fn plan_body( + params: &[Param], stmts: &[Stmt], module_boxed: &HashSet, counts: &HashMap, @@ -58,7 +66,11 @@ fn plan_body( let mut declared = HashSet::new(); crate::collectors::collect_let_ids(stmts, &mut declared); analysis::collect_shallow_prealloc_ids(stmts, &mut declared); - declared.retain(|id| module_boxed.contains(id) && counts.get(id).copied().unwrap_or(0) <= 1); + declared.retain(|id| { + module_boxed.contains(id) + && counts.get(id).copied().unwrap_or(0) <= 1 + && !params.iter().any(|p| p.id == *id) + }); if declared.is_empty() { return; } diff --git a/crates/perry-hir/src/lower/const_fold_fn.rs b/crates/perry-hir/src/lower/const_fold_fn.rs index 535c1a27cb..7ae590b10d 100644 --- a/crates/perry-hir/src/lower/const_fold_fn.rs +++ b/crates/perry-hir/src/lower/const_fold_fn.rs @@ -395,7 +395,11 @@ pub(crate) fn try_const_fold_function_construct_kind( let outer_strict = ctx.current_strict; ctx.current_strict = false; + // A Function constructor body has its own strict context; it does not + // inherit the enclosing source's strict-mode stack or module default. + ctx.enter_strict_mode(false); let lowered_result = lower_fn_expr(ctx, fn_expr); + ctx.exit_strict_mode(); ctx.current_strict = outer_strict; let lowered = match lowered_result { Ok(l) => l, diff --git a/crates/perry-hir/src/lower/expr_object.rs b/crates/perry-hir/src/lower/expr_object.rs index 0dbcbc6313..a05338ae2c 100644 --- a/crates/perry-hir/src/lower/expr_object.rs +++ b/crates/perry-hir/src/lower/expr_object.rs @@ -25,6 +25,7 @@ use crate::analysis::{ use crate::ir::{EnumValue, Expr, Function, Param, Stmt}; use crate::lower_decl::{ append_synthetic_arguments_param, body_uses_arguments, lower_fn_body_block_stmt, + mapped_argument_parameter_ids, params_are_simple_arguments_list, params_use_arguments, }; use crate::lower_patterns::{ generate_param_destructuring_stmts, get_param_default, get_pat_name, is_destructuring_pattern, @@ -270,6 +271,38 @@ fn lower_method_prop( destructuring_params.push((param_id, inner_pat.clone())); } } + // Object methods inherit strictness and use the same parameter mapping + // rules as ordinary functions. Bind arguments before lowering defaults: + // a default that refers to arguments sees this method's object. + let simple_parameters = params_are_simple_arguments_list(&method.function.params); + let user_has_arguments_param = method + .function + .params + .iter() + .any(|p| get_pat_name(&p.pat).ok().as_deref() == Some("arguments")); + let needs_arguments_synth = !user_has_arguments_param + && (method + .function + .body + .as_ref() + .is_some_and(|b| body_uses_arguments(&b.stmts)) + || params_use_arguments(&method.function.params)); + if needs_arguments_synth { + let mapped = !method_strict && simple_parameters; + let mapped_parameter_ids = if mapped { + mapped_argument_parameter_ids(¶ms) + } else { + Vec::new() + }; + append_synthetic_arguments_param( + ctx, + &mut params, + method_strict, + simple_parameters, + !mapped, + mapped_parameter_ids, + ); + } for (param, pat) in params.iter_mut().zip(default_param_pats.iter()) { param.default = get_param_default(ctx, pat)?; } @@ -288,29 +321,6 @@ fn lower_method_prop( .map(|rt| extract_ts_type_with_ctx(&rt.type_ann, Some(ctx))) .unwrap_or(Type::Any); - // #321 / #64 / #65: synthesize legacy `arguments` for object-literal methods - // whose body references it. Without this, effect's Pipeable prototype - // methods (`pipe() { return pipeArguments(this, arguments) }` on - // `TypeMatcherProto`/`ValueMatcherProto` and friends) see an unbound - // `arguments` identifier, and `.pipe(...)` quietly drops all of its - // operands. Mirrors the synthesis in `class_members.rs` / `fn_decl.rs` / - // `expr_function.rs` — the only call site that was missing this hook. - let user_has_arguments_param = method - .function - .params - .iter() - .any(|p| get_pat_name(&p.pat).ok().as_deref() == Some("arguments")); - let needs_arguments_synth = !user_has_arguments_param - && method - .function - .body - .as_ref() - .map(|b| body_uses_arguments(&b.stmts)) - .unwrap_or(false); - if needs_arguments_synth { - append_synthetic_arguments_param(ctx, &mut params, true, false, true, Vec::new()); - } - crate::lower::unrebound_params::note( ctx, ¶ms, @@ -434,7 +444,7 @@ fn lower_method_prop( body, is_async: method.function.is_async, is_generator: method.function.is_generator, - is_strict: ctx.current_strict, + is_strict: method_strict, was_plain_async: false, was_unrolled: false, is_exported: false, @@ -473,7 +483,7 @@ fn lower_method_prop( is_arrow: false, is_async: method.function.is_async, is_generator: method.function.is_generator, - is_strict: ctx.current_strict, + is_strict: method_strict, } }; Ok(Some((method_key, value_expr, uses_this))) diff --git a/crates/perry-hir/src/lower/unrebound_params_tests.rs b/crates/perry-hir/src/lower/unrebound_params_tests.rs index 2e54136cc4..991cc2be58 100644 --- a/crates/perry-hir/src/lower/unrebound_params_tests.rs +++ b/crates/perry-hir/src/lower/unrebound_params_tests.rs @@ -258,3 +258,77 @@ fn arguments_mentions_invalidate_every_parameter_not_just_index_zero() { } } } + +#[test] +fn object_methods_use_their_own_arguments_mapping_rules() { + let m = lower( + r#"const o = { + mapped(a, b) { (() => { arguments[0] = b; })(); return a; }, + strict(a) { "use strict"; return arguments; }, + defaulted(a = arguments[0]) { return a; }, + rest(a, ...r) { return arguments; }, + destructured({a}) { return arguments; } + };"#, + "object_method_arguments.cts", + ); + for (name, strict, simple) in [ + ("mapped", false, true), + ("strict", true, true), + ("defaulted", false, false), + ("rest", false, false), + ("destructured", false, false), + ] { + let f = m + .functions + .iter() + .find(|f| f.name.starts_with(&format!("__obj_method_{name}_"))) + .unwrap_or_else(|| panic!("missing method {name}")); + assert_eq!(f.is_strict, strict, "{name}"); + let meta = f + .params + .iter() + .find_map(|p| p.arguments_object.as_ref()) + .unwrap_or_else(|| panic!("{name} needs its own arguments binding")); + assert_eq!(meta.strict, strict, "{name}"); + assert_eq!(meta.simple_parameters, simple, "{name}"); + assert_eq!(meta.restricted_callee, strict || !simple, "{name}"); + if name == "mapped" { + assert_eq!( + meta.mapped_parameter_ids, + vec![(0, f.params[0].id), (1, f.params[1].id)] + ); + } else { + assert!(meta.mapped_parameter_ids.is_empty(), "{name}"); + } + } +} + +#[test] +fn function_constructor_arguments_do_not_inherit_source_strictness() { + let m = lower( + r#"const loose = new Function("a", "arguments[0] = 7; return a;"); + const strict = new Function("a", '"use strict"; arguments[0] = 7; return a;');"#, + "function_constructor_arguments.ts", + ); + fn visit(expr: &Expr, seen: &mut Vec<(bool, usize)>) { + if let Expr::Closure { + params, is_strict, .. + } = expr + { + if let Some(meta) = params.iter().find_map(|p| p.arguments_object.as_ref()) { + assert_eq!(meta.strict, *is_strict); + seen.push((meta.strict, meta.mapped_parameter_ids.len())); + } + } + crate::walker::walk_expr_children(expr, &mut |child| visit(child, seen)); + } + let mut seen = Vec::new(); + for stmt in &m.init { + crate::walker::stmt_any_expr(stmt, &mut |expr| { + visit(expr, &mut seen); + false + }); + } + seen.sort(); + assert_eq!(seen, vec![(false, 1), (true, 0)]); +} diff --git a/test-files/test_gap_argsarrow_function_ctor_sloppy.cts b/test-files/test_gap_argsarrow_function_ctor_sloppy.cts new file mode 100644 index 0000000000..6c66adb2ea --- /dev/null +++ b/test-files/test_gap_argsarrow_function_ctor_sloppy.cts @@ -0,0 +1,21 @@ +// Function constructor bodies start sloppy even inside strict source. +function make() { + "use strict"; + return new Function("a", "b", "(() => { arguments[0] = b; })(); return a;"); +} +console.log("constructed-arrow", make()(1, 2)); +function makeReverse() { + "use strict"; + return new Function("a", "a = 7; return arguments[0];"); +} +console.log("constructed-reverse", makeReverse()(1)); +function makeStrict() { + "use strict"; + return new Function("a", '"use strict"; arguments[0] = 11; return a;'); +} +console.log("constructed-strict", makeStrict()(1)); +function makeDefault() { + "use strict"; + return new Function("a=1", "arguments[0] = 13; return a;"); +} +console.log("constructed-default", makeDefault()(1)); diff --git a/test-files/test_gap_argsarrow_methods_sloppy.cts b/test-files/test_gap_argsarrow_methods_sloppy.cts new file mode 100644 index 0000000000..121a978b9b --- /dev/null +++ b/test-files/test_gap_argsarrow_methods_sloppy.cts @@ -0,0 +1,55 @@ +// Sloppy object methods follow the same mapping rules as ordinary functions. +function poke(args, value) { args[0] = value; } +const o = { + mapped(a, b) { + this.calls++; + (() => { arguments[0] = b; })(); + console.log("method-arrow", a === b, arguments[0] === b, this.calls); + a = 11; + console.log("method-reverse", arguments[0]); + }, + body(a, b) { + arguments[0] = b; + console.log("method-body", a === b); + }, + escaped(a, b) { + poke(arguments, b); + console.log("method-helper", a === b); + }, + strict(a) { + "use strict"; + arguments[0] = 13; + console.log("method-strict", a, arguments[0]); + }, + defaulted(a = arguments[0]) { + arguments[0] = 17; + console.log("method-default", a === undefined, arguments[0], arguments.length); + }, + rest(a, ...r) { + arguments[0] = 19; + console.log("method-rest", a, arguments[0], r.join(",")); + }, + destructured({value}) { + arguments[0] = 23; + console.log("method-destructure", value, arguments[0]); + }, + missing(a, b) { + arguments[1] = 29; + console.log("method-missing", b === undefined, arguments[1]); + }, + deleted(a) { + delete arguments[0]; + arguments[0] = 31; + console.log("method-deleted", a, arguments[0]); + }, + calls: 0 +}; +o.mapped(1, 2); +o.body(1, 2); +o.escaped(1, 2); +o.strict(1); +o.defaulted(); +o.rest(1, 2, 3); +o.destructured({value: 1}); +o.missing(1); +o.deleted(1); diff --git a/test-files/test_gap_argsarrow_redeclared_sloppy.cts b/test-files/test_gap_argsarrow_redeclared_sloppy.cts new file mode 100644 index 0000000000..1789961f90 --- /dev/null +++ b/test-files/test_gap_argsarrow_redeclared_sloppy.cts @@ -0,0 +1,16 @@ +// A var declaration of a parameter must keep its original mapped cell. +function redeclared(a) { var a = 5; return [a, arguments[0]].join(","); } +function noInit(a) { var a; return [a, arguments[0]].join(","); } +function captured(a, b) { + var a = b; + const write = () => { arguments[0] = 7; }; + const read = () => a; + write(); + return [a, read(), arguments[0]].join(","); +} +const expression = function(a) { var a = 11; return [a, arguments[0]].join(","); }; +console.log("redeclared", redeclared(1), noInit(3), captured(1, 2), expression(1)); +function missing(a) { var a = 13; return [a, arguments[0] === undefined].join(","); } +console.log("redeclared-missing", missing()); +function strict(a) { "use strict"; var a = 17; return [a, arguments[0]].join(","); } +console.log("redeclared-strict", strict(1)); From e58b6c3b335258af0fcefdee7a379984bd546821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 5 Oct 2026 17:27:58 +0000 Subject: [PATCH 3/4] Reuse parameter bindings for function-expression var declarations --- changelog.d/argsarrow-mapped-arguments.md | 2 + crates/perry-hir/src/lower/expr_function.rs | 21 ++++---- .../src/lower/unrebound_params_tests.rs | 48 +++++++++++++++++-- .../test_gap_argsarrow_redeclared_sloppy.cts | 9 ++++ 4 files changed, 66 insertions(+), 14 deletions(-) diff --git a/changelog.d/argsarrow-mapped-arguments.md b/changelog.d/argsarrow-mapped-arguments.md index 5e8e901aed..c3252158e9 100644 --- a/changelog.d/argsarrow-mapped-arguments.md +++ b/changelog.d/argsarrow-mapped-arguments.md @@ -2,3 +2,5 @@ - Add Node-compared CommonJS coverage for both aliasing directions, nested and returned arrows, escaped arguments, compound receiver snapshots, strict and non-simple parameters, missing arguments, changed length, and deleted indices. Unit tests cover shared capture representation, preserved elision, and invalidation of every parameter's compound-assignment shortcut. - Apply ordinary function strictness and simple-parameter rules to object methods, bind their arguments before parameter defaults, and prevent scope grouping from replacing a parameter's prologue cell when a var declaration reuses its binding. Function-constructor bodies now begin with their own sloppy strict-mode context unless their source contains a strict directive. + +Function-expression var redeclarations now reuse their parameter bindings. diff --git a/crates/perry-hir/src/lower/expr_function.rs b/crates/perry-hir/src/lower/expr_function.rs index d651ee1e67..566e651b4f 100644 --- a/crates/perry-hir/src/lower/expr_function.rs +++ b/crates/perry-hir/src/lower/expr_function.rs @@ -858,11 +858,13 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul } else { Type::Any }; - let already_in_scope = ctx - .locals - .lookup_index_in_scope(&name, outer_locals_len) - .is_some(); - if !already_in_scope { + let existing = + ctx.locals.lookup_index_in_scope(&name, outer_locals_len); + if let Some(pos) = existing { + // A var redeclaration shares the parameter binding. + // The declaration lowerer reuses hoisted ids. + ctx.var_hoisted_ids.insert(ctx.locals[pos].1); + } else { let id = ctx.define_local(name.clone(), ty.clone()); // Mark as hoisted so closures created // before the var's init expression see @@ -1040,11 +1042,10 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul names.sort(); names.dedup(); for name in names { - let already_in_scope = ctx - .locals - .lookup_index_in_scope(&name, outer_locals_len) - .is_some(); - if !already_in_scope { + let existing = ctx.locals.lookup_index_in_scope(&name, outer_locals_len); + if let Some(pos) = existing { + ctx.var_hoisted_ids.insert(ctx.locals[pos].1); + } else { let id = ctx.define_local(name.clone(), Type::Any); ctx.var_hoisted_ids.insert(id); hoisted_id_set.insert(id); diff --git a/crates/perry-hir/src/lower/unrebound_params_tests.rs b/crates/perry-hir/src/lower/unrebound_params_tests.rs index 991cc2be58..5676eeb52b 100644 --- a/crates/perry-hir/src/lower/unrebound_params_tests.rs +++ b/crates/perry-hir/src/lower/unrebound_params_tests.rs @@ -293,10 +293,9 @@ fn object_methods_use_their_own_arguments_mapping_rules() { assert_eq!(meta.simple_parameters, simple, "{name}"); assert_eq!(meta.restricted_callee, strict || !simple, "{name}"); if name == "mapped" { - assert_eq!( - meta.mapped_parameter_ids, - vec![(0, f.params[0].id), (1, f.params[1].id)] - ); + let mut mapped = meta.mapped_parameter_ids.clone(); + mapped.sort_unstable(); + assert_eq!(mapped, vec![(0, f.params[0].id), (1, f.params[1].id)]); } else { assert!(meta.mapped_parameter_ids.is_empty(), "{name}"); } @@ -332,3 +331,44 @@ fn function_constructor_arguments_do_not_inherit_source_strictness() { seen.sort(); assert_eq!(seen, vec![(false, 1), (true, 0)]); } + +#[test] +fn function_expression_var_redeclarations_share_parameter_ids() { + let m = lower( + r#"const f = function(a, b) { + var a = 11; + if (b) { var b = 12; } + return [a, b, arguments[0], arguments[1]]; + };"#, + "function_expression_redeclared.cts", + ); + let Stmt::Let { + init: Some(Expr::Closure { params, body, .. }), + .. + } = &m.init[0] + else { + panic!("function expression retained"); + }; + let mut declarations = Vec::new(); + fn visit(stmts: &[Stmt], ids: &mut Vec) { + for s in stmts { + match s { + Stmt::Let { id, name, .. } if name == "a" || name == "b" => ids.push(*id), + Stmt::If { + then_branch, + else_branch, + .. + } => { + visit(then_branch, ids); + if let Some(branch) = else_branch { + visit(branch, ids); + } + } + _ => {} + } + } + } + visit(body, &mut declarations); + declarations.sort_unstable(); + assert_eq!(declarations, vec![params[0].id, params[1].id]); +} diff --git a/test-files/test_gap_argsarrow_redeclared_sloppy.cts b/test-files/test_gap_argsarrow_redeclared_sloppy.cts index 1789961f90..1df42fdd69 100644 --- a/test-files/test_gap_argsarrow_redeclared_sloppy.cts +++ b/test-files/test_gap_argsarrow_redeclared_sloppy.cts @@ -14,3 +14,12 @@ function missing(a) { var a = 13; return [a, arguments[0] === undefined].join(", console.log("redeclared-missing", missing()); function strict(a) { "use strict"; var a = 17; return [a, arguments[0]].join(","); } console.log("redeclared-strict", strict(1)); + +const nestedExpression = function(a, b) { + if (b) { var a = 19; } + const write = () => { arguments[0] = 23; }; + write(); + return [a, arguments[0]].join(","); +}; +const emptyExpression = function(a) { var a; return [a, arguments[0]].join(","); }; +console.log("redeclared-expressions", nestedExpression(1, true), emptyExpression(29)); From ba8af9a360f8ac833cd416138f981045bc16ed7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 5 Oct 2026 18:03:21 +0000 Subject: [PATCH 4/4] Strengthen Function constructor strict-context regression test --- crates/perry-hir/src/lower/unrebound_params_tests.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/perry-hir/src/lower/unrebound_params_tests.rs b/crates/perry-hir/src/lower/unrebound_params_tests.rs index 5676eeb52b..a5aa3864c1 100644 --- a/crates/perry-hir/src/lower/unrebound_params_tests.rs +++ b/crates/perry-hir/src/lower/unrebound_params_tests.rs @@ -305,8 +305,12 @@ fn object_methods_use_their_own_arguments_mapping_rules() { #[test] fn function_constructor_arguments_do_not_inherit_source_strictness() { let m = lower( - r#"const loose = new Function("a", "arguments[0] = 7; return a;"); - const strict = new Function("a", '"use strict"; arguments[0] = 7; return a;');"#, + r#"function enclosingStrict() { + "use strict"; + const loose = new Function("a", "arguments[0] = 7; return a;"); + const strict = new Function("a", '"use strict"; arguments[0] = 7; return a;'); + return [loose, strict]; + }"#, "function_constructor_arguments.ts", ); fn visit(expr: &Expr, seen: &mut Vec<(bool, usize)>) { @@ -322,7 +326,7 @@ fn function_constructor_arguments_do_not_inherit_source_strictness() { crate::walker::walk_expr_children(expr, &mut |child| visit(child, seen)); } let mut seen = Vec::new(); - for stmt in &m.init { + for stmt in &function(&m, "enclosingStrict").body { crate::walker::stmt_any_expr(stmt, &mut |expr| { visit(expr, &mut seen); false