diff --git a/changelog.d/10355-builder-fold-gap.md b/changelog.d/10355-builder-fold-gap.md new file mode 100644 index 0000000000..b6a597f2be --- /dev/null +++ b/changelog.d/10355-builder-fold-gap.md @@ -0,0 +1,37 @@ +Fixed a 75× property-store cliff on `const o = {}; const X = 1; o.a = X;` +(#10353). The straight-line builder fold (#6812) rewrites `const o = {}` +plus its following `o.k = v` assignments into the object literal they spell +out, which is what gives the object a closed anon shape, a shape-stamped +allocation and direct stores. It only matched when the assignments followed +the binding *immediately*, so a single ordinary declaration in between — the +usual way initialisation code names its constants — dropped the whole +sequence back onto the dynamic `js_put_value_set` path, where every store +re-interns and re-coerces the key and transitions the object's shape. The +same program with the value passed as a parameter, or with the constants +written inline, was 75× faster, which is what made the cliff look like a +property of the stored *value*. + +`fold_builder_sequences` now skips up to 64 statements between an **empty** +`{}` binding and its first assignment, sinking the allocation below them. A +statement is skippable only when moving the allocation past it is +unobservable, which is the pair of conditions the value side already carries +(`gap_stmt_is_hoistable`): it must not name the binding, and it must not be +able to execute user code — a call can reach a hoisted +`function peek() { return o; }` that names the binding without the statement +naming it, which would turn a successful read into a TDZ `ReferenceError`. +Destructuring patterns (getter-bearing property reads) and populated +literals are excluded; sinking `const o = { a: y }` below `const y = 1` +would hide a TDZ throw. Skipped statements keep their relative order and +still run before every folded value. + +Measured with `perf stat -e instructions:u` on x86_64, 2400 iterations +building a six-property object with `--no-auto-optimize`: 108,447,339 → +1,399,772 instructions (77×), matching the same program with the constants +written inline (1,401,872) or the value passed as a parameter (1,411,774). +Nothing that folded before folds differently — the gap is an additional +match, and a statement that fails the test leaves the original dynamic +writes exactly as they were: `benchmarks/object-write-6812` and the +`bench_*` corpus move by at most 0.006%, and a 12k-line file whose gaps +never reach an assignment (maximum pre-scan work, zero folds) costs 0.019% +more to compile. A file where the fold now applies compiles 51% cheaper, +because 1,200 dynamic store sites become 200 stamped allocations. diff --git a/crates/perry-hir/src/lower/builder_fold.rs b/crates/perry-hir/src/lower/builder_fold.rs index 936b51ee74..e91b777692 100644 --- a/crates/perry-hir/src/lower/builder_fold.rs +++ b/crates/perry-hir/src/lower/builder_fold.rs @@ -15,6 +15,21 @@ //! - The appended value expressions run in the same order at the same //! sequence points; only the allocation moves AFTER them, and a bare //! object allocation has no user-visible effects. +//! - #10353: the assignments need not follow the binding IMMEDIATELY. The +//! scan skips up to `MAX_FOLD_GAP_STMTS` statements in between when +//! moving the allocation below them is unobservable by the same argument +//! — `gap_stmt_is_hoistable` requires exactly what the value side already +//! requires: the statement must not name the binding, and it must not be +//! able to execute user code. Skipped statements keep their relative +//! order and still run before every appended value, so +//! `const o = {}; const X = 1; o.a = X;` folds to +//! `const X = 1; const o = { a: X };`. That gap is the ordinary shape of +//! initialisation code, and before #10353 it cost 75×: the unfolded form +//! leaves a 0-field anon shape that denies `Ptr` containment, so +//! every store takes the dynamic `PutValueSet` path. A gap is allowed only +//! for an EMPTY literal — sinking a populated one would move its own value +//! expressions below the skipped statements, and `const o = { a: y }; +//! const y = 1; o.b = 2;` must keep throwing on `y`'s TDZ. //! - Values must not reference the bound name (checked conservatively by //! symbol name anywhere in the value expression, ignoring shadowing), so //! no expression can observe the half-built object. @@ -47,6 +62,12 @@ use swc_ecma_visit::{Visit, VisitWith}; /// literal machinery's inline-slot benefits taper off anyway. const MAX_FOLDED_PROPS: usize = 64; +/// How many statements the scan may skip between the binding and its first +/// assignment (#10353). Real builders separate the two by a handful of +/// constant bindings at most; the cap keeps the forward scan O(n) over a +/// statement list instead of O(n²) on a long run of hoistable declarations. +const MAX_FOLD_GAP_STMTS: usize = 64; + /// Returns a folded clone when at least one builder sequence was folded; /// `None` means "nothing to do — lower the original". pub(crate) fn fold_builder_sequences(module: &ast::Module) -> Option { @@ -155,17 +176,25 @@ fn is_object_prototype_expr(expr: &ast::Expr) -> bool { /// Cheap read-only pre-scan: is any statement list anywhere (including /// function bodies nested in expressions) a `const/let/var x = {…}` -/// immediately followed by a static member assignment to the same name? -/// False positives only cost the clone; a false negative would skip a -/// fold, so the walk mirrors the mutating one's reach. +/// followed — across a hoistable gap (#10353) — by a static member +/// assignment to the same name? False positives only cost the clone; a +/// false negative would skip a fold, so the walk mirrors the mutating +/// one's reach, gap included. fn module_has_candidate(module: &ast::Module) -> bool { - for pair in module.body.windows(2) { - if let (ast::ModuleItem::Stmt(a), ast::ModuleItem::Stmt(b)) = (&pair[0], &pair[1]) { - if let (Some(name), _) = decl_object_binding(a) { - if assign_to_name_key(b, name.as_str()).is_some() { - return true; - } - } + for (i, item) in module.body.iter().enumerate() { + let ast::ModuleItem::Stmt(a) = item else { + continue; + }; + let (Some(name), _) = decl_object_binding(a) else { + continue; + }; + let item_stmt = |k: usize| match module.body.get(i + 1 + k) { + Some(ast::ModuleItem::Stmt(s)) => Some(s), + _ => None, + }; + let gap = fold_gap_len(name.as_str(), item_stmt); + if item_stmt(gap).is_some_and(|b| assign_to_name_key(b, name.as_str()).is_some()) { + return true; } } module.body.iter().any(|item| match item { @@ -177,11 +206,16 @@ fn module_has_candidate(module: &ast::Module) -> bool { } fn stmts_have_candidate(stmts: &[ast::Stmt]) -> bool { - for pair in stmts.windows(2) { - if let (Some(name), _) = decl_object_binding(&pair[0]) { - if assign_to_name_key(&pair[1], name.as_str()).is_some() { - return true; - } + for (i, s) in stmts.iter().enumerate() { + let (Some(name), _) = decl_object_binding(s) else { + continue; + }; + let gap = fold_gap_len(name.as_str(), |k| stmts.get(i + 1 + k)); + if stmts + .get(i + 1 + gap) + .is_some_and(|b| assign_to_name_key(b, name.as_str()).is_some()) + { + return true; } } stmts.iter().any(scan_stmt) @@ -368,10 +402,23 @@ fn fold_module_stmt_run(items: &mut [ast::ModuleItem], changed: &mut bool) { idx += 1; continue; } + // A gap is only skippable for an EMPTY literal: sinking a populated + // one would move its own value expressions below the skipped + // statements, and `const o = { a: y }; const y = 1; o.b = 2;` must + // keep throwing on `y`'s TDZ. + let gap = if existing.is_empty() { + fold_gap_len(&name_start, |k| match items.get(idx + 1 + k) { + Some(ast::ModuleItem::Stmt(s)) => Some(s), + _ => None, + }) + } else { + 0 + }; + let first = idx + 1 + gap; let mut keys = existing_keys(existing); let mut appended: Vec<(ast::PropName, Box)> = Vec::new(); let mut consumed = 0usize; - for follower in items[idx + 1..].iter() { + for follower in items[first..].iter() { let ast::ModuleItem::Stmt(fs) = follower else { break; }; @@ -392,16 +439,28 @@ fn fold_module_stmt_run(items: &mut [ast::ModuleItem], changed: &mut bool) { idx += 1; continue; } - // Apply: extend the literal, blank out the consumed statements. + // Apply: extend the literal, sink the declaration below the skipped + // statements so the appended values still evaluate after them, and + // blank out the consumed statements. if let ast::ModuleItem::Stmt(s) = &mut items[idx] { append_props(s, appended); } - for follower in items[idx + 1..idx + 1 + consumed].iter_mut() { + if gap > 0 { + items[idx..first].rotate_left(1); + } + for follower in items[first..first + consumed].iter_mut() { *follower = ast::ModuleItem::Stmt(ast::Stmt::Empty(ast::EmptyStmt { span: swc_common::DUMMY_SP, })); } *changed = true; + if gap > 0 { + // `items[idx]` is now the first skipped statement, which may open + // a builder of its own (`const a = {}; const b = {}; a.x = 1; + // b.y = 2;`). Re-examining it terminates: each fold blanks at + // least one assignment statement, and the run holds finitely many. + continue; + } idx += 1 + consumed; } } @@ -419,9 +478,16 @@ fn fold_stmts(stmts: &mut Vec, changed: &mut bool) { idx += 1; continue; }; + // Empty literals only — see `fold_module_stmt_run`. + let gap = if existing_len == 0 { + fold_gap_len(&name, |k| stmts.get(idx + 1 + k)) + } else { + 0 + }; + let first = idx + 1 + gap; let mut appended: Vec<(ast::PropName, Box)> = Vec::new(); let mut consumed = 0usize; - for follower in stmts[idx + 1..].iter() { + for follower in stmts[first..].iter() { let Some((key, value)) = assign_to_name_key(follower, &name) else { break; }; @@ -437,8 +503,20 @@ fn fold_stmts(stmts: &mut Vec, changed: &mut bool) { } if consumed > 0 { append_props(&mut stmts[idx], appended); - stmts.drain(idx + 1..idx + 1 + consumed); + // Sink the declaration below the skipped statements: the appended + // values evaluate where the literal now sits, so they must still + // run after everything that used to precede them. + if gap > 0 { + stmts[idx..first].rotate_left(1); + } + stmts.drain(first..first + consumed); *changed = true; + if gap > 0 { + // `stmts[idx]` is now the first skipped statement, which may + // open a builder of its own. Re-examining it terminates: each + // fold removes at least one statement from the list. + continue; + } } idx += 1; } @@ -500,6 +578,66 @@ fn assign_to_name_key<'a>( Some((key, &a.right)) } +/// How many statements between the `{ … }` binding and its first fold-able +/// assignment the scan may skip (#10353). +/// +/// `at(k)` yields the k-th follower of the binding, or `None` when the run +/// ends (a non-`Stmt` module item, or the end of the list). The walk stops at +/// the first statement that is an assignment to `name` — that is where the +/// fold proper takes over — and at the first statement the declaration may +/// not move below. +fn fold_gap_len<'a>(name: &str, at: impl Fn(usize) -> Option<&'a ast::Stmt>) -> usize { + let mut gap = 0usize; + while gap < MAX_FOLD_GAP_STMTS { + let Some(s) = at(gap) else { break }; + if assign_to_name_key(s, name).is_some() || !gap_stmt_is_hoistable(s, name) { + break; + } + gap += 1; + } + gap +} + +/// May the `{ … }` declaration move BELOW this statement? +/// +/// The fold evaluates the appended values where the literal ends up, so a +/// statement standing between the binding and its first assignment is only +/// skippable when moving the ALLOCATION past it is unobservable. That is the +/// same pair of conditions `value_is_fold_safe` already enforces on the value +/// side, for the same two reasons: +/// +/// - the statement must not NAME the binding — it would otherwise read, write +/// or capture an object that no longer exists at that point (`const o = {}; +/// f(o); o.a = 1` keeps its dynamic writes); +/// - the statement must not be able to execute user code, because a call can +/// reach a hoisted `function peek() { return o; }` that names the binding +/// WITHOUT the statement naming it, turning a successful read into a TDZ +/// `ReferenceError`. Reusing `value_is_fold_safe` for initializers and +/// expression statements buys exactly that test, and deliberately shares +/// its precision: that predicate admits the implicit conversions (`a + b`, +/// a template substitution) that can still reach a user `valueOf`, which +/// is a pre-existing imprecision of the value side, tracked separately — +/// the two sides must not drift apart here. +/// +/// Destructuring patterns are excluded: the binding itself performs property +/// reads, which can run a getter. Type-only declarations are erased before +/// codegen, so they carry no runtime effect at all and always qualify — +/// `enum` and `namespace` do emit code and do not. +fn gap_stmt_is_hoistable(s: &ast::Stmt, name: &str) -> bool { + match s { + ast::Stmt::Empty(_) => true, + ast::Stmt::Decl(ast::Decl::TsInterface(_) | ast::Decl::TsTypeAlias(_)) => true, + ast::Stmt::Expr(es) => value_is_fold_safe(&es.expr, name), + ast::Stmt::Decl(ast::Decl::Var(var)) => var.decls.iter().all(|d| { + matches!(&d.name, ast::Pat::Ident(bi) if bi.id.sym.as_ref() != name) + && d.init + .as_deref() + .is_none_or(|init| value_is_fold_safe(init, name)) + }), + _ => false, + } +} + /// The literal may only contain plain key/value + shorthand props; anything /// else (accessors, spreads, computed keys, methods) disables folding. fn literal_is_foldable(props: &[ast::PropOrSpread]) -> bool { diff --git a/crates/perry-hir/tests/builder_fold_gap.rs b/crates/perry-hir/tests/builder_fold_gap.rs new file mode 100644 index 0000000000..13ac6ea4af --- /dev/null +++ b/crates/perry-hir/tests/builder_fold_gap.rs @@ -0,0 +1,384 @@ +//! #10353: a builder whose assignments do not IMMEDIATELY follow the `{}` +//! binding must still fold into the literal — `const o = {}; const X = 1; +//! o.a = X;` is the ordinary shape of initialisation code, and leaving it +//! unfolded costs 75× (a 0-field anon shape that every store then transitions +//! dynamically through `js_put_value_set`). +//! +//! The gap is only skippable while moving the ALLOCATION below it stays +//! unobservable, so these tests pin both directions: the fold happens for +//! inert statements, and it does NOT happen for a statement that names the +//! binding, can run user code, or destructures. + +use perry_diagnostics::SourceCache; +use perry_hir::{lower_module, Expr, Stmt}; +use perry_parser::parse_typescript_with_cache; + +fn lower_src(src: &str) -> perry_hir::Module { + let mut cache = SourceCache::new(); + let parsed = parse_typescript_with_cache(src, "builder_fold_gap.ts", &mut cache) + .expect("parse should succeed"); + lower_module(&parsed.module, "test", "builder_fold_gap.ts").expect("lower should succeed") +} + +/// Number of constructor args on the `__AnonShape_…` allocation bound to +/// `name`, i.e. how many properties the literal carries after folding. +fn anon_shape_arity(stmts: &[Stmt], name: &str) -> Option { + stmts.iter().find_map(|stmt| match stmt { + Stmt::Let { + name: binding, + init: Some(Expr::New { + class_name, args, .. + }), + .. + } if binding == name && class_name.starts_with("__AnonShape_") => Some(args.len()), + _ => None, + }) +} + +fn runtime_set_keys(stmts: &[Stmt]) -> Vec { + stmts + .iter() + .filter_map(|stmt| match stmt { + Stmt::Expr(Expr::PutValueSet { key, .. }) => match key.as_ref() { + Expr::String(k) => Some(k.clone()), + _ => None, + }, + _ => None, + }) + .collect() +} + +fn fn_body<'a>(module: &'a perry_hir::Module, name: &str) -> &'a [Stmt] { + module + .functions + .iter() + .find(|f| f.name == name) + .map(|f| f.body.as_slice()) + .unwrap_or_else(|| panic!("function `{name}` not found")) +} + +/// Index of the `Let` binding `name` within a statement list. +fn let_position(stmts: &[Stmt], name: &str) -> Option { + stmts + .iter() + .position(|stmt| matches!(stmt, Stmt::Let { name: binding, .. } if binding == name)) +} + +#[test] +fn a_constant_binding_between_the_literal_and_its_stores_still_folds() { + // The issue's `vA.ts`, at module scope. + let module = lower_src( + r#" + const o: any = {}; + const x = 1; + o.p1 = x; o.p2 = x; o.p3 = x; o.p4 = x; o.p5 = x; o.p6 = x; + "#, + ); + + assert_eq!( + anon_shape_arity(&module.init, "o"), + Some(6), + "all six stores should have folded into the allocation: {:?}", + module.init + ); + assert!( + runtime_set_keys(&module.init).is_empty(), + "no store should survive as a dynamic [[Set]]: {:?}", + runtime_set_keys(&module.init) + ); +} + +#[test] +fn the_skipped_binding_is_still_initialized_before_the_literal() { + // Sinking the allocation below `const x` is the whole point: the folded + // values read `x`, so `x` must still be initialized first or the literal + // would hit `x`'s TDZ. + let module = lower_src( + r#" + const o: any = {}; + const x = 1; + o.p1 = x; + "#, + ); + + let x = let_position(&module.init, "x").expect("`x` binding not found"); + let o = let_position(&module.init, "o").expect("`o` binding not found"); + assert!( + x < o, + "`x` must be initialized before the folded literal: {:?}", + module.init + ); +} + +#[test] +fn a_gap_inside_a_function_body_folds_too() { + let module = lower_src( + r#" + export function build(): any { + const o: any = {}; + const x = 1; + o.p1 = x; o.p2 = x; + return o; + } + "#, + ); + + let body = fn_body(&module, "build"); + assert_eq!( + anon_shape_arity(body, "o"), + Some(2), + "both stores should have folded inside the function body: {body:?}" + ); + assert!( + runtime_set_keys(body).is_empty(), + "no store should survive as a dynamic [[Set]]: {:?}", + runtime_set_keys(body) + ); +} + +#[test] +fn several_skipped_bindings_are_all_kept_in_order() { + let module = lower_src( + r#" + const o: any = {}; + const a = 1; + const b = a + 1; + o.p1 = a; + o.p2 = b; + "#, + ); + + assert_eq!( + anon_shape_arity(&module.init, "o"), + Some(2), + "both stores should have folded: {:?}", + module.init + ); + let a = let_position(&module.init, "a").expect("`a` binding not found"); + let b = let_position(&module.init, "b").expect("`b` binding not found"); + let o = let_position(&module.init, "o").expect("`o` binding not found"); + assert!( + a < b && b < o, + "the skipped bindings must keep their order and precede the literal: {:?}", + module.init + ); +} + +#[test] +fn a_second_builder_in_the_gap_folds_as_well() { + let module = lower_src( + r#" + const a: any = {}; + const b: any = {}; + a.x = 1; + b.y = 2; + "#, + ); + + assert_eq!( + anon_shape_arity(&module.init, "a"), + Some(1), + "the outer builder should fold: {:?}", + module.init + ); + assert_eq!( + anon_shape_arity(&module.init, "b"), + Some(1), + "the builder found in the gap should fold too: {:?}", + module.init + ); +} + +#[test] +fn a_gap_statement_that_names_the_binding_blocks_the_fold() { + // `alias` reads `o` before the allocation would happen — sinking the + // declaration past it would be a TDZ ReferenceError. + let module = lower_src( + r#" + const o: any = {}; + const alias = o; + o.p1 = 1; + "#, + ); + + assert_eq!( + anon_shape_arity(&module.init, "o"), + Some(0), + "the allocation must stay empty: {:?}", + module.init + ); + assert_eq!( + runtime_set_keys(&module.init), + vec!["p1".to_string()], + "the store must survive as a dynamic [[Set]]: {:?}", + module.init + ); +} + +#[test] +fn a_gap_statement_that_can_run_user_code_blocks_the_fold() { + // The gap statement does not name `o`, but `peek()` reads it — sinking + // the allocation below the call would turn that read into a TDZ + // ReferenceError. + let module = lower_src( + r#" + function peek(): any { return o; } + const o: any = {}; + const seen = peek(); + o.p1 = 1; + "#, + ); + + assert_eq!( + anon_shape_arity(&module.init, "o"), + Some(0), + "a call in the gap must block the fold: {:?}", + module.init + ); + assert_eq!( + runtime_set_keys(&module.init), + vec!["p1".to_string()], + "the store must survive as a dynamic [[Set]]: {:?}", + module.init + ); +} + +#[test] +fn a_destructuring_gap_blocks_the_fold() { + // The pattern itself performs property reads, which can run a getter. + let module = lower_src( + r#" + const src: any = { a: 1 }; + const o: any = {}; + const { a } = src; + o.p1 = a; + "#, + ); + + assert_eq!( + anon_shape_arity(&module.init, "o"), + Some(0), + "a destructuring gap must block the fold: {:?}", + module.init + ); + assert_eq!( + runtime_set_keys(&module.init), + vec!["p1".to_string()], + "the store must survive as a dynamic [[Set]]: {:?}", + module.init + ); +} + +#[test] +fn a_member_read_in_the_gap_blocks_the_fold() { + // A getter on `src` could reach the binding. + let module = lower_src( + r#" + const src: any = { a: 1 }; + const o: any = {}; + const a = src.a; + o.p1 = a; + "#, + ); + + assert_eq!( + anon_shape_arity(&module.init, "o"), + Some(0), + "a member read in the gap must block the fold: {:?}", + module.init + ); + assert_eq!( + runtime_set_keys(&module.init), + vec!["p1".to_string()], + "the store must survive as a dynamic [[Set]]: {:?}", + module.init + ); +} + +#[test] +fn a_populated_literal_does_not_skip_a_gap() { + // Sinking `{ a: y }` below `const y` would turn a TDZ ReferenceError into + // a successful build, so only empty literals may skip statements. + let module = lower_src( + r#" + const o: any = { a: 1 }; + const y = 2; + o.b = y; + "#, + ); + + assert_eq!( + anon_shape_arity(&module.init, "o"), + Some(1), + "the populated literal must keep its single property: {:?}", + module.init + ); + assert_eq!( + runtime_set_keys(&module.init), + vec!["b".to_string()], + "the store must survive as a dynamic [[Set]]: {:?}", + module.init + ); +} + +#[test] +fn an_adjacent_builder_still_folds_into_a_populated_literal() { + // The pre-#10353 behaviour is unchanged when there is no gap. + let module = lower_src( + r#" + const o: any = { a: 1 }; + o.b = 2; + "#, + ); + + assert_eq!( + anon_shape_arity(&module.init, "o"), + Some(2), + "an adjacent store should still fold: {:?}", + module.init + ); + assert!( + runtime_set_keys(&module.init).is_empty(), + "no store should survive as a dynamic [[Set]]: {:?}", + runtime_set_keys(&module.init) + ); +} + +#[test] +fn a_type_only_declaration_in_the_gap_is_skipped() { + let module = lower_src( + r#" + const o: any = {}; + type Width = number; + interface Shape { w: Width } + const x: Width = 1; + o.p1 = x; + "#, + ); + + assert_eq!( + anon_shape_arity(&module.init, "o"), + Some(1), + "erased declarations should not block the fold: {:?}", + module.init + ); +} + +#[test] +fn an_enum_in_the_gap_blocks_the_fold() { + // Unlike `type`/`interface`, an enum emits an initializer at run time. + let module = lower_src( + r#" + const o: any = {}; + enum Color { Red } + o.p1 = 1; + "#, + ); + + assert_eq!( + anon_shape_arity(&module.init, "o"), + Some(0), + "an enum in the gap must block the fold: {:?}", + module.init + ); +} diff --git a/crates/perry/tests/builder_fold_gap_semantics.rs b/crates/perry/tests/builder_fold_gap_semantics.rs new file mode 100644 index 0000000000..1909593d75 --- /dev/null +++ b/crates/perry/tests/builder_fold_gap_semantics.rs @@ -0,0 +1,137 @@ +//! #10353: the builder fold may skip statements between a `{}` binding and +//! its stores, which sinks the ALLOCATION below them. These are the observable +//! consequences — the HIR tests in `perry-hir/tests/builder_fold_gap.rs` pin +//! which shapes fold, this file pins that folding them changes nothing a +//! program can see. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn compile_and_run(source: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.ts"); + let output = dir.path().join("main_bin"); + std::fs::write(&entry, source).expect("write entry"); + + let compile = Command::new(perry_bin()) + .current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .arg("--no-cache") + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(&output) + .current_dir(dir.path()) + .output() + .expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed (exit {:?})\nstdout:\n{}\nstderr:\n{}", + run.status.code(), + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + String::from_utf8_lossy(&run.stdout).into_owned() +} + +#[test] +fn a_skipped_constant_binding_leaves_the_object_unchanged() { + // The issue's repro plus the shape observations a fold could disturb: + // key order, own-property-ness and `in` must all match the unfolded form. + let stdout = compile_and_run( + r#" +function build(): any { + const o: any = {}; + const x = 1; + o.p1 = x; o.p2 = x + 1; o.p3 = x + 2; + return o; +} +const o = build(); +console.log(JSON.stringify(o)); +console.log(Object.keys(o).join(",")); +console.log("p1" in o, "p9" in o, Object.prototype.hasOwnProperty.call(o, "p3")); +"#, + ); + assert_eq!( + stdout, + "{\"p1\":1,\"p2\":2,\"p3\":3}\np1,p2,p3\ntrue false true\n" + ); +} + +#[test] +fn skipped_bindings_still_run_before_the_literal() { + let stdout = compile_and_run( + r#" +const o: any = {}; +const a = 2; +const b = a * 3; +o.sum = a + b; +o.b = b; +console.log(JSON.stringify(o)); +"#, + ); + assert_eq!(stdout, "{\"sum\":8,\"b\":6}\n"); +} + +#[test] +fn a_gap_statement_that_reads_the_binding_still_sees_the_object() { + // `peek()` does not name `o` at the call site, but it reads it. Sinking + // the allocation below the call would make this a TDZ ReferenceError. + let stdout = compile_and_run( + r#" +function peek(): any { return o; } +const o: any = {}; +const seen = peek(); +o.p1 = 1; +console.log(seen === o, seen.p1, JSON.stringify(o)); +"#, + ); + assert_eq!(stdout, "true 1 {\"p1\":1}\n"); +} + +#[test] +fn an_alias_taken_in_the_gap_still_observes_the_stores() { + let stdout = compile_and_run( + r#" +const o: any = {}; +const alias = o; +o.p1 = 1; +o.p2 = 2; +console.log(alias === o, JSON.stringify(alias)); +"#, + ); + assert_eq!(stdout, "true {\"p1\":1,\"p2\":2}\n"); +} + +#[test] +fn a_populated_literal_keeps_its_own_evaluation_order() { + let stdout = compile_and_run( + r#" +function run(): string { + try { + const o: any = { a: (y as any) }; + const y = 1; + o.b = 2; + return JSON.stringify(o); + } catch (e: any) { + return "threw " + e.name; + } +} +console.log(run()); +"#, + ); + assert_eq!(stdout, "threw ReferenceError\n"); +}