From 0a0cdefe08f6f359041aed87a37995e1ff121e82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 16 Sep 2026 10:39:11 +0200 Subject: [PATCH 1/3] fix(hir): keep observable user code out of a folded builder (#10357) Folding `const o = {}; o.a = v;` into `const o = { a: v }` evaluates `v` before `o` is initialized. `value_is_fold_safe` claimed such values run no user code, yet admitted two ways to run it. First, every converting operator: `"" + w`, `-w`, `w < 1`, `w == 1` and `` `${w}` `` all call `w`'s `valueOf`/`toString`/`Symbol.toPrimitive`. Second, every bare identifier: a name that is no binding reads the global object, whose property may be an accessor. When that code reads `o`, node builds the object and perry threw a TDZ ReferenceError. Dropping both would stop the fold's own motivating example (`o.b = r + i`) from folding. Instead, a new `FoldScope` asks whether anything can read the binding early. User code can only read a binding it names, so the scan looks for function-likes in the enclosing function (or module) that mention the builder's name. To avoid giving up folds, it ignores a function-like created after a `let`/`const` builder (the binding is fresh per loop pass and execution within a pass only moves forward) and one that re-binds the name at its own function level. It always counts a hoisted function declaration and any observer of a `var`, and treats `eval`, `with`, an export of the name and a module-level `var` as observers outright. Unobservable builders fold exactly as before. On an observable one, a conversion needs operands that are primitive by construction, and a read needs a proven declarative binding. `Visible` provides that proof from declarations that cover the whole region: a list's own declarations, parameters, function-scoped `var`s, loop heads, catch parameters, and module imports and declarations. It never counts a sibling block, an ambient `declare` (#10363), or anything in a module containing `with`. The gap statements from #10355 take the same answers. --- changelog.d/10361-builder-fold-toprimitive.md | 44 + crates/perry-hir/src/lower/builder_fold.rs | 1022 ++++++++++++++--- .../tests/builder_fold_conversion.rs | 561 +++++++++ .../builder_fold_conversion_semantics.rs | 195 ++++ 4 files changed, 1692 insertions(+), 130 deletions(-) create mode 100644 changelog.d/10361-builder-fold-toprimitive.md create mode 100644 crates/perry-hir/tests/builder_fold_conversion.rs create mode 100644 crates/perry/tests/builder_fold_conversion_semantics.rs diff --git a/changelog.d/10361-builder-fold-toprimitive.md b/changelog.d/10361-builder-fold-toprimitive.md new file mode 100644 index 0000000000..c7d7ec5b1c --- /dev/null +++ b/changelog.d/10361-builder-fold-toprimitive.md @@ -0,0 +1,44 @@ +Fixed the builder fold (#6812) turning a successful read into a TDZ +`ReferenceError` when a folded value runs an implicit conversion (#10357). +Folding `const o = {}; o.a = v;` into `const o = { a: v }` evaluates `v` before +`o` is initialized, which is unobservable only if `v` runs no user code that +can read `o`. `value_is_fold_safe` claimed exactly that, yet admitted every +converting operator: `"" + w`, `-w`, `w < 1`, `w == 1` and `` `${w}` `` all call +`w`'s `valueOf`/`toString`/`Symbol.toPrimitive`. With +`w = { valueOf() { return o; } }` node builds `{ a: "[object Object]" }` and +perry threw. + +Every such conversion could have been dropped from the predicate, but that +would have stopped the fold's own motivating example (`o.b = r + i`) from +folding. Instead the fold now asks whether anything *can* read the binding +early. A binding is only readable by code that names it, so user code reached +through a conversion must be a function-like nested in the builder's scope +that mentions the name. `FoldScope` scans each function body (or the module) +for such observers, and keeps that scan precise because every false positive +costs a fold: + +- a function-like created after a `let`/`const` builder cannot run before it + (the binding is fresh per loop pass and execution within a pass only moves + forward); a hoisted function declaration always counts, and so does any + observer of a `var`, whose binding every loop pass shares; +- a nested function-like that re-binds the name as a parameter or top-level + body declaration is not an observer (body declarations do not shadow + parameter defaults); +- `eval` (Perry compiles a literal `eval("o")` into a closure that reads + `o`), `with`, an exported name and a module-level `var` make a builder + observable outright. + +The same hazard hides in a bare identifier read: a name that resolves to no +binding reads the global object, and `Object.defineProperty(globalThis, "g", +{ get() { return o; } })` makes that read user code. `o.a = g` threw exactly +like the conversion. An unobservable builder folds exactly as before. On an +observable builder, a conversion needs operands that are primitive by +construction, and a read needs a proven declarative binding. `Visible` +proves that from a chain built only of declarations that cover the whole +region: a statement list's own declarations, parameters and +function-scoped `var`s, loop heads, catch parameters, and module imports and +declarations. A sibling block's declaration, an ambient `declare` (see +#10363), or any `with` in the module does not count. #10355's gap statements +share the same answers. The scan covers the enclosing function rather than +the folded statement list, because a `var` is function-scoped and a `let` in +one `case` is visible to every other case of its `switch`. diff --git a/crates/perry-hir/src/lower/builder_fold.rs b/crates/perry-hir/src/lower/builder_fold.rs index e91b777692..549c205573 100644 --- a/crates/perry-hir/src/lower/builder_fold.rs +++ b/crates/perry-hir/src/lower/builder_fold.rs @@ -33,6 +33,18 @@ //! - 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. +//! - #10357: values must not run user code that could read the binding +//! either. Calls, getters and the like never qualify. An implicit +//! conversion (`"" + w`, `-w`, `` `${w}` ``) calls a user `valueOf` too, +//! and a bare read of a name that is no binding reads the global object, +//! whose property may be an accessor. Both qualify only when nothing in +//! the builder's scope can read the binding early (`FoldScope`: no +//! function-like that names it and can already exist, no `eval`, `with` or +//! export, not a module-level `var`); otherwise a conversion needs operands +//! that are primitive by construction and a read needs a proven +//! declarative binding (`Visible`). Before #10357 both always qualified, +//! and `const o = {}; o.a = "" + w;` threw a TDZ `ReferenceError` whenever +//! `w.valueOf` read `o`. //! - If a value throws, the original leaves a partially-built object bound //! to a local no live code can reach (the following statements never run, //! and the values captured no reference to it) — indistinguishable. @@ -55,6 +67,7 @@ //! A miss here is only a missed optimization: unmatched shapes lower //! exactly as before. +use swc_common::{BytePos, Spanned}; use swc_ecma_ast as ast; use swc_ecma_visit::{Visit, VisitWith}; @@ -74,9 +87,11 @@ pub(crate) fn fold_builder_sequences(module: &ast::Module) -> Option bool { Some(ast::ModuleItem::Stmt(s)) => Some(s), _ => None, }; - let gap = fold_gap_len(name.as_str(), item_stmt); + let gap = fold_gap_len(name.as_str(), false, &Visible::ROOT, item_stmt); if item_stmt(gap).is_some_and(|b| assign_to_name_key(b, name.as_str()).is_some()) { return true; } @@ -210,7 +225,9 @@ fn stmts_have_candidate(stmts: &[ast::Stmt]) -> bool { let (Some(name), _) = decl_object_binding(s) else { continue; }; - let gap = fold_gap_len(name.as_str(), |k| stmts.get(i + 1 + k)); + let gap = fold_gap_len(name.as_str(), false, &Visible::ROOT, |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()) @@ -354,7 +371,12 @@ fn scan_expr(e: &ast::Expr) -> bool { } } -fn process_module_items(items: &mut [ast::ModuleItem], changed: &mut bool) { +fn process_module_items( + items: &mut [ast::ModuleItem], + changed: &mut bool, + scope: &FoldScope, + visible: &Visible<'_>, +) { // Fold across consecutive top-level Stmt items. let mut i = 0; while i < items.len() { @@ -365,16 +387,16 @@ fn process_module_items(items: &mut [ast::ModuleItem], changed: &mut bool) { j += 1; } // Temporarily extract the run as &mut [Stmt]-alike processing. - fold_module_stmt_run(&mut items[i..j], changed); + fold_module_stmt_run(&mut items[i..j], changed, scope, visible); for item in items[i..j].iter_mut() { if let ast::ModuleItem::Stmt(s) = item { - walk_stmt(s, changed); + walk_stmt(s, changed, scope, visible); } } i = j; } else { if let ast::ModuleItem::ModuleDecl(ast::ModuleDecl::ExportDecl(ed)) = &mut items[i] { - walk_decl(&mut ed.decl, changed); + walk_decl(&mut ed.decl, changed, visible); } i += 1; } @@ -383,7 +405,12 @@ fn process_module_items(items: &mut [ast::ModuleItem], changed: &mut bool) { /// Fold within a run of top-level ModuleItem::Stmt entries. Consumed /// assignment statements are replaced with `;` (EmptyStmt). -fn fold_module_stmt_run(items: &mut [ast::ModuleItem], changed: &mut bool) { +fn fold_module_stmt_run( + items: &mut [ast::ModuleItem], + changed: &mut bool, + scope: &FoldScope, + visible: &Visible<'_>, +) { let mut idx = 0; while idx < items.len() { let Some((name_start, existing)) = ({ @@ -402,14 +429,20 @@ fn fold_module_stmt_run(items: &mut [ast::ModuleItem], changed: &mut bool) { idx += 1; continue; } + let observable = match &items[idx] { + ast::ModuleItem::Stmt(s) => scope.observes(&name_start, s), + _ => true, + }; // 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, + fold_gap_len(&name_start, observable, visible, |k| { + match items.get(idx + 1 + k) { + Some(ast::ModuleItem::Stmt(s)) => Some(s), + _ => None, + } }) } else { 0 @@ -425,7 +458,9 @@ fn fold_module_stmt_run(items: &mut [ast::ModuleItem], changed: &mut bool) { let Some((key, value)) = assign_to_name_key(fs, &name_start) else { break; }; - if !fold_key_ok(&key, &keys) || !value_is_fold_safe(value, &name_start) { + if !fold_key_ok(&key, &keys) + || !value_is_fold_safe(value, &name_start, observable, visible) + { break; } if existing.len() + appended.len() >= MAX_FOLDED_PROPS { @@ -465,7 +500,14 @@ fn fold_module_stmt_run(items: &mut [ast::ModuleItem], changed: &mut bool) { } } -fn fold_stmts(stmts: &mut Vec, changed: &mut bool) { +fn fold_stmts( + stmts: &mut Vec, + changed: &mut bool, + scope: &FoldScope, + visible: &Visible<'_>, +) { + // A list's own declarations are bindings for the whole list. + let visible = &visible.child(declared_names(stmts)); let mut idx = 0; while idx < stmts.len() { let foldable = match decl_object_binding(&stmts[idx]) { @@ -478,9 +520,10 @@ fn fold_stmts(stmts: &mut Vec, changed: &mut bool) { idx += 1; continue; }; + let observable = scope.observes(&name, &stmts[idx]); // Empty literals only — see `fold_module_stmt_run`. let gap = if existing_len == 0 { - fold_gap_len(&name, |k| stmts.get(idx + 1 + k)) + fold_gap_len(&name, observable, visible, |k| stmts.get(idx + 1 + k)) } else { 0 }; @@ -491,7 +534,7 @@ fn fold_stmts(stmts: &mut Vec, changed: &mut bool) { let Some((key, value)) = assign_to_name_key(follower, &name) else { break; }; - if !fold_key_ok(&key, &keys) || !value_is_fold_safe(value, &name) { + if !fold_key_ok(&key, &keys) || !value_is_fold_safe(value, &name, observable, visible) { break; } if existing_len + appended.len() >= MAX_FOLDED_PROPS { @@ -521,7 +564,7 @@ fn fold_stmts(stmts: &mut Vec, changed: &mut bool) { idx += 1; } for s in stmts.iter_mut() { - walk_stmt(s, changed); + walk_stmt(s, changed, scope, visible); } } @@ -586,11 +629,18 @@ fn assign_to_name_key<'a>( /// 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 { +fn fold_gap_len<'a>( + name: &str, + observable: bool, + visible: &Visible<'_>, + 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) { + if assign_to_name_key(s, name).is_some() + || !gap_stmt_is_hoistable(s, name, observable, visible) + { break; } gap += 1; @@ -613,26 +663,29 @@ fn fold_gap_len<'a>(name: &str, at: impl Fn(usize) -> Option<&'a ast::Stmt>) -> /// 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. +/// expression statements buys exactly that test — including its #10357 +/// rule for implicit conversions, which take the same builder's +/// `observable` answer so the two sides cannot drift apart. /// /// 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 { +fn gap_stmt_is_hoistable( + s: &ast::Stmt, + name: &str, + observable: bool, + visible: &Visible<'_>, +) -> 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::Expr(es) => value_is_fold_safe(&es.expr, name, observable, visible), 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)) + .is_none_or(|init| value_is_fold_safe(init, name, observable, visible)) }), _ => false, } @@ -707,55 +760,78 @@ fn append_props(s: &mut ast::Stmt, appended: Vec<(ast::PropName, Box) } /// May this VALUE expression fold into a literal that now evaluates it -/// BEFORE the builder binding is initialized? Only expressions that -/// provably cannot execute user code qualify — a call, `new`, member read -/// (getters), optional chain, tagged template, spread (iterator -/// protocols), `in`/`instanceof` (traps / `Symbol.hasInstance`), -/// `await`/`yield`, or any function-bearing form could reach the binding -/// through a closure or trap WITHOUT naming it (e.g. a hoisted -/// `function f() { return o.a; }` observed via `o.b = f()` — folding +/// BEFORE the builder binding is initialized? +/// +/// A call, `new`, member read (getters), optional chain, tagged template, +/// spread (iterator protocols), `in`/`instanceof` (traps / +/// `Symbol.hasInstance`), `await`/`yield`, or any function-bearing form can +/// run arbitrary user code, so none of them ever qualifies — that code could +/// reach the binding through a closure WITHOUT the value naming it (e.g. a +/// hoisted `function f() { return o.a; }` observed via `o.b = f()` — folding /// would turn the original's successful read into a TDZ ReferenceError). /// Reading OTHER identifiers is safe (identical evaluation either side of /// the allocation); reading the builder's own name is excluded directly. -fn value_is_fold_safe(e: &ast::Expr, name: &str) -> bool { +/// +/// #10357: an implicit conversion runs user code too. `"" + w`, `-w`, `w < 1` +/// and `` `${w}` `` call `w`'s `valueOf`/`toString`/`Symbol.toPrimitive`. That +/// is only a problem when such code can read the binding at all, which is +/// what `observable` answers (`FoldScope::observes`): when nothing in scope +/// can, the conversion is unobservable and folds as before; when something +/// can, a converting operator qualifies only on operands that are primitive +/// by construction (`is_primitive_valued`), where no user code runs. +/// +/// A bare identifier read is the same hazard in disguise: one that resolves +/// to no binding reads a property of the global object, and that property +/// can be an accessor whose getter reads the builder. On an observable +/// builder a read therefore qualifies only when `visible` proves it resolves +/// to a declarative binding. +fn value_is_fold_safe(e: &ast::Expr, name: &str, observable: bool, visible: &Visible<'_>) -> bool { use ast::Expr as E; + let safe = |x: &ast::Expr| value_is_fold_safe(x, name, observable, visible); + let read_ok = |sym: &str| sym != name && (!observable || visible.resolves(sym)); match e { E::Lit(_) | E::This(_) => true, - E::Ident(i) => i.sym.as_ref() != name, - E::Paren(p) => value_is_fold_safe(&p.expr, name), - E::Tpl(t) => t.exprs.iter().all(|x| value_is_fold_safe(x, name)), - E::Unary(u) => u.op != ast::UnaryOp::Delete && value_is_fold_safe(&u.arg, name), + E::Ident(i) => read_ok(i.sym.as_ref()), + E::Paren(p) => safe(&p.expr), + E::Tpl(t) => t + .exprs + .iter() + .all(|x| safe(x) && (!observable || is_primitive_valued(x))), + E::Unary(u) => { + u.op != ast::UnaryOp::Delete + && safe(&u.arg) + && (!observable || !unary_converts(u.op) || is_primitive_valued(&u.arg)) + } E::Bin(b) => { !matches!(b.op, ast::BinaryOp::In | ast::BinaryOp::InstanceOf) - && value_is_fold_safe(&b.left, name) - && value_is_fold_safe(&b.right, name) - } - E::Cond(c) => { - value_is_fold_safe(&c.test, name) - && value_is_fold_safe(&c.cons, name) - && value_is_fold_safe(&c.alt, name) + && safe(&b.left) + && safe(&b.right) + && (!observable + || !binary_converts(b.op) + || (is_primitive_valued(&b.left) && is_primitive_valued(&b.right))) } - E::Seq(sq) => sq.exprs.iter().all(|x| value_is_fold_safe(x, name)), + E::Cond(c) => safe(&c.test) && safe(&c.cons) && safe(&c.alt), + E::Seq(sq) => sq.exprs.iter().all(|x| safe(x)), E::Array(a) => a.elems.iter().all(|el| match el { None => true, - Some(el) => el.spread.is_none() && value_is_fold_safe(&el.expr, name), + Some(el) => el.spread.is_none() && safe(&el.expr), }), E::Object(o) => o.props.iter().all(|p| match p { ast::PropOrSpread::Spread(_) => false, ast::PropOrSpread::Prop(prop) => match &**prop { ast::Prop::KeyValue(kv) => { matches!(kv.key, ast::PropName::Ident(_) | ast::PropName::Str(_)) - && value_is_fold_safe(&kv.value, name) + && safe(&kv.value) } - ast::Prop::Shorthand(i) => i.sym.as_ref() != name, + ast::Prop::Shorthand(i) => read_ok(i.sym.as_ref()), _ => false, }, }), - E::TsAs(t) => value_is_fold_safe(&t.expr, name), - E::TsNonNull(t) => value_is_fold_safe(&t.expr, name), - E::TsTypeAssertion(t) => value_is_fold_safe(&t.expr, name), - E::TsSatisfies(t) => value_is_fold_safe(&t.expr, name), - E::TsConstAssertion(t) => value_is_fold_safe(&t.expr, name), + E::TsAs(t) => safe(&t.expr), + E::TsNonNull(t) => safe(&t.expr), + E::TsTypeAssertion(t) => safe(&t.expr), + E::TsSatisfies(t) => safe(&t.expr), + E::TsConstAssertion(t) => safe(&t.expr), // Everything else — calls, news, member/optional access, tagged // templates, await/yield, updates, assignments, function-bearing // forms, unknown variants — may execute user code: unsafe to hoist @@ -764,51 +840,488 @@ fn value_is_fold_safe(e: &ast::Expr, name: &str) -> bool { } } -fn walk_decl(d: &mut ast::Decl, changed: &mut bool) { - if let ast::Decl::Fn(f) = d { - if let Some(body) = &mut f.function.body { - fold_stmts(&mut body.stmts, changed); +/// Does this unary operator convert its operand (`ToNumeric`)? `!` is +/// `ToBoolean`, `typeof`/`void` inspect without converting — none of those +/// can call user code. +fn unary_converts(op: ast::UnaryOp) -> bool { + matches!( + op, + ast::UnaryOp::Minus | ast::UnaryOp::Plus | ast::UnaryOp::Tilde + ) +} + +/// Does this binary operator convert its operands? Only strict equality and +/// the short-circuiting operators (`ToBoolean`, or no conversion at all) +/// cannot reach `valueOf`/`toString`. Loose equality can — an object +/// compared with a primitive goes through `ToPrimitive`. +fn binary_converts(op: ast::BinaryOp) -> bool { + !matches!( + op, + ast::BinaryOp::EqEqEq + | ast::BinaryOp::NotEqEq + | ast::BinaryOp::LogicalAnd + | ast::BinaryOp::LogicalOr + | ast::BinaryOp::NullishCoalescing + ) +} + +/// Is this expression's value a primitive by construction, whatever its +/// operands are? A conversion of a primitive runs no user code. Deliberately +/// syntactic: identifiers are never primitive here (no type facts at this +/// stage, and a `: number` annotation is not a proof), and a regex literal is +/// an object whose `toString` is user-patchable. +fn is_primitive_valued(e: &ast::Expr) -> bool { + use ast::Expr as E; + match e { + E::Lit(lit) => !matches!(lit, ast::Lit::Regex(_) | ast::Lit::JSXText(_)), + // A template is a string and every unary operator yields a + // primitive, whatever their operands. + E::Tpl(_) | E::Unary(_) => true, + E::Bin(b) => match b.op { + // These return one of their operands. + ast::BinaryOp::LogicalAnd + | ast::BinaryOp::LogicalOr + | ast::BinaryOp::NullishCoalescing => { + is_primitive_valued(&b.left) && is_primitive_valued(&b.right) + } + _ => true, + }, + E::Cond(c) => is_primitive_valued(&c.cons) && is_primitive_valued(&c.alt), + E::Seq(sq) => sq.exprs.last().is_some_and(|x| is_primitive_valued(x)), + E::Paren(p) => is_primitive_valued(&p.expr), + E::TsAs(t) => is_primitive_valued(&t.expr), + E::TsNonNull(t) => is_primitive_valued(&t.expr), + E::TsTypeAssertion(t) => is_primitive_valued(&t.expr), + E::TsSatisfies(t) => is_primitive_valued(&t.expr), + E::TsConstAssertion(t) => is_primitive_valued(&t.expr), + _ => false, + } +} + +/// Which builders in one var scope (a function-like body, or the module) +/// could be read by user code before their folded literal initializes them +/// (#10357). +/// +/// A binding can only be read by code that NAMES it, so the user code a +/// conversion reaches must be a function-like nested in the binding's own +/// scope whose body mentions the name. Three refinements keep that precise, +/// because every false positive costs a fold: +/// +/// - **Position.** A `let`/`const` binding is fresh on every pass through an +/// enclosing loop, and execution within one pass only moves forward, so a +/// function-like CREATED after the declaration cannot run before it. A +/// hoisted function declaration exists from scope entry and always counts. +/// A `var` binding is shared by every pass, so a closure made later in one +/// pass can run during the next pass's fold: for a `var`, any mention +/// counts. +/// - **Shadowing.** A nested function-like that re-binds the name — as a +/// parameter, or a declaration at the top level of its body — reads its own +/// binding, not the builder. Body declarations do not shadow parameter +/// defaults, which are evaluated in a scope of their own; block-level +/// re-declarations are not tracked and so conservatively still count. +/// - **Escapes the name scan cannot see** make every builder observable: +/// `eval` anywhere in the scope (Perry compiles a literal `eval("o")` into a +/// closure that reads `o`), `with` (identifier resolution becomes property +/// lookup), an export of the name (an importer reads the live binding), and +/// a module-level `var` (a global-object property). +/// +/// The scope is the whole enclosing function (or module), not the statement +/// list being folded: a `var` is function-scoped, and a `let` in one `case` +/// is visible to every other case of its `switch`. +#[derive(Default)] +struct FoldScope { + observers: Vec, + /// `eval` or `with` inside the scope. + opaque: bool, + /// Module top level. + top_level: bool, +} + +/// The function-likes that name one builder. +struct Observer { + name: String, + /// Named from code that exists before the scope runs anything: a hoisted + /// function declaration, or an export. + always: bool, + /// Where the earliest other function-like naming it starts. + first_lo: Option, +} + +impl FoldScope { + fn of_module(module: &ast::Module) -> Self { + let mut scope = Self::scan(|v| module.visit_with(v)); + scope.top_level = true; + scope + } + + fn of_body(stmts: &[ast::Stmt]) -> Self { + Self::scan(|v| stmts.visit_with(v)) + } + + /// Two passes, and the second only when it can change an answer: the + /// scope must bind an object literal (a builder candidate) at its own + /// depth. + fn scan(visit: impl Fn(&mut dyn Visit)) -> Self { + let mut own = OwnScopeScan::default(); + visit(&mut own); + if own.builders.is_empty() { + return Self::default(); + } + let mut observers = ObserverScan::new(&own.builders); + visit(&mut observers); + Self { + observers: observers.observers, + opaque: observers.opaque, + top_level: false, } } - if let ast::Decl::Class(c) = d { - walk_class(&mut c.class, changed); + + /// Is the builder declared by `decl` (bound to `name`) observable? + fn observes(&self, name: &str, decl: &ast::Stmt) -> bool { + let is_var = matches!( + decl, + ast::Stmt::Decl(ast::Decl::Var(v)) if v.kind == ast::VarDeclKind::Var + ); + if self.opaque || (self.top_level && is_var) { + return true; + } + let Some(observer) = self.observers.iter().find(|o| o.name == name) else { + return false; + }; + observer.always || is_var || observer.first_lo.is_some_and(|lo| lo < decl.span().hi) } - if let ast::Decl::Var(v) = d { - for decl in &mut v.decls { - if let Some(init) = &mut decl.init { - walk_expr(init, changed); +} + +/// Pass 1: the scope's OWN depth — never enters a nested function-like. +/// Collects object-literal binding names. +#[derive(Default)] +struct OwnScopeScan { + builders: Vec, +} + +impl Visit for OwnScopeScan { + fn visit_var_declarator(&mut self, d: &ast::VarDeclarator) { + if let (ast::Pat::Ident(bi), Some(init)) = (&d.name, d.init.as_deref()) { + if matches!(init, ast::Expr::Object(_)) { + let name = bi.id.sym.to_string(); + if !self.builders.contains(&name) { + self.builders.push(name); + } } } + d.visit_children_with(self); } + // Nested function-likes are separate scopes with their own scan. + fn visit_function(&mut self, _: &ast::Function) {} + fn visit_arrow_expr(&mut self, _: &ast::ArrowExpr) {} + fn visit_constructor(&mut self, _: &ast::Constructor) {} + fn visit_getter_prop(&mut self, _: &ast::GetterProp) {} + fn visit_setter_prop(&mut self, _: &ast::SetterProp) {} + fn visit_class_prop(&mut self, _: &ast::ClassProp) {} + fn visit_private_prop(&mut self, _: &ast::PrivateProp) {} + fn visit_auto_accessor(&mut self, _: &ast::AutoAccessor) {} + fn visit_static_block(&mut self, _: &ast::StaticBlock) {} +} + +/// Pass 2: every depth. Records where each builder name is mentioned by a +/// nested function-like or an export, and any `eval`/`with`. +struct ObserverScan<'b> { + builders: &'b [String], + depth: u32, + in_export: bool, + /// Set by a function declaration for the function it declares. + declared: bool, + /// The outermost nested function-like being walked. + frame_hoisted: bool, + frame_lo: BytePos, + /// Builder names re-bound by an enclosing nested function-like. + shadowed: Vec, + observers: Vec, + opaque: bool, } -fn walk_class(class: &mut ast::Class, changed: &mut bool) { +impl<'b> ObserverScan<'b> { + fn new(builders: &'b [String]) -> Self { + Self { + builders, + depth: 0, + in_export: false, + declared: false, + frame_hoisted: false, + frame_lo: BytePos::DUMMY, + shadowed: Vec::new(), + observers: Vec::new(), + opaque: false, + } + } + + fn enter(&mut self, lo: BytePos) -> usize { + let declared = std::mem::take(&mut self.declared); + if self.depth == 0 { + self.frame_hoisted = declared; + self.frame_lo = lo; + } + self.depth += 1; + self.shadowed.len() + } + + fn leave(&mut self, mark: usize) { + self.depth -= 1; + self.shadowed.truncate(mark); + } + + fn bind(&mut self, sym: &str) { + if self.builders.iter().any(|b| b == sym) { + self.shadowed.push(sym.to_string()); + } + } + + fn bind_pat(&mut self, pat: &ast::Pat) { + if let ast::Pat::Ident(bi) = pat { + self.bind(bi.id.sym.as_ref()); + } + } + + /// Declarations at the top level of a function-like body bind for the + /// whole body. + fn bind_body(&mut self, stmts: &[ast::Stmt]) { + for stmt in stmts { + match stmt { + ast::Stmt::Decl(ast::Decl::Var(v)) => { + for d in &v.decls { + self.bind_pat(&d.name); + } + } + ast::Stmt::Decl(ast::Decl::Fn(f)) => self.bind(f.ident.sym.as_ref()), + ast::Stmt::Decl(ast::Decl::Class(c)) => self.bind(c.ident.sym.as_ref()), + _ => {} + } + } + } + + fn record(&mut self, sym: &str) { + if !self.builders.iter().any(|b| b == sym) || self.shadowed.iter().any(|s| s == sym) { + return; + } + let always = self.in_export || self.frame_hoisted; + let lo = self.frame_lo; + match self.observers.iter_mut().find(|o| o.name == sym) { + Some(observer) => { + observer.always |= always; + if !always { + observer.first_lo = Some(observer.first_lo.map_or(lo, |first| first.min(lo))); + } + } + None => self.observers.push(Observer { + name: sym.to_string(), + always, + first_lo: (!always).then_some(lo), + }), + } + } +} + +impl Visit for ObserverScan<'_> { + fn visit_ident(&mut self, i: &ast::Ident) { + let sym = i.sym.as_ref(); + if sym == "eval" { + self.opaque = true; + } + if self.depth > 0 || self.in_export { + self.record(sym); + } + } + fn visit_with_stmt(&mut self, w: &ast::WithStmt) { + self.opaque = true; + w.visit_children_with(self); + } + fn visit_module_decl(&mut self, d: &ast::ModuleDecl) { + let outer = std::mem::replace(&mut self.in_export, true); + d.visit_children_with(self); + self.in_export = outer; + } + fn visit_fn_decl(&mut self, f: &ast::FnDecl) { + // The declared name is a binding, not a read. + self.declared = true; + f.function.visit_with(self); + } + fn visit_fn_expr(&mut self, f: &ast::FnExpr) { + // A named function expression binds its own name inside itself. + let mark = self.shadowed.len(); + if let Some(ident) = &f.ident { + self.bind(ident.sym.as_ref()); + } + f.function.visit_with(self); + self.shadowed.truncate(mark); + } + fn visit_function(&mut self, f: &ast::Function) { + let mark = self.enter(f.span.lo); + for param in &f.params { + self.bind_pat(¶m.pat); + } + f.decorators.visit_with(self); + f.params.visit_with(self); + if let Some(body) = &f.body { + self.bind_body(&body.stmts); + body.visit_with(self); + } + self.leave(mark); + } + fn visit_arrow_expr(&mut self, a: &ast::ArrowExpr) { + let mark = self.enter(a.span.lo); + for param in &a.params { + self.bind_pat(param); + } + a.params.visit_with(self); + match &*a.body { + ast::BlockStmtOrExpr::BlockStmt(body) => { + self.bind_body(&body.stmts); + body.visit_with(self); + } + ast::BlockStmtOrExpr::Expr(body) => body.visit_with(self), + } + self.leave(mark); + } + fn visit_constructor(&mut self, c: &ast::Constructor) { + let mark = self.enter(c.span.lo); + for param in &c.params { + match param { + ast::ParamOrTsParamProp::Param(p) => self.bind_pat(&p.pat), + ast::ParamOrTsParamProp::TsParamProp(p) => { + if let ast::TsParamPropParam::Ident(bi) = &p.param { + self.bind(bi.id.sym.as_ref()); + } + } + } + } + c.key.visit_with(self); + c.params.visit_with(self); + if let Some(body) = &c.body { + self.bind_body(&body.stmts); + body.visit_with(self); + } + self.leave(mark); + } + fn visit_getter_prop(&mut self, g: &ast::GetterProp) { + let mark = self.enter(g.span.lo); + g.key.visit_with(self); + if let Some(body) = &g.body { + self.bind_body(&body.stmts); + body.visit_with(self); + } + self.leave(mark); + } + fn visit_setter_prop(&mut self, st: &ast::SetterProp) { + let mark = self.enter(st.span.lo); + self.bind_pat(&st.param); + st.key.visit_with(self); + st.param.visit_with(self); + if let Some(body) = &st.body { + self.bind_body(&body.stmts); + body.visit_with(self); + } + self.leave(mark); + } + fn visit_class_prop(&mut self, p: &ast::ClassProp) { + let mark = self.enter(p.span.lo); + p.visit_children_with(self); + self.leave(mark); + } + fn visit_private_prop(&mut self, p: &ast::PrivateProp) { + let mark = self.enter(p.span.lo); + p.visit_children_with(self); + self.leave(mark); + } + fn visit_auto_accessor(&mut self, a: &ast::AutoAccessor) { + let mark = self.enter(a.span.lo); + a.visit_children_with(self); + self.leave(mark); + } + fn visit_static_block(&mut self, b: &ast::StaticBlock) { + let mark = self.enter(b.span.lo); + self.bind_body(&b.body.stmts); + b.body.visit_with(self); + self.leave(mark); + } +} + +fn walk_decl(d: &mut ast::Decl, changed: &mut bool, visible: &Visible<'_>) { + match d { + ast::Decl::Fn(f) => { + let params = param_names(f.function.params.iter().map(|p| &p.pat)); + if let Some(body) = &mut f.function.body { + fold_body(&mut body.stmts, changed, params, visible); + } + } + ast::Decl::Class(c) => walk_class(&mut c.class, changed, visible), + ast::Decl::Var(v) => { + for decl in &mut v.decls { + if let Some(init) = &mut decl.init { + walk_expr(init, changed, visible); + } + } + } + _ => {} + } +} + +/// Fold a function-like body: its own var scope, so its own `FoldScope`, and +/// its parameters and function-scoped `var`s as bindings for all of it. +fn fold_body( + stmts: &mut Vec, + changed: &mut bool, + mut bindings: Vec, + parent: &Visible<'_>, +) { + let scope = FoldScope::of_body(stmts); + bindings.extend(var_names(stmts)); + let visible = parent.child(bindings); + fold_stmts(stmts, changed, &scope, &visible); +} + +fn walk_class(class: &mut ast::Class, changed: &mut bool, visible: &Visible<'_>) { for member in &mut class.body { match member { ast::ClassMember::Method(m) => { + let params = param_names(m.function.params.iter().map(|p| &p.pat)); if let Some(body) = &mut m.function.body { - fold_stmts(&mut body.stmts, changed); + fold_body(&mut body.stmts, changed, params, visible); } } ast::ClassMember::PrivateMethod(m) => { + let params = param_names(m.function.params.iter().map(|p| &p.pat)); if let Some(body) = &mut m.function.body { - fold_stmts(&mut body.stmts, changed); + fold_body(&mut body.stmts, changed, params, visible); } } ast::ClassMember::Constructor(c) => { + let mut params = Vec::new(); + for param in &c.params { + match param { + ast::ParamOrTsParamProp::Param(p) => collect_pat_names(&p.pat, &mut params), + ast::ParamOrTsParamProp::TsParamProp(p) => match &p.param { + ast::TsParamPropParam::Ident(bi) => params.push(bi.id.sym.to_string()), + ast::TsParamPropParam::Assign(a) => { + collect_pat_names(&a.left, &mut params) + } + }, + } + } if let Some(body) = &mut c.body { - fold_stmts(&mut body.stmts, changed); + fold_body(&mut body.stmts, changed, params, visible); } } - ast::ClassMember::StaticBlock(b) => fold_stmts(&mut b.body.stmts, changed), + ast::ClassMember::StaticBlock(b) => { + fold_body(&mut b.body.stmts, changed, Vec::new(), visible) + } ast::ClassMember::ClassProp(prop) => { if let Some(v) = &mut prop.value { - walk_expr(v, changed); + walk_expr(v, changed, visible); } } ast::ClassMember::PrivateProp(prop) => { if let Some(v) = &mut prop.value { - walk_expr(v, changed); + walk_expr(v, changed, visible); } } _ => {} @@ -816,104 +1329,136 @@ fn walk_class(class: &mut ast::Class, changed: &mut bool) { } } -fn walk_stmt(s: &mut ast::Stmt, changed: &mut bool) { +fn walk_stmt(s: &mut ast::Stmt, changed: &mut bool, scope: &FoldScope, visible: &Visible<'_>) { match s { - ast::Stmt::Block(b) => fold_stmts(&mut b.stmts, changed), + ast::Stmt::Block(b) => fold_stmts(&mut b.stmts, changed, scope, visible), ast::Stmt::If(i) => { - walk_stmt(&mut i.cons, changed); + walk_stmt(&mut i.cons, changed, scope, visible); if let Some(alt) = &mut i.alt { - walk_stmt(alt, changed); + walk_stmt(alt, changed, scope, visible); } - walk_expr(&mut i.test, changed); + walk_expr(&mut i.test, changed, visible); } ast::Stmt::While(w) => { - walk_expr(&mut w.test, changed); - walk_stmt(&mut w.body, changed); + walk_expr(&mut w.test, changed, visible); + walk_stmt(&mut w.body, changed, scope, visible); } ast::Stmt::DoWhile(d) => { - walk_stmt(&mut d.body, changed); - walk_expr(&mut d.test, changed); + walk_stmt(&mut d.body, changed, scope, visible); + walk_expr(&mut d.test, changed, visible); } ast::Stmt::For(f) => { + let mut head = Vec::new(); + if let Some(ast::VarDeclOrExpr::VarDecl(v)) = &f.init { + collect_var_decl_names(v, &mut head); + } + let visible = &visible.child(head); if let Some(ast::VarDeclOrExpr::Expr(e)) = &mut f.init { - walk_expr(e, changed); + walk_expr(e, changed, visible); } if let Some(t) = &mut f.test { - walk_expr(t, changed); + walk_expr(t, changed, visible); } if let Some(u) = &mut f.update { - walk_expr(u, changed); + walk_expr(u, changed, visible); } - walk_stmt(&mut f.body, changed); + walk_stmt(&mut f.body, changed, scope, visible); + } + ast::Stmt::ForIn(f) => { + let visible = &visible.child(for_head_names(&f.left)); + walk_stmt(&mut f.body, changed, scope, visible); + } + ast::Stmt::ForOf(f) => { + let visible = &visible.child(for_head_names(&f.left)); + walk_stmt(&mut f.body, changed, scope, visible); } - ast::Stmt::ForIn(f) => walk_stmt(&mut f.body, changed), - ast::Stmt::ForOf(f) => walk_stmt(&mut f.body, changed), - ast::Stmt::Labeled(l) => walk_stmt(&mut l.body, changed), + ast::Stmt::Labeled(l) => walk_stmt(&mut l.body, changed, scope, visible), ast::Stmt::Try(t) => { - fold_stmts(&mut t.block.stmts, changed); + fold_stmts(&mut t.block.stmts, changed, scope, visible); if let Some(h) = &mut t.handler { - fold_stmts(&mut h.body.stmts, changed); + let mut param = Vec::new(); + if let Some(p) = &h.param { + collect_pat_names(p, &mut param); + } + fold_stmts(&mut h.body.stmts, changed, scope, &visible.child(param)); } if let Some(f) = &mut t.finalizer { - fold_stmts(&mut f.stmts, changed); + fold_stmts(&mut f.stmts, changed, scope, visible); } } ast::Stmt::Switch(sw) => { - walk_expr(&mut sw.discriminant, changed); + walk_expr(&mut sw.discriminant, changed, visible); + // A declaration in any case is scoped to the whole switch block. + let mut declared = Vec::new(); + for case in &sw.cases { + declared.extend(declared_names(&case.cons)); + } + let visible = &visible.child(declared); for case in &mut sw.cases { - fold_stmts(&mut case.cons, changed); + fold_stmts(&mut case.cons, changed, scope, visible); } } - ast::Stmt::Decl(d) => walk_decl(d, changed), - ast::Stmt::Expr(es) => walk_expr(&mut es.expr, changed), + ast::Stmt::Decl(d) => walk_decl(d, changed, visible), + ast::Stmt::Expr(es) => walk_expr(&mut es.expr, changed, visible), ast::Stmt::Return(r) => { if let Some(e) = &mut r.arg { - walk_expr(e, changed); + walk_expr(e, changed, visible); } } - ast::Stmt::Throw(t) => walk_expr(&mut t.arg, changed), + ast::Stmt::Throw(t) => walk_expr(&mut t.arg, changed, visible), _ => {} } } /// Recurse into expressions only far enough to find nested function bodies. -fn walk_expr(e: &mut ast::Expr, changed: &mut bool) { +fn walk_expr(e: &mut ast::Expr, changed: &mut bool, visible: &Visible<'_>) { use ast::Expr as E; match e { E::Fn(f) => { + let mut bindings = param_names(f.function.params.iter().map(|p| &p.pat)); + if let Some(ident) = &f.ident { + bindings.push(ident.sym.to_string()); + } if let Some(body) = &mut f.function.body { - fold_stmts(&mut body.stmts, changed); + fold_body(&mut body.stmts, changed, bindings, visible); } } - E::Arrow(a) => match &mut *a.body { - ast::BlockStmtOrExpr::BlockStmt(b) => fold_stmts(&mut b.stmts, changed), - ast::BlockStmtOrExpr::Expr(e) => walk_expr(e, changed), - }, - E::Class(c) => walk_class(&mut c.class, changed), + E::Arrow(a) => { + let params = param_names(a.params.iter()); + match &mut *a.body { + ast::BlockStmtOrExpr::BlockStmt(b) => { + fold_body(&mut b.stmts, changed, params, visible) + } + ast::BlockStmtOrExpr::Expr(e) => walk_expr(e, changed, &visible.child(params)), + } + } + E::Class(c) => walk_class(&mut c.class, changed, visible), E::Array(a) => { for el in a.elems.iter_mut().flatten() { - walk_expr(&mut el.expr, changed); + walk_expr(&mut el.expr, changed, visible); } } E::Object(o) => { for p in &mut o.props { match p { - ast::PropOrSpread::Spread(sp) => walk_expr(&mut sp.expr, changed), + ast::PropOrSpread::Spread(sp) => walk_expr(&mut sp.expr, changed, visible), ast::PropOrSpread::Prop(prop) => match &mut **prop { - ast::Prop::KeyValue(kv) => walk_expr(&mut kv.value, changed), + ast::Prop::KeyValue(kv) => walk_expr(&mut kv.value, changed, visible), ast::Prop::Method(m) => { + let params = param_names(m.function.params.iter().map(|p| &p.pat)); if let Some(body) = &mut m.function.body { - fold_stmts(&mut body.stmts, changed); + fold_body(&mut body.stmts, changed, params, visible); } } ast::Prop::Getter(g) => { if let Some(body) = &mut g.body { - fold_stmts(&mut body.stmts, changed); + fold_body(&mut body.stmts, changed, Vec::new(), visible); } } ast::Prop::Setter(sst) => { + let params = param_names(std::iter::once(&*sst.param)); if let Some(body) = &mut sst.body { - fold_stmts(&mut body.stmts, changed); + fold_body(&mut body.stmts, changed, params, visible); } } _ => {} @@ -921,59 +1466,276 @@ fn walk_expr(e: &mut ast::Expr, changed: &mut bool) { } } } - E::Unary(u) => walk_expr(&mut u.arg, changed), - E::Update(u) => walk_expr(&mut u.arg, changed), + E::Unary(u) => walk_expr(&mut u.arg, changed, visible), + E::Update(u) => walk_expr(&mut u.arg, changed, visible), E::Bin(b) => { - walk_expr(&mut b.left, changed); - walk_expr(&mut b.right, changed); + walk_expr(&mut b.left, changed, visible); + walk_expr(&mut b.right, changed, visible); } - E::Assign(a) => walk_expr(&mut a.right, changed), - E::Member(m) => walk_expr(&mut m.obj, changed), + E::Assign(a) => walk_expr(&mut a.right, changed, visible), + E::Member(m) => walk_expr(&mut m.obj, changed, visible), E::Cond(c) => { - walk_expr(&mut c.test, changed); - walk_expr(&mut c.cons, changed); - walk_expr(&mut c.alt, changed); + walk_expr(&mut c.test, changed, visible); + walk_expr(&mut c.cons, changed, visible); + walk_expr(&mut c.alt, changed, visible); } E::Call(c) => { if let ast::Callee::Expr(e) = &mut c.callee { - walk_expr(e, changed); + walk_expr(e, changed, visible); } for a in &mut c.args { - walk_expr(&mut a.expr, changed); + walk_expr(&mut a.expr, changed, visible); } } E::New(n) => { - walk_expr(&mut n.callee, changed); + walk_expr(&mut n.callee, changed, visible); if let Some(args) = &mut n.args { for a in args { - walk_expr(&mut a.expr, changed); + walk_expr(&mut a.expr, changed, visible); } } } E::Seq(s) => { for e in &mut s.exprs { - walk_expr(e, changed); + walk_expr(e, changed, visible); } } E::Tpl(t) => { for e in &mut t.exprs { - walk_expr(e, changed); + walk_expr(e, changed, visible); } } - E::Paren(p) => walk_expr(&mut p.expr, changed), - E::Await(a) => walk_expr(&mut a.arg, changed), + E::Paren(p) => walk_expr(&mut p.expr, changed, visible), + E::Await(a) => walk_expr(&mut a.arg, changed, visible), E::Yield(y) => { if let Some(a) = &mut y.arg { - walk_expr(a, changed); + walk_expr(a, changed, visible); + } + } + E::TsAs(t) => walk_expr(&mut t.expr, changed, visible), + E::TsNonNull(t) => walk_expr(&mut t.expr, changed, visible), + E::TsSatisfies(t) => walk_expr(&mut t.expr, changed, visible), + _ => {} + } +} + +/// Names that resolve to a declarative binding at a point in the source, +/// innermost scope first (#10357). A read of any other name may resolve to a +/// property of the global object, and that property may be an accessor. +/// +/// Built only from declarations that cover the WHOLE region they are attached +/// to: a statement list's own declarations, a function's parameters and +/// function-scoped `var`s, a loop head, a catch parameter, and the module's +/// imports and declarations. A declaration in a sibling block is not in the +/// chain, so a same-named read outside it correctly stays unresolved. An +/// ambient `declare` binds nothing at run time and is never collected. A +/// `with` statement anywhere in the module makes every read unresolvable — +/// its object is consulted, through `has`/`get`, before any binding. +struct Visible<'p> { + names: Vec, + parent: Option<&'p Visible<'p>>, + /// Nothing resolves (`with` in the module). + opaque: bool, +} + +impl Visible<'static> { + /// Resolves only the global constants; for predicates that are never + /// consulted on an observable builder. + const ROOT: Visible<'static> = Visible { + names: Vec::new(), + parent: None, + opaque: false, + }; + + fn of_module(module: &ast::Module) -> Self { + let mut names = Vec::new(); + let mut opaque = false; + for item in &module.body { + match item { + ast::ModuleItem::ModuleDecl(ast::ModuleDecl::Import(import)) => { + for specifier in &import.specifiers { + let local = match specifier { + ast::ImportSpecifier::Named(s) => &s.local, + ast::ImportSpecifier::Default(s) => &s.local, + ast::ImportSpecifier::Namespace(s) => &s.local, + }; + names.push(local.sym.to_string()); + } + } + ast::ModuleItem::ModuleDecl(ast::ModuleDecl::ExportDecl(e)) => { + collect_decl_names(&e.decl, &mut names); + } + ast::ModuleItem::Stmt(stmt) => collect_stmt_decl_names(stmt, &mut names), + _ => {} + } + } + let stmts: Vec<&ast::Stmt> = module + .body + .iter() + .filter_map(|item| match item { + ast::ModuleItem::Stmt(s) => Some(s), + _ => None, + }) + .collect(); + for stmt in stmts { + let mut vars = VarNames::default(); + stmt.visit_with(&mut vars); + names.extend(vars.names); + } + struct WithFinder(bool); + impl Visit for WithFinder { + fn visit_with_stmt(&mut self, _: &ast::WithStmt) { + self.0 = true; } } - E::TsAs(t) => walk_expr(&mut t.expr, changed), - E::TsNonNull(t) => walk_expr(&mut t.expr, changed), - E::TsSatisfies(t) => walk_expr(&mut t.expr, changed), + let mut with = WithFinder(false); + module.visit_with(&mut with); + opaque |= with.0; + Visible { + names, + parent: None, + opaque, + } + } +} + +impl<'p> Visible<'p> { + fn child<'c>(&'c self, names: Vec) -> Visible<'c> { + Visible { + names, + parent: Some(self), + opaque: self.opaque, + } + } + + /// Does a read of `sym` here resolve to a declarative binding, or to a + /// global constant that cannot be an accessor? + fn resolves(&self, sym: &str) -> bool { + if self.opaque { + return false; + } + // Non-writable, non-configurable data properties of the global + // object: they can never become accessors. + if matches!(sym, "undefined" | "NaN" | "Infinity") { + return true; + } + let mut scope = Some(self); + while let Some(visible) = scope { + if visible.names.iter().any(|n| n == sym) { + return true; + } + scope = visible.parent; + } + false + } +} + +/// The declarations made directly by a statement list. +fn declared_names(stmts: &[ast::Stmt]) -> Vec { + let mut names = Vec::new(); + for stmt in stmts { + collect_stmt_decl_names(stmt, &mut names); + } + names +} + +fn collect_stmt_decl_names(stmt: &ast::Stmt, names: &mut Vec) { + if let ast::Stmt::Decl(decl) = stmt { + collect_decl_names(decl, names); + } +} + +fn collect_decl_names(decl: &ast::Decl, names: &mut Vec) { + match decl { + ast::Decl::Var(v) => collect_var_decl_names(v, names), + ast::Decl::Fn(f) if !f.declare => names.push(f.ident.sym.to_string()), + ast::Decl::Class(c) if !c.declare => names.push(c.ident.sym.to_string()), + ast::Decl::TsEnum(e) if !e.declare => names.push(e.id.sym.to_string()), _ => {} } } +fn collect_var_decl_names(v: &ast::VarDecl, names: &mut Vec) { + if v.declare { + return; + } + for d in &v.decls { + collect_pat_names(&d.name, names); + } +} + +fn for_head_names(head: &ast::ForHead) -> Vec { + let mut names = Vec::new(); + if let ast::ForHead::VarDecl(v) = head { + collect_var_decl_names(v, &mut names); + } + names +} + +fn param_names<'a>(pats: impl Iterator) -> Vec { + let mut names = Vec::new(); + for pat in pats { + collect_pat_names(pat, &mut names); + } + names +} + +/// Binding names of a pattern. Default values are not walked: a closure in a +/// default binds nothing in this scope. +fn collect_pat_names(pat: &ast::Pat, names: &mut Vec) { + match pat { + ast::Pat::Ident(bi) => names.push(bi.id.sym.to_string()), + ast::Pat::Array(a) => { + for elem in a.elems.iter().flatten() { + collect_pat_names(elem, names); + } + } + ast::Pat::Rest(r) => collect_pat_names(&r.arg, names), + ast::Pat::Object(o) => { + for prop in &o.props { + match prop { + ast::ObjectPatProp::KeyValue(kv) => collect_pat_names(&kv.value, names), + ast::ObjectPatProp::Assign(a) => names.push(a.key.id.sym.to_string()), + ast::ObjectPatProp::Rest(r) => collect_pat_names(&r.arg, names), + } + } + } + ast::Pat::Assign(a) => collect_pat_names(&a.left, names), + ast::Pat::Invalid(_) | ast::Pat::Expr(_) => {} + } +} + +/// Function-scoped `var` names anywhere in a body, not inside a nested +/// function-like. +fn var_names(stmts: &[ast::Stmt]) -> Vec { + let mut vars = VarNames::default(); + stmts.visit_with(&mut vars); + vars.names +} + +#[derive(Default)] +struct VarNames { + names: Vec, +} + +impl Visit for VarNames { + fn visit_var_decl(&mut self, v: &ast::VarDecl) { + if v.kind == ast::VarDeclKind::Var { + collect_var_decl_names(v, &mut self.names); + } + v.visit_children_with(self); + } + fn visit_function(&mut self, _: &ast::Function) {} + fn visit_arrow_expr(&mut self, _: &ast::ArrowExpr) {} + fn visit_constructor(&mut self, _: &ast::Constructor) {} + fn visit_getter_prop(&mut self, _: &ast::GetterProp) {} + fn visit_setter_prop(&mut self, _: &ast::SetterProp) {} + fn visit_class_prop(&mut self, _: &ast::ClassProp) {} + fn visit_private_prop(&mut self, _: &ast::PrivateProp) {} + fn visit_auto_accessor(&mut self, _: &ast::AutoAccessor) {} + fn visit_static_block(&mut self, _: &ast::StaticBlock) {} +} + // --------------------------------------------------------------------------- // #6812 (w16): compile-time builder WIDTH scan. // diff --git a/crates/perry-hir/tests/builder_fold_conversion.rs b/crates/perry-hir/tests/builder_fold_conversion.rs new file mode 100644 index 0000000000..dc41f2e9a2 --- /dev/null +++ b/crates/perry-hir/tests/builder_fold_conversion.rs @@ -0,0 +1,561 @@ +//! #10357: the builder fold evaluates a value BEFORE the builder binding is +//! initialized, and an implicit conversion (`"" + w`, `-w`, `` `${w}` ``) can +//! call a user `valueOf`/`toString`. When that user code can read the binding +//! the fold turns a successful read into a TDZ `ReferenceError`, so such a +//! value must not fold. When nothing can read the binding before the literal +//! initializes it, the conversion is unobservable and must keep folding — +//! these tests pin both directions. + +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_conversion.ts", &mut cache) + .expect("parse should succeed"); + lower_module(&parsed.module, "test", "builder_fold_conversion.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, + }) +} + +/// Every statement list in the module, depth-first: the builder under test +/// may sit in module init, a function body, or a block nested in either. +fn all_stmt_lists(module: &perry_hir::Module) -> Vec<&[Stmt]> { + fn nested<'a>(stmts: &'a [Stmt], out: &mut Vec<&'a [Stmt]>) { + out.push(stmts); + for stmt in stmts { + match stmt { + Stmt::If { + then_branch, + else_branch, + .. + } => { + nested(then_branch, out); + if let Some(else_branch) = else_branch { + nested(else_branch, out); + } + } + Stmt::Switch { cases, .. } => { + for case in cases { + nested(&case.body, out); + } + } + Stmt::While { body, .. } | Stmt::DoWhile { body, .. } | Stmt::For { body, .. } => { + nested(body, out) + } + Stmt::Try { + body, + catch, + finally, + } => { + nested(body, out); + if let Some(catch) = catch { + nested(&catch.body, out); + } + if let Some(finally) = finally { + nested(finally, out); + } + } + _ => {} + } + } + } + let mut out = Vec::new(); + nested(&module.init, &mut out); + for function in &module.functions { + nested(&function.body, &mut out); + } + out +} + +fn arity_anywhere(module: &perry_hir::Module, name: &str) -> Option { + all_stmt_lists(module) + .into_iter() + .find_map(|stmts| anon_shape_arity(stmts, name)) +} + +#[test] +fn conversions_keep_folding_when_nothing_can_observe_the_builder() { + // The #6812 motivating shape: no closure anywhere names `o`, so no user + // code a conversion reaches can read it early. + let module = lower_src( + r#" + export function build(r: number, i: number): any { + const o: any = {}; + o.a = i; o.b = r + i; o.c = `k${i}`; o.d = -r; + return o; + } + "#, + ); + assert_eq!( + arity_anywhere(&module, "o"), + Some(4), + "every conversion should still fold: {:?}", + module.functions + ); +} + +#[test] +fn a_conversion_does_not_fold_when_a_closure_can_read_the_builder() { + // The #10357 repro: `weird.valueOf()` reads `o`. + let module = lower_src( + r#" + export function run(): string { + const weird = { valueOf(): any { return o; } }; + const o: any = {}; + o.a = "" + weird; + return typeof o.a; + } + "#, + ); + assert_eq!( + arity_anywhere(&module, "o"), + Some(0), + "the conversion must stay after the allocation: {:?}", + module.functions + ); +} + +#[test] +fn conversion_free_values_still_fold_on_an_observable_builder() { + let module = lower_src( + r#" + export function run(w: any): any { + const peek = () => o; + const o: any = {}; + o.a = 1 + 2; o.b = typeof w; o.c = !w; o.d = w === 1; o.e = `lit`; o.f = w; + return peek; + } + "#, + ); + assert_eq!( + arity_anywhere(&module, "o"), + Some(6), + "values that run no conversion on an unknown operand should fold: {:?}", + module.functions + ); +} + +#[test] +fn each_converting_form_stops_the_fold_on_an_observable_builder() { + for value in [ + "-w", + "+w", + "~w", + "`${w}`", + "w + 1", + "w < 1", + "w == 1", + "(w && 1) + 1", + ] { + let module = lower_src(&format!( + r#" + export function run(w: any): any {{ + const peek = () => o; + const o: any = {{}}; + o.a = {value}; + return peek; + }} + "# + )); + assert_eq!( + arity_anywhere(&module, "o"), + Some(0), + "`o.a = {value}` must not fold when a closure reads `o`: {:?}", + module.functions + ); + } +} + +#[test] +fn a_gap_statement_with_a_conversion_does_not_sink_an_observable_allocation() { + // #10355's gap test shares the predicate: `"" + w` below would run + // `w.valueOf()` before `o` exists. + let module = lower_src( + r#" + export function run(w: any): any { + const peek = () => o; + const o: any = {}; + const s = "" + w; + o.a = s; + return peek; + } + "#, + ); + assert_eq!( + arity_anywhere(&module, "o"), + Some(0), + "the allocation must not sink below the conversion: {:?}", + module.functions + ); +} + +#[test] +fn a_closure_in_another_switch_case_observes_the_builder() { + // A `const` in a case clause is scoped to the whole switch, so a closure + // made in an earlier case can fall through into the builder and read it: + // the scan must cover the enclosing function, not just the statement + // list being folded. + let module = lower_src( + r#" + export function run(k: number, w: any): any { + let f: any; + switch (k) { + case 2: + f = () => o; + case 1: + const o: any = {}; + o.a = "" + w; + break; + } + return f; + } + "#, + ); + assert_eq!( + arity_anywhere(&module, "o"), + Some(0), + "a sibling-case closure must count: {:?}", + module.functions + ); +} + +#[test] +fn a_block_var_builder_is_observed_by_a_function_level_closure() { + let module = lower_src( + r#" + export function run(w: any): any { + if (w) { + var o: any = {}; + o.a = "" + w; + } + return () => o; + } + "#, + ); + assert_eq!( + arity_anywhere(&module, "o"), + Some(0), + "a `var` is function-scoped, so the closure outside the block counts: {:?}", + module.functions + ); +} + +#[test] +fn an_exported_top_level_builder_is_observable() { + let module = lower_src( + r#" + declare const w: any; + const o: any = {}; + o.a = "" + w; + export { o }; + "#, + ); + assert_eq!( + anon_shape_arity(&module.init, "o"), + Some(0), + "an importer can read the live binding: {:?}", + module.init + ); +} + +#[test] +fn a_top_level_var_builder_is_observable() { + let module = lower_src( + r#" + declare const w: any; + var o: any = {}; + o.a = "" + w; + "#, + ); + assert_eq!( + anon_shape_arity(&module.init, "o"), + Some(0), + "a top-level `var` is a global-object property: {:?}", + module.init + ); +} + +#[test] +fn an_unobserved_top_level_const_builder_still_folds() { + let module = lower_src( + r#" + declare const w: any; + const o: any = {}; + o.a = "" + w; + console.log(o.a); + "#, + ); + assert_eq!( + anon_shape_arity(&module.init, "o"), + Some(1), + "nothing can read `o` before the literal: {:?}", + module.init + ); +} + +#[test] +fn eval_makes_every_builder_in_its_scope_observable() { + let module = lower_src( + r#" + export function run(w: any): any { + const o: any = {}; + o.a = "" + w; + return eval("o"); + } + "#, + ); + assert_eq!( + arity_anywhere(&module, "o"), + Some(0), + "name-based reasoning is void once `eval` is in scope: {:?}", + module.functions + ); +} + +#[test] +fn a_closure_created_after_a_const_builder_does_not_observe_it() { + // It cannot exist yet while the folded values run. + let module = lower_src( + r#" + export function build(r: number, i: number): any { + const o: any = {}; + o.a = i; o.b = r + i; o.c = -r; + const peek = () => o; + return peek(); + } + "#, + ); + assert_eq!( + arity_anywhere(&module, "o"), + Some(3), + "a later closure is no observer: {:?}", + module.functions + ); +} + +#[test] +fn a_hoisted_function_declared_after_the_builder_observes_it() { + let module = lower_src( + r#" + export function run(w: any): any { + const o: any = {}; + o.a = "" + w; + return peek; + function peek(): any { return o; } + } + "#, + ); + assert_eq!( + arity_anywhere(&module, "o"), + Some(0), + "a function declaration exists from scope entry: {:?}", + module.functions + ); +} + +#[test] +fn a_var_builder_is_observed_by_a_closure_from_an_earlier_loop_pass() { + // The `var` binding is shared by every pass, so the closure pushed after + // the builder in pass 0 can run during pass 1's fold. + let module = lower_src( + r#" + export function run(w: any): any { + const fns: any[] = []; + for (let k = 0; k < 2; k++) { + var o: any = {}; + o.a = "" + w; + fns.push(() => o); + } + return fns; + } + "#, + ); + assert_eq!( + arity_anywhere(&module, "o"), + Some(0), + "position says nothing for a `var`: {:?}", + module.functions + ); +} + +#[test] +fn a_nested_function_with_its_own_binding_is_not_an_observer() { + let module = lower_src( + r#" + export function outer(x: number): any { + function inner(y: number): any { + const o: any = {}; + o.a = y + 1; + return o; + } + const peek = (o: any) => o; + const o: any = {}; + o.a = x + 1; + o.n = inner(x); + return peek(o); + } + "#, + ); + let folded: Vec = all_stmt_lists(&module) + .into_iter() + .filter_map(|stmts| anon_shape_arity(stmts, "o")) + .collect(); + assert!( + folded.contains(&1) && !folded.contains(&0), + "both builders should fold, since `inner` and `peek` bind their own `o`: {folded:?} {:?}", + module.functions + ); +} + +#[test] +fn a_body_declaration_does_not_shadow_a_parameter_default() { + // The default is evaluated in its own scope and reads the OUTER `o`. + let module = lower_src( + r#" + export function run(w: any): any { + const peek = (a = o) => { var o; return a; }; + const o: any = {}; + o.a = "" + w; + return peek; + } + "#, + ); + assert_eq!( + arity_anywhere(&module, "o"), + Some(0), + "the default parameter observes the builder: {:?}", + module.functions + ); +} + +#[test] +fn a_block_level_redeclaration_does_not_shadow() { + let module = lower_src( + r#" + export function run(w: any): any { + const peek = () => { { const o = 1; } return o; }; + const o: any = {}; + o.a = "" + w; + return peek; + } + "#, + ); + assert_eq!( + arity_anywhere(&module, "o"), + Some(0), + "only function-level bindings shadow: {:?}", + module.functions + ); +} + +#[test] +fn a_global_read_does_not_fold_on_an_observable_builder() { + // `gObs` is no declared binding, so it may be an accessor on the global + // object — a getter that reads `o` before it is initialized. + let module = lower_src( + r#" + export function run(): any { + const peek = () => o; + const o: any = {}; + o.a = gObs; + return peek; + } + "#, + ); + assert_eq!( + arity_anywhere(&module, "o"), + Some(0), + "an undeclared identifier may run a global getter: {:?}", + module.functions + ); +} + +#[test] +fn reads_of_declared_bindings_still_fold_on_an_observable_builder() { + let module = lower_src( + r#" + const top = 1; + export function run(w: any): any { + var fnVar = 2; + const peek = () => o; + for (let i = 0; i < 1; i++) { + try { + throw 0; + } catch (err) { + const o: any = {}; + o.a = w; o.b = top; o.c = fnVar; o.d = i; o.e = err; o.f = undefined; + return peek; + } + } + } + "#, + ); + assert_eq!( + arity_anywhere(&module, "o"), + Some(6), + "parameters, outer and loop-head bindings, catch parameters and `undefined` run no code: {:?}", + module.functions + ); +} + +#[test] +fn an_ambient_declaration_is_not_a_binding() { + // `declare` binds nothing at run time: the read still goes to the global + // object (#10363 tracks perry materializing it today). + let module = lower_src( + r#" + declare const gAmb: any; + export function run(): any { + const peek = () => o; + const o: any = {}; + o.a = gAmb; + return peek; + } + "#, + ); + assert_eq!( + arity_anywhere(&module, "o"), + Some(0), + "`declare const` must not count as a binding: {:?}", + module.functions + ); +} + +#[test] +fn a_declaration_in_a_sibling_block_does_not_resolve_the_read() { + let module = lower_src( + r#" + export function run(): any { + const peek = () => o; + { + const gSib = 1; + } + const o: any = {}; + o.a = gSib; + return peek; + } + "#, + ); + assert_eq!( + arity_anywhere(&module, "o"), + Some(0), + "`gSib` here is the global, not the block's binding: {:?}", + module.functions + ); +} diff --git a/crates/perry/tests/builder_fold_conversion_semantics.rs b/crates/perry/tests/builder_fold_conversion_semantics.rs new file mode 100644 index 0000000000..76528a6586 --- /dev/null +++ b/crates/perry/tests/builder_fold_conversion_semantics.rs @@ -0,0 +1,195 @@ +//! #10357: an implicit conversion in a folded builder value can call user +//! code. These are the observable consequences — the HIR tests in +//! `perry-hir/tests/builder_fold_conversion.rs` pin which shapes fold, this +//! file pins that the program behaves like node either way. + +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_value_of_that_reads_the_builder_sees_the_allocated_object() { + // The #10357 repro: node prints `string:str`; the fold used to move + // `weird.valueOf()` into `o`'s TDZ. + let stdout = compile_and_run( + r#" +function run(): string { + const weird = { valueOf(): any { return o; } }; + const o: any = {}; + o.a = "" + weird; + return typeof o.a + ":" + (o.a === "[object Object]" ? "str" : "other"); +} +try { console.log(run()); } catch (e: any) { console.log("threw " + e.name); } +"#, + ); + assert_eq!(stdout, "string:str\n"); +} + +#[test] +fn every_converting_form_sees_the_allocated_object() { + let stdout = compile_and_run( + r#" +function probe(kind: string): string { + const seen: string[] = []; + const w = { + valueOf(): any { seen.push(typeof o); return 1; }, + toString(): string { seen.push(typeof o); return "w"; }, + }; + const o: any = {}; + if (kind === "neg") o.a = -(w as any); + else if (kind === "lt") o.a = (w as any) < 2; + else if (kind === "tpl") o.a = `${w}`; + else o.a = "" + w; + return kind + "=" + seen.join(","); +} +for (const kind of ["plus", "neg", "lt", "tpl"]) { + try { console.log(probe(kind)); } catch (e: any) { console.log(kind + " threw " + e.name); } +} +"#, + ); + assert_eq!(stdout, "plus=object\nneg=object\nlt=object\ntpl=object\n"); +} + +#[test] +fn a_conversion_in_a_gap_statement_sees_the_allocated_object() { + let stdout = compile_and_run( + r#" +function run(): string { + const w = { toString(): string { return typeof o; } }; + const o: any = {}; + const s = `${w}`; + o.a = s; + return o.a; +} +try { console.log(run()); } catch (e: any) { console.log("threw " + e.name); } +"#, + ); + assert_eq!(stdout, "object\n"); +} + +#[test] +fn unobserved_conversions_still_build_the_same_object() { + let stdout = compile_and_run( + r#" +function build(r: number, i: number): any { + const o: any = {}; + o.a = i; o.b = r + i; o.c = `k${i}`; o.d = -r; o.e = r < i; + return o; +} +console.log(JSON.stringify(build(3, 4)), Object.keys(build(1, 2)).join(",")); +"#, + ); + assert_eq!( + stdout, + "{\"a\":4,\"b\":7,\"c\":\"k4\",\"d\":-3,\"e\":true} a,b,c,d,e\n" + ); +} + +#[test] +fn loop_passes_see_the_right_binding() { + // `var`: the closure from pass 0 reads the SHARED binding, which the + // unfolded pass 1 has already pointed at a fresh object. `let`: each pass + // has its own binding, so pass 0's closure reads pass 0's object either + // way. + let stdout = compile_and_run( + r#" +function viaVar(): string { + const fns: Array<() => any> = []; + const seen: string[] = []; + for (let k = 0; k < 2; k++) { + const w = { valueOf(): any { if (fns.length) seen.push(fns[0]().v === undefined ? "fresh" : "old"); return k; } }; + var o: any = {}; + o.v = -(w as any); + fns.push(() => o); + } + return seen.join(","); +} +function viaLet(): string { + const fns: Array<() => any> = []; + const seen: string[] = []; + for (let k = 0; k < 2; k++) { + const w = { valueOf(): any { if (fns.length) seen.push(fns[0]().v === undefined ? "fresh" : "old"); return k; } }; + let o: any = {}; + o.v = -(w as any); + fns.push(() => o); + } + return seen.join(","); +} +try { console.log("var=" + viaVar()); } catch (e: any) { console.log("var threw " + e.name); } +try { console.log("let=" + viaLet()); } catch (e: any) { console.log("let threw " + e.name); } +"#, + ); + assert_eq!(stdout, "var=fresh\nlet=old\n"); +} + +#[test] +fn a_closure_created_after_the_builder_still_reads_the_built_object() { + let stdout = compile_and_run( + r#" +function build(r: number, i: number): any { + const o: any = {}; + o.a = i; o.b = r + i; o.c = -r; + const peek = () => o; + return peek(); +} +console.log(JSON.stringify(build(2, 5))); +"#, + ); + assert_eq!(stdout, "{\"a\":5,\"b\":7,\"c\":-2}\n"); +} + +#[test] +fn a_global_getter_that_reads_the_builder_sees_the_allocated_object() { + let stdout = compile_and_run( + r#" +// @ts-nocheck +function run() { + Object.defineProperty(globalThis, "gObs", { get() { return typeof o; }, configurable: true }); + const o = {}; + o.a = gObs; + return String(o.a); +} +try { console.log(run()); } catch (e) { console.log("threw " + e.name); } +"#, + ); + assert_eq!(stdout, "object\n"); +} From 05b647c315726722f637dbd1bde139a921742c32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 17 Sep 2026 08:35:31 +0200 Subject: [PATCH 2/3] refactor(hir): split builder_fold.rs width-hint scan into its own file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #10357 (cherry-picked onto main after #10353 landed as 2cb9c76f86) pushed builder_fold.rs to 2118 lines, over the 2000-line-per-file cap enforced by scripts/check_file_size.sh. The compile-time builder WIDTH-hint scan (#6812 w16: empty_builder_width_hints and its hint_*/const_build_loop_width/width_int_lit helpers) has no dependency on the fold-safety logic above it or vice versa — it only needs swc_ecma_ast — so it moves to builder_fold_width_hints.rs via the same `#[path = "..."] mod ...; pub(crate) use ...;` pattern codegen/method.rs uses for method_static.rs. Callers keep using `builder_fold::empty_builder_width_hints` unchanged. --- crates/perry-hir/src/lower/builder_fold.rs | 385 +----------------- .../src/lower/builder_fold_width_hints.rs | 385 ++++++++++++++++++ 2 files changed, 390 insertions(+), 380 deletions(-) create mode 100644 crates/perry-hir/src/lower/builder_fold_width_hints.rs diff --git a/crates/perry-hir/src/lower/builder_fold.rs b/crates/perry-hir/src/lower/builder_fold.rs index 549c205573..8e1cc83c2a 100644 --- a/crates/perry-hir/src/lower/builder_fold.rs +++ b/crates/perry-hir/src/lower/builder_fold.rs @@ -1736,383 +1736,8 @@ impl Visit for VarNames { fn visit_static_block(&mut self, _: &ast::StaticBlock) {} } -// --------------------------------------------------------------------------- -// #6812 (w16): compile-time builder WIDTH scan. -// -// `fold_builder_sequences` above handles STATIC-key builders. A builder -// whose keys are computed (`for (let k = 0; k < 24; k++) o["p" + k] = v;`) -// cannot be folded into a literal, but when the build loop is -// constant-bounded its FINAL width is still statically known. That width -// matters beyond the learned resize: the runtime learns a site's width only -// when the FIRST instance overflows, so that instance stays under-sized -// forever — and as element 0 of the array built at the site it vetoes the -// whole-loop clone guard ("first receiver target slot is out of bounds") -// for every hot loop over that array. A width hint right-sizes instance #1 -// so the site's instances are uniform from the first allocation. -// -// The hint is pure allocation capacity: over-counting (e.g. duplicate keys -// across iterations) wastes slots but can never change semantics, so the -// VALUE side of the assignments is deliberately unconstrained. - -/// Scan for `const o = {};` immediately followed by a constant-bounded -/// build loop writing only to `o`. Returns `span.lo` of each empty object -/// literal → proven final width (writes per iteration × trip count). -pub(crate) fn empty_builder_width_hints( - module: &ast::Module, -) -> std::collections::HashMap { - let mut hints = std::collections::HashMap::new(); - // Pair `const o = {}` (plain or `export const`) with a following build - // loop; the loop itself is always a plain Stmt. - for w in module.body.windows(2) { - let ast::ModuleItem::Stmt(b) = &w[1] else { - continue; - }; - if let Some((name, span_lo)) = item_empty_object_decl(&w[0]) { - note_hint_for_site(&name, span_lo, b, &mut hints); - } - } - for item in &module.body { - match item { - ast::ModuleItem::Stmt(s) => hint_walk_stmt(s, &mut hints), - // Exported declarations are ModuleDecls, not Stmts — and - // `export function buildX() { const o = {}; ... }` is the most - // common real-world builder shape. - ast::ModuleItem::ModuleDecl(md) => match md { - ast::ModuleDecl::ExportDecl(e) => hint_walk_hint_decl(&e.decl, &mut hints), - ast::ModuleDecl::ExportDefaultDecl(d) => match &d.decl { - ast::DefaultDecl::Fn(f) => { - if let Some(body) = &f.function.body { - hint_scan_stmts(&body.stmts, &mut hints); - } - } - ast::DefaultDecl::Class(c) => hint_walk_class(&c.class, &mut hints), - ast::DefaultDecl::TsInterfaceDecl(_) => {} - }, - ast::ModuleDecl::ExportDefaultExpr(e) => hint_walk_expr(&e.expr, &mut hints), - _ => {} - }, - } - } - hints -} - -/// `const/let name = {}` from a plain statement or an `export const`. -/// Returns the binding name and the empty literal's span.lo. -fn item_empty_object_decl(item: &ast::ModuleItem) -> Option<(String, u32)> { - match item { - ast::ModuleItem::Stmt(ast::Stmt::Decl(ast::Decl::Var(v))) => empty_object_decl(v), - ast::ModuleItem::ModuleDecl(ast::ModuleDecl::ExportDecl(e)) => match &e.decl { - ast::Decl::Var(v) => empty_object_decl(v), - _ => None, - }, - _ => None, - } -} - -fn empty_object_decl(var: &ast::VarDecl) -> Option<(String, u32)> { - if var.decls.len() != 1 { - return None; - } - let d = &var.decls[0]; - let ast::Pat::Ident(bi) = &d.name else { - return None; - }; - let ast::Expr::Object(obj) = d.init.as_deref()? else { - return None; - }; - if !obj.props.is_empty() { - return None; - } - Some((bi.id.sym.to_string(), obj.span.lo.0)) -} - -fn hint_scan_stmts(stmts: &[ast::Stmt], hints: &mut std::collections::HashMap) { - for w in stmts.windows(2) { - note_hint_pair(&w[0], &w[1], hints); - } - for s in stmts { - hint_walk_stmt(s, hints); - } -} - -fn hint_walk_stmt(s: &ast::Stmt, hints: &mut std::collections::HashMap) { - match s { - ast::Stmt::Block(b) => hint_scan_stmts(&b.stmts, hints), - ast::Stmt::If(i) => { - hint_walk_stmt(&i.cons, hints); - if let Some(alt) = &i.alt { - hint_walk_stmt(alt, hints); - } - } - ast::Stmt::While(w) => hint_walk_stmt(&w.body, hints), - ast::Stmt::DoWhile(w) => hint_walk_stmt(&w.body, hints), - ast::Stmt::For(f) => hint_walk_stmt(&f.body, hints), - ast::Stmt::ForIn(f) => hint_walk_stmt(&f.body, hints), - ast::Stmt::ForOf(f) => hint_walk_stmt(&f.body, hints), - ast::Stmt::Labeled(l) => hint_walk_stmt(&l.body, hints), - ast::Stmt::Try(t) => { - hint_scan_stmts(&t.block.stmts, hints); - if let Some(h) = &t.handler { - hint_scan_stmts(&h.body.stmts, hints); - } - if let Some(f) = &t.finalizer { - hint_scan_stmts(&f.stmts, hints); - } - } - ast::Stmt::Switch(sw) => { - for case in &sw.cases { - hint_scan_stmts(&case.cons, hints); - } - } - ast::Stmt::Decl(d) => hint_walk_hint_decl(d, hints), - ast::Stmt::Expr(e) => hint_walk_expr(&e.expr, hints), - ast::Stmt::Return(r) => { - if let Some(arg) = &r.arg { - hint_walk_expr(arg, hints); - } - } - _ => {} - } -} - -fn hint_walk_hint_decl(d: &ast::Decl, hints: &mut std::collections::HashMap) { - match d { - ast::Decl::Fn(f) => { - if let Some(body) = &f.function.body { - hint_scan_stmts(&body.stmts, hints); - } - } - ast::Decl::Var(v) => { - for decl in &v.decls { - if let Some(init) = &decl.init { - hint_walk_expr(init, hints); - } - } - } - ast::Decl::Class(c) => hint_walk_class(&c.class, hints), - _ => {} - } -} - -fn hint_walk_class(class: &ast::Class, hints: &mut std::collections::HashMap) { - for member in &class.body { - match member { - ast::ClassMember::Method(m) => { - if let Some(body) = &m.function.body { - hint_scan_stmts(&body.stmts, hints); - } - } - ast::ClassMember::PrivateMethod(m) => { - if let Some(body) = &m.function.body { - hint_scan_stmts(&body.stmts, hints); - } - } - ast::ClassMember::Constructor(c) => { - if let Some(body) = &c.body { - hint_scan_stmts(&body.stmts, hints); - } - } - ast::ClassMember::ClassProp(p) => { - if let Some(v) = &p.value { - hint_walk_expr(v, hints); - } - } - ast::ClassMember::PrivateProp(p) => { - if let Some(v) = &p.value { - hint_walk_expr(v, hints); - } - } - _ => {} - } - } -} - -fn hint_walk_expr(e: &ast::Expr, hints: &mut std::collections::HashMap) { - use ast::Expr as E; - match e { - E::Fn(f) => { - if let Some(body) = &f.function.body { - hint_scan_stmts(&body.stmts, hints); - } - } - E::Arrow(a) => match &*a.body { - ast::BlockStmtOrExpr::BlockStmt(b) => hint_scan_stmts(&b.stmts, hints), - ast::BlockStmtOrExpr::Expr(x) => hint_walk_expr(x, hints), - }, - E::Class(c) => hint_walk_class(&c.class, hints), - E::Paren(p) => hint_walk_expr(&p.expr, hints), - E::Seq(s) => { - for x in &s.exprs { - hint_walk_expr(x, hints); - } - } - E::Cond(c) => { - hint_walk_expr(&c.test, hints); - hint_walk_expr(&c.cons, hints); - hint_walk_expr(&c.alt, hints); - } - E::Bin(b) => { - hint_walk_expr(&b.left, hints); - hint_walk_expr(&b.right, hints); - } - E::Unary(u) => hint_walk_expr(&u.arg, hints), - E::Assign(a) => hint_walk_expr(&a.right, hints), - E::Await(a) => hint_walk_expr(&a.arg, hints), - E::Call(c) => { - for arg in &c.args { - hint_walk_expr(&arg.expr, hints); - } - } - E::New(n) => { - if let Some(args) = &n.args { - for arg in args { - hint_walk_expr(&arg.expr, hints); - } - } - } - E::Array(arr) => { - for el in arr.elems.iter().flatten() { - hint_walk_expr(&el.expr, hints); - } - } - E::Object(o) => { - for prop in &o.props { - if let ast::PropOrSpread::Prop(p) = prop { - if let ast::Prop::KeyValue(kv) = p.as_ref() { - hint_walk_expr(&kv.value, hints); - } - } - } - } - E::Tpl(t) => { - for x in &t.exprs { - hint_walk_expr(x, hints); - } - } - E::Member(m) => hint_walk_expr(&m.obj, hints), - _ => {} - } -} - -/// Cap mirroring the runtime's `LEARNED_INLINE_MAX_FIELDS`: hints past this -/// stop paying for themselves and a pathological constant loop must not -/// inflate every instance. -const WIDTH_HINT_MAX: u32 = 64; - -fn note_hint_pair(a: &ast::Stmt, b: &ast::Stmt, hints: &mut std::collections::HashMap) { - let ast::Stmt::Decl(ast::Decl::Var(var)) = a else { - return; - }; - let Some((name, span_lo)) = empty_object_decl(var) else { - return; - }; - note_hint_for_site(&name, span_lo, b, hints); -} - -fn note_hint_for_site( - name: &str, - literal_span_lo: u32, - build_loop: &ast::Stmt, - hints: &mut std::collections::HashMap, -) { - let Some(width) = const_build_loop_width(build_loop, name) else { - return; - }; - if width == 0 || width > WIDTH_HINT_MAX { - return; - } - hints.insert(literal_span_lo, width); -} - -/// `for (let k = C0; k < C1; k++) body` where every body statement is a -/// plain `name.x = value` / `name[expr] = value` assignment. Returns writes -/// per iteration × trip count. Values and key expressions are arbitrary — -/// the width is capacity only. -fn const_build_loop_width(s: &ast::Stmt, name: &str) -> Option { - let ast::Stmt::For(f) = s else { - return None; - }; - let Some(ast::VarDeclOrExpr::VarDecl(vd)) = &f.init else { - return None; - }; - if vd.decls.len() != 1 { - return None; - } - let d0 = &vd.decls[0]; - let ast::Pat::Ident(kb) = &d0.name else { - return None; - }; - let counter = kb.id.sym.as_ref(); - let c0 = width_int_lit(d0.init.as_deref()?)?; - let ast::Expr::Bin(cmp) = f.test.as_deref()? else { - return None; - }; - if cmp.op != ast::BinaryOp::Lt { - return None; - } - let ast::Expr::Ident(ci) = &*cmp.left else { - return None; - }; - if ci.sym.as_ref() != counter { - return None; - } - let c1 = width_int_lit(&cmp.right)?; - match f.update.as_deref()? { - ast::Expr::Update(u) if u.op == ast::UpdateOp::PlusPlus => { - let ast::Expr::Ident(ui) = &*u.arg else { - return None; - }; - if ui.sym.as_ref() != counter { - return None; - } - } - _ => return None, - } - if c1 <= c0 { - return None; - } - let trips = u32::try_from(c1 - c0).ok()?; - let body: &[ast::Stmt] = match &*f.body { - ast::Stmt::Block(bs) => &bs.stmts, - other => std::slice::from_ref(other), - }; - if body.is_empty() || body.len() > 4 { - return None; - } - let mut writes = 0u32; - for stmt in body { - let ast::Stmt::Expr(es) = stmt else { - return None; - }; - let ast::Expr::Assign(assign) = &*es.expr else { - return None; - }; - if assign.op != ast::AssignOp::Assign { - return None; - } - let ast::AssignTarget::Simple(ast::SimpleAssignTarget::Member(m)) = &assign.left else { - return None; - }; - let ast::Expr::Ident(oi) = &*m.obj else { - return None; - }; - if oi.sym.as_ref() != name { - return None; - } - if matches!(&m.prop, ast::MemberProp::PrivateName(_)) { - return None; - } - writes += 1; - } - trips.checked_mul(writes) -} - -fn width_int_lit(e: &ast::Expr) -> Option { - let ast::Expr::Lit(ast::Lit::Num(n)) = e else { - return None; - }; - if n.value.fract() != 0.0 || !(0.0..=1_000_000_000.0).contains(&n.value) { - return None; - } - Some(n.value as i64) -} +// #6812 (w16): compile-time builder WIDTH scan, split into +// `builder_fold_width_hints.rs` to keep this file under the 2000-line cap. +#[path = "builder_fold_width_hints.rs"] +mod width_hints; +pub(crate) use width_hints::empty_builder_width_hints; diff --git a/crates/perry-hir/src/lower/builder_fold_width_hints.rs b/crates/perry-hir/src/lower/builder_fold_width_hints.rs new file mode 100644 index 0000000000..60185092e8 --- /dev/null +++ b/crates/perry-hir/src/lower/builder_fold_width_hints.rs @@ -0,0 +1,385 @@ +//! Compile-time builder WIDTH-hint scan. Split out of `builder_fold.rs` +//! to keep that file under the repo's 2000-line-per-file cap (#10361). +//! +//! #6812 (w16): compile-time builder WIDTH scan. +//! +//! `fold_builder_sequences` in `builder_fold.rs` handles STATIC-key +//! builders. A builder whose keys are computed +//! (`for (let k = 0; k < 24; k++) o["p" + k] = v;`) +//! cannot be folded into a literal, but when the build loop is +//! constant-bounded its FINAL width is still statically known. That width +//! matters beyond the learned resize: the runtime learns a site's width only +//! when the FIRST instance overflows, so that instance stays under-sized +//! forever — and as element 0 of the array built at the site it vetoes the +//! whole-loop clone guard ("first receiver target slot is out of bounds") +//! for every hot loop over that array. A width hint right-sizes instance #1 +//! so the site's instances are uniform from the first allocation. +//! +//! The hint is pure allocation capacity: over-counting (e.g. duplicate keys +//! across iterations) wastes slots but can never change semantics, so the +//! VALUE side of the assignments is deliberately unconstrained. + +use swc_ecma_ast as ast; + +/// Scan for `const o = {};` immediately followed by a constant-bounded +/// build loop writing only to `o`. Returns `span.lo` of each empty object +/// literal → proven final width (writes per iteration × trip count). +pub(crate) fn empty_builder_width_hints( + module: &ast::Module, +) -> std::collections::HashMap { + let mut hints = std::collections::HashMap::new(); + // Pair `const o = {}` (plain or `export const`) with a following build + // loop; the loop itself is always a plain Stmt. + for w in module.body.windows(2) { + let ast::ModuleItem::Stmt(b) = &w[1] else { + continue; + }; + if let Some((name, span_lo)) = item_empty_object_decl(&w[0]) { + note_hint_for_site(&name, span_lo, b, &mut hints); + } + } + for item in &module.body { + match item { + ast::ModuleItem::Stmt(s) => hint_walk_stmt(s, &mut hints), + // Exported declarations are ModuleDecls, not Stmts — and + // `export function buildX() { const o = {}; ... }` is the most + // common real-world builder shape. + ast::ModuleItem::ModuleDecl(md) => match md { + ast::ModuleDecl::ExportDecl(e) => hint_walk_hint_decl(&e.decl, &mut hints), + ast::ModuleDecl::ExportDefaultDecl(d) => match &d.decl { + ast::DefaultDecl::Fn(f) => { + if let Some(body) = &f.function.body { + hint_scan_stmts(&body.stmts, &mut hints); + } + } + ast::DefaultDecl::Class(c) => hint_walk_class(&c.class, &mut hints), + ast::DefaultDecl::TsInterfaceDecl(_) => {} + }, + ast::ModuleDecl::ExportDefaultExpr(e) => hint_walk_expr(&e.expr, &mut hints), + _ => {} + }, + } + } + hints +} + +/// `const/let name = {}` from a plain statement or an `export const`. +/// Returns the binding name and the empty literal's span.lo. +fn item_empty_object_decl(item: &ast::ModuleItem) -> Option<(String, u32)> { + match item { + ast::ModuleItem::Stmt(ast::Stmt::Decl(ast::Decl::Var(v))) => empty_object_decl(v), + ast::ModuleItem::ModuleDecl(ast::ModuleDecl::ExportDecl(e)) => match &e.decl { + ast::Decl::Var(v) => empty_object_decl(v), + _ => None, + }, + _ => None, + } +} + +fn empty_object_decl(var: &ast::VarDecl) -> Option<(String, u32)> { + if var.decls.len() != 1 { + return None; + } + let d = &var.decls[0]; + let ast::Pat::Ident(bi) = &d.name else { + return None; + }; + let ast::Expr::Object(obj) = d.init.as_deref()? else { + return None; + }; + if !obj.props.is_empty() { + return None; + } + Some((bi.id.sym.to_string(), obj.span.lo.0)) +} + +fn hint_scan_stmts(stmts: &[ast::Stmt], hints: &mut std::collections::HashMap) { + for w in stmts.windows(2) { + note_hint_pair(&w[0], &w[1], hints); + } + for s in stmts { + hint_walk_stmt(s, hints); + } +} + +fn hint_walk_stmt(s: &ast::Stmt, hints: &mut std::collections::HashMap) { + match s { + ast::Stmt::Block(b) => hint_scan_stmts(&b.stmts, hints), + ast::Stmt::If(i) => { + hint_walk_stmt(&i.cons, hints); + if let Some(alt) = &i.alt { + hint_walk_stmt(alt, hints); + } + } + ast::Stmt::While(w) => hint_walk_stmt(&w.body, hints), + ast::Stmt::DoWhile(w) => hint_walk_stmt(&w.body, hints), + ast::Stmt::For(f) => hint_walk_stmt(&f.body, hints), + ast::Stmt::ForIn(f) => hint_walk_stmt(&f.body, hints), + ast::Stmt::ForOf(f) => hint_walk_stmt(&f.body, hints), + ast::Stmt::Labeled(l) => hint_walk_stmt(&l.body, hints), + ast::Stmt::Try(t) => { + hint_scan_stmts(&t.block.stmts, hints); + if let Some(h) = &t.handler { + hint_scan_stmts(&h.body.stmts, hints); + } + if let Some(f) = &t.finalizer { + hint_scan_stmts(&f.stmts, hints); + } + } + ast::Stmt::Switch(sw) => { + for case in &sw.cases { + hint_scan_stmts(&case.cons, hints); + } + } + ast::Stmt::Decl(d) => hint_walk_hint_decl(d, hints), + ast::Stmt::Expr(e) => hint_walk_expr(&e.expr, hints), + ast::Stmt::Return(r) => { + if let Some(arg) = &r.arg { + hint_walk_expr(arg, hints); + } + } + _ => {} + } +} + +fn hint_walk_hint_decl(d: &ast::Decl, hints: &mut std::collections::HashMap) { + match d { + ast::Decl::Fn(f) => { + if let Some(body) = &f.function.body { + hint_scan_stmts(&body.stmts, hints); + } + } + ast::Decl::Var(v) => { + for decl in &v.decls { + if let Some(init) = &decl.init { + hint_walk_expr(init, hints); + } + } + } + ast::Decl::Class(c) => hint_walk_class(&c.class, hints), + _ => {} + } +} + +fn hint_walk_class(class: &ast::Class, hints: &mut std::collections::HashMap) { + for member in &class.body { + match member { + ast::ClassMember::Method(m) => { + if let Some(body) = &m.function.body { + hint_scan_stmts(&body.stmts, hints); + } + } + ast::ClassMember::PrivateMethod(m) => { + if let Some(body) = &m.function.body { + hint_scan_stmts(&body.stmts, hints); + } + } + ast::ClassMember::Constructor(c) => { + if let Some(body) = &c.body { + hint_scan_stmts(&body.stmts, hints); + } + } + ast::ClassMember::ClassProp(p) => { + if let Some(v) = &p.value { + hint_walk_expr(v, hints); + } + } + ast::ClassMember::PrivateProp(p) => { + if let Some(v) = &p.value { + hint_walk_expr(v, hints); + } + } + _ => {} + } + } +} + +fn hint_walk_expr(e: &ast::Expr, hints: &mut std::collections::HashMap) { + use ast::Expr as E; + match e { + E::Fn(f) => { + if let Some(body) = &f.function.body { + hint_scan_stmts(&body.stmts, hints); + } + } + E::Arrow(a) => match &*a.body { + ast::BlockStmtOrExpr::BlockStmt(b) => hint_scan_stmts(&b.stmts, hints), + ast::BlockStmtOrExpr::Expr(x) => hint_walk_expr(x, hints), + }, + E::Class(c) => hint_walk_class(&c.class, hints), + E::Paren(p) => hint_walk_expr(&p.expr, hints), + E::Seq(s) => { + for x in &s.exprs { + hint_walk_expr(x, hints); + } + } + E::Cond(c) => { + hint_walk_expr(&c.test, hints); + hint_walk_expr(&c.cons, hints); + hint_walk_expr(&c.alt, hints); + } + E::Bin(b) => { + hint_walk_expr(&b.left, hints); + hint_walk_expr(&b.right, hints); + } + E::Unary(u) => hint_walk_expr(&u.arg, hints), + E::Assign(a) => hint_walk_expr(&a.right, hints), + E::Await(a) => hint_walk_expr(&a.arg, hints), + E::Call(c) => { + for arg in &c.args { + hint_walk_expr(&arg.expr, hints); + } + } + E::New(n) => { + if let Some(args) = &n.args { + for arg in args { + hint_walk_expr(&arg.expr, hints); + } + } + } + E::Array(arr) => { + for el in arr.elems.iter().flatten() { + hint_walk_expr(&el.expr, hints); + } + } + E::Object(o) => { + for prop in &o.props { + if let ast::PropOrSpread::Prop(p) = prop { + if let ast::Prop::KeyValue(kv) = p.as_ref() { + hint_walk_expr(&kv.value, hints); + } + } + } + } + E::Tpl(t) => { + for x in &t.exprs { + hint_walk_expr(x, hints); + } + } + E::Member(m) => hint_walk_expr(&m.obj, hints), + _ => {} + } +} + +/// Cap mirroring the runtime's `LEARNED_INLINE_MAX_FIELDS`: hints past this +/// stop paying for themselves and a pathological constant loop must not +/// inflate every instance. +const WIDTH_HINT_MAX: u32 = 64; + +fn note_hint_pair(a: &ast::Stmt, b: &ast::Stmt, hints: &mut std::collections::HashMap) { + let ast::Stmt::Decl(ast::Decl::Var(var)) = a else { + return; + }; + let Some((name, span_lo)) = empty_object_decl(var) else { + return; + }; + note_hint_for_site(&name, span_lo, b, hints); +} + +fn note_hint_for_site( + name: &str, + literal_span_lo: u32, + build_loop: &ast::Stmt, + hints: &mut std::collections::HashMap, +) { + let Some(width) = const_build_loop_width(build_loop, name) else { + return; + }; + if width == 0 || width > WIDTH_HINT_MAX { + return; + } + hints.insert(literal_span_lo, width); +} + +/// `for (let k = C0; k < C1; k++) body` where every body statement is a +/// plain `name.x = value` / `name[expr] = value` assignment. Returns writes +/// per iteration × trip count. Values and key expressions are arbitrary — +/// the width is capacity only. +fn const_build_loop_width(s: &ast::Stmt, name: &str) -> Option { + let ast::Stmt::For(f) = s else { + return None; + }; + let Some(ast::VarDeclOrExpr::VarDecl(vd)) = &f.init else { + return None; + }; + if vd.decls.len() != 1 { + return None; + } + let d0 = &vd.decls[0]; + let ast::Pat::Ident(kb) = &d0.name else { + return None; + }; + let counter = kb.id.sym.as_ref(); + let c0 = width_int_lit(d0.init.as_deref()?)?; + let ast::Expr::Bin(cmp) = f.test.as_deref()? else { + return None; + }; + if cmp.op != ast::BinaryOp::Lt { + return None; + } + let ast::Expr::Ident(ci) = &*cmp.left else { + return None; + }; + if ci.sym.as_ref() != counter { + return None; + } + let c1 = width_int_lit(&cmp.right)?; + match f.update.as_deref()? { + ast::Expr::Update(u) if u.op == ast::UpdateOp::PlusPlus => { + let ast::Expr::Ident(ui) = &*u.arg else { + return None; + }; + if ui.sym.as_ref() != counter { + return None; + } + } + _ => return None, + } + if c1 <= c0 { + return None; + } + let trips = u32::try_from(c1 - c0).ok()?; + let body: &[ast::Stmt] = match &*f.body { + ast::Stmt::Block(bs) => &bs.stmts, + other => std::slice::from_ref(other), + }; + if body.is_empty() || body.len() > 4 { + return None; + } + let mut writes = 0u32; + for stmt in body { + let ast::Stmt::Expr(es) = stmt else { + return None; + }; + let ast::Expr::Assign(assign) = &*es.expr else { + return None; + }; + if assign.op != ast::AssignOp::Assign { + return None; + } + let ast::AssignTarget::Simple(ast::SimpleAssignTarget::Member(m)) = &assign.left else { + return None; + }; + let ast::Expr::Ident(oi) = &*m.obj else { + return None; + }; + if oi.sym.as_ref() != name { + return None; + } + if matches!(&m.prop, ast::MemberProp::PrivateName(_)) { + return None; + } + writes += 1; + } + trips.checked_mul(writes) +} + +fn width_int_lit(e: &ast::Expr) -> Option { + let ast::Expr::Lit(ast::Lit::Num(n)) = e else { + return None; + }; + if n.value.fract() != 0.0 || !(0.0..=1_000_000_000.0).contains(&n.value) { + return None; + } + Some(n.value as i64) +} From df08e26fe0d46924a85977da4c2074fa5e48d631 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 17 Sep 2026 10:18:54 +0200 Subject: [PATCH 3/3] chore: release merge train 211 as v0.5.1589 --- CLAUDE.md | 2 +- Cargo.lock | 162 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 83 insertions(+), 83 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index aaf88a51cc..c9ccc04961 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1588 +**Current Version:** 0.5.1589 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index b57491dd5e..a941fcf402 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5623,7 +5623,7 @@ checksum = "fc61f41aef38c94e922057977bcb33bf185ab42242188719991ecfdc0fa1fe6b" [[package]] name = "perry" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "anyhow", "base64 0.22.1", @@ -5687,7 +5687,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "perry-dispatch", "serde", @@ -5695,7 +5695,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "cc", "libc", @@ -5704,7 +5704,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "aho-corasick", "anyhow", @@ -5721,7 +5721,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "anyhow", "perry-hir", @@ -5729,7 +5729,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "anyhow", "perry-hir", @@ -5737,7 +5737,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "anyhow", "perry-dispatch", @@ -5746,7 +5746,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "anyhow", "perry-hir", @@ -5754,7 +5754,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "anyhow", "base64 0.22.1", @@ -5766,7 +5766,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "anyhow", "perry-hir", @@ -5774,7 +5774,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "async-trait", "clap", @@ -5798,14 +5798,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "serde", "serde_json", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1588" +version = "0.5.1589" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5824,7 +5824,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "anyhow", "clap", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "block2", "objc2", @@ -5849,7 +5849,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "argon2", "perry-ffi", @@ -5858,7 +5858,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "perry-ffi", "reqwest", @@ -5867,7 +5867,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "bcrypt", "perry-ffi", @@ -5875,7 +5875,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "perry-ffi", "rusqlite", @@ -5883,7 +5883,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "perry-ffi", "scraper", @@ -5891,7 +5891,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "perry-ffi", "perry-runtime", @@ -5899,7 +5899,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "chrono", "cron", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "chrono", "perry-ffi", @@ -5917,7 +5917,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "perry-ffi", "rust_decimal", @@ -5925,7 +5925,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "perry-ffi", "serde_json", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -5941,7 +5941,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "perry-ffi", "perry-runtime", @@ -5949,14 +5949,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "bytes", "http-body-util", @@ -5973,7 +5973,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "bytes", "lazy_static", @@ -5986,7 +5986,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "base64 0.22.1", "bytes", @@ -6018,7 +6018,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "lazy_static", "perry-ffi", @@ -6028,7 +6028,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "base64 0.22.1", "jsonwebtoken", @@ -6039,7 +6039,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "lru", "perry-ffi", @@ -6048,7 +6048,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "chrono", "perry-ffi", @@ -6056,7 +6056,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "bson", "futures-util", @@ -6068,7 +6068,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "chrono", "perry-ffi", @@ -6080,7 +6080,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "nanoid", "perry-ffi", @@ -6089,7 +6089,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "bytes", "perry-ffi", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "const-oid 0.10.2", "der 0.8.2", @@ -6123,7 +6123,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "lettre", "perry-ffi", @@ -6133,7 +6133,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "notify", "perry-ffi", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "perry-ffi", "printpdf", @@ -6153,7 +6153,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "perry-ffi", "sqlx", @@ -6162,7 +6162,7 @@ dependencies = [ [[package]] name = "perry-ext-qs" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "perry-ffi", "perry-runtime", @@ -6171,7 +6171,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "governor", "perry-ffi", @@ -6179,7 +6179,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "fast_image_resize", "image", @@ -6190,7 +6190,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "lazy_static", "perry-ffi", @@ -6199,7 +6199,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "anyhow", "perry-ffi", @@ -6219,7 +6219,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "perry-ffi", "perry-runtime", @@ -6228,7 +6228,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "perry-ffi", "uuid", @@ -6236,7 +6236,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "perry-ffi", "perry-validation", @@ -6245,7 +6245,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "futures-util", "lazy_static", @@ -6258,7 +6258,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "brotli", "flate2", @@ -6268,7 +6268,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6278,7 +6278,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "anyhow", "perry-api-manifest", @@ -6298,11 +6298,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1588" +version = "0.5.1589" [[package]] name = "perry-parser" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "anyhow", "perry-diagnostics", @@ -6315,7 +6315,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "perex", "regex", @@ -6323,7 +6323,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "ahash", "base64 0.22.1", @@ -6381,14 +6381,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6477,21 +6477,21 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "perry-hir", ] [[package]] name = "perry-ui" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "dirs", "perry-ffi", @@ -6501,7 +6501,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "base64 0.22.1", "jni", @@ -6516,7 +6516,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "rand 0.10.2", "serde", @@ -6526,7 +6526,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6549,7 +6549,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "base64 0.22.1", "block2", @@ -6566,7 +6566,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "base64 0.22.1", "block2", @@ -6583,7 +6583,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1588" +version = "0.5.1589" [[package]] name = "perry-ui-test" @@ -6594,11 +6594,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1588" +version = "0.5.1589" [[package]] name = "perry-ui-tvos" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "base64 0.22.1", "block2", @@ -6615,7 +6615,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "base64 0.22.1", "block2", @@ -6632,7 +6632,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "block2", "libc", @@ -6646,7 +6646,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "base64 0.22.1", "libc", @@ -6665,7 +6665,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "base64 0.22.1", "libc", @@ -6678,7 +6678,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "anyhow", "base64 0.22.1", @@ -6693,7 +6693,7 @@ dependencies = [ [[package]] name = "perry-validation" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "idna", "regex", @@ -6703,7 +6703,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1588" +version = "0.5.1589" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index bec99c356b..b24c221792 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -338,7 +338,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1588" +version = "0.5.1589" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry"