From 219efd571aa8c04a22889a8f2f8793a75df86379 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Tue, 25 Aug 2026 00:50:57 +0200 Subject: [PATCH 1/4] perf(codegen): specialize imported object literal methods --- benchmarks/compiler_output/workloads.toml | 59 ++++ crates/perry-codegen/src/codegen/closure.rs | 2 + crates/perry-codegen/src/codegen/entry.rs | 4 + crates/perry-codegen/src/codegen/function.rs | 2 + crates/perry-codegen/src/codegen/method.rs | 4 + crates/perry-codegen/src/codegen/mod.rs | 24 +- crates/perry-codegen/src/codegen/opts.rs | 45 +++ crates/perry-codegen/src/collectors/mod.rs | 2 + .../src/collectors/object_literal_exports.rs | 205 ++++++++++++++ crates/perry-codegen/src/expr/mod.rs | 7 + crates/perry-codegen/src/lib.rs | 13 +- .../src/lower_call/property_get.rs | 14 + .../property_get/imported_object.rs | 235 ++++++++++++++++ .../src/lower_call/typed_shape_bake_tests.rs | 1 + .../src/stmt/let_object_facts.rs | 54 ++++ crates/perry-codegen/src/stmt/let_stmt.rs | 20 +- crates/perry-codegen/src/stmt/mod.rs | 1 + .../src/commands/compile/object_cache.rs | 20 ++ .../object_cache/object_cache_tests.rs | 20 ++ .../src/commands/compile/run_pipeline.rs | 85 ++++++ ...sue_8775_imported_object_specialization.rs | 261 ++++++++++++++++++ .../issue_8775_imported_object/adapter.js | 37 +++ .../issue_8775_imported_object/barrel.js | 1 + .../issue_8775_imported_object/main.js | 15 + .../issue_8775_imported_object/package.json | 1 + .../issue_8775_imported_object/semantics.js | 86 ++++++ 26 files changed, 1197 insertions(+), 21 deletions(-) create mode 100644 crates/perry-codegen/src/collectors/object_literal_exports.rs create mode 100644 crates/perry-codegen/src/lower_call/property_get/imported_object.rs create mode 100644 crates/perry-codegen/src/stmt/let_object_facts.rs create mode 100644 crates/perry/tests/issue_8775_imported_object_specialization.rs create mode 100644 test-files/fixtures/issue_8775_imported_object/adapter.js create mode 100644 test-files/fixtures/issue_8775_imported_object/barrel.js create mode 100644 test-files/fixtures/issue_8775_imported_object/main.js create mode 100644 test-files/fixtures/issue_8775_imported_object/package.json create mode 100644 test-files/fixtures/issue_8775_imported_object/semantics.js diff --git a/benchmarks/compiler_output/workloads.toml b/benchmarks/compiler_output/workloads.toml index 93511805da..c54637ef87 100644 --- a/benchmarks/compiler_output/workloads.toml +++ b/benchmarks/compiler_output/workloads.toml @@ -2134,3 +2134,62 @@ name = "imported_registry_generic_fallback_retained" consumer = "proven_this_method_direct_call" notes_contains = "generic_dispatch_fallback=js_native_call_method_by_id" min = 2 + +[workloads.issue_8775_imported_object] +source = "test-files/fixtures/issue_8775_imported_object/main.js" +kind = "imported_object_literal_method_specialization" +allow_hot_loop_conversions = true +allow_dynamic_property_runtime = true + +[workloads.issue_8775_imported_object.vectorization] +min_vectorized_loops = 0 +scalar_baseline = "allowed: this fixture gates cross-module own-method dispatch" +allowed_missed_reason_kinds = [ + "call_instruction", + "control_flow", + "generic_not_vectorized", + "not_beneficial", + "uncountable_loop", + "unknown_trip_count", + "unsupported_instruction", + "unsupported_reduction", +] + +[workloads.issue_8775_imported_object.runtime_budgets] + +[[workloads.issue_8775_imported_object.stdout_checks]] +name = "imported_object_checksum" +equals = "{\"checksum\":400000,\"remaining\":0}\n" +detail = "stable imported object methods preserve the Node checksum" + +[[workloads.issue_8775_imported_object.ir_checks]] +name = "direct_producer_closure_calls" +section = "llvm_before" +contains_all = [ + "call double @perry_closure_adapter_js__6", + "call double @perry_closure_adapter_js__7", + "call double @perry_closure_adapter_js__8", + "call double @perry_closure_adapter_js__9", +] +detail = "stable arms directly call producer closure bodies" + +[[workloads.issue_8775_imported_object.ir_checks]] +name = "generic_method_fallback_retained" +section = "llvm_before" +contains = "call double @js_native_call_method_by_id" +detail = "guard failures retain the universal method dispatcher" + +[workloads.issue_8775_imported_object.native_rep_checks] +allow_materialization_reasons = ["runtime_api"] + +[[workloads.issue_8775_imported_object.native_rep_checks.require_records]] +name = "imported_object_direct_selection" +consumer = "imported_object_literal_method_direct_call" +notes_contains = "receiver_provenance=imported_object_literal_metadata" +min = 5 + +[[workloads.issue_8775_imported_object.native_rep_checks.require_records]] +name = "imported_object_generic_fallback_retained" +consumer = "imported_object_literal_method_direct_call" +notes_contains = "generic_dispatch_fallback=js_native_call_method_by_id" +min = 5 diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index 6c5b4ca67c..d83364e5da 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -1116,7 +1116,9 @@ pub(super) fn compile_closure( local_class_field_aliases: HashMap::new(), local_id_to_name: HashMap::new(), local_value_aliases: HashMap::new(), + local_imported_object_aliases: HashMap::new(), imported_vars: &cross_module.imported_vars, + imported_object_literals: &cross_module.imported_object_literals, compile_time_constants: native_facts.compile_time_constants(), target_triple: &cross_module.target_triple, app_metadata: &cross_module.app_metadata, diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 7fc4ac852c..c085123359 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -914,7 +914,9 @@ pub(super) fn compile_module_entry( local_class_field_aliases: HashMap::new(), local_id_to_name: HashMap::new(), local_value_aliases: HashMap::new(), + local_imported_object_aliases: HashMap::new(), imported_vars: &cross_module.imported_vars, + imported_object_literals: &cross_module.imported_object_literals, compile_time_constants: main_native_facts.compile_time_constants(), target_triple: &cross_module.target_triple, app_metadata: &cross_module.app_metadata, @@ -1619,7 +1621,9 @@ pub(super) fn compile_module_entry( local_class_field_aliases: HashMap::new(), local_id_to_name: HashMap::new(), local_value_aliases: HashMap::new(), + local_imported_object_aliases: HashMap::new(), imported_vars: &cross_module.imported_vars, + imported_object_literals: &cross_module.imported_object_literals, compile_time_constants: init_native_facts.compile_time_constants(), target_triple: &cross_module.target_triple, app_metadata: &cross_module.app_metadata, diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index 0e59ec451f..04459d32f0 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -1166,7 +1166,9 @@ pub(super) fn compile_function( local_class_field_aliases: HashMap::new(), local_id_to_name: HashMap::new(), local_value_aliases: HashMap::new(), + local_imported_object_aliases: HashMap::new(), imported_vars: &cross_module.imported_vars, + imported_object_literals: &cross_module.imported_object_literals, compile_time_constants: native_facts.compile_time_constants(), target_triple: &cross_module.target_triple, app_metadata: &cross_module.app_metadata, diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index f5ad672265..bcf5e4d198 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -496,7 +496,9 @@ pub(super) fn compile_method( local_class_field_aliases: HashMap::new(), local_id_to_name: HashMap::new(), local_value_aliases: HashMap::new(), + local_imported_object_aliases: HashMap::new(), imported_vars: &cross_module.imported_vars, + imported_object_literals: &cross_module.imported_object_literals, compile_time_constants: native_facts.compile_time_constants(), target_triple: &cross_module.target_triple, app_metadata: &cross_module.app_metadata, @@ -1768,7 +1770,9 @@ pub(super) fn compile_static_method( local_class_field_aliases: HashMap::new(), local_id_to_name: HashMap::new(), local_value_aliases: HashMap::new(), + local_imported_object_aliases: HashMap::new(), imported_vars: &cross_module.imported_vars, + imported_object_literals: &cross_module.imported_object_literals, compile_time_constants: native_facts.compile_time_constants(), target_triple: &cross_module.target_triple, app_metadata: &cross_module.app_metadata, diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index 0f21557b32..3be4a14d68 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -240,7 +240,8 @@ pub(crate) use helpers::{ module_callable_count, set_full_outline_ic, write_barriers_enabled, }; pub use opts::{ - AppMetadata, CompileOptions, FpContractMode, ImportedClass, NamespaceEntry, NamespaceEntryKind, + AppMetadata, CompileOptions, ExportedObjectLiteralCapability, FpContractMode, ImportedClass, + ImportedObjectLiteral, ImportedObjectLiteralMethod, NamespaceEntry, NamespaceEntryKind, }; pub(crate) use opts::{CrossModuleCtx, ImportedCtor}; pub(crate) use param_guard::scalar_descriptor_rep; @@ -2219,6 +2220,26 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> }) .collect(); + let imported_object_literals: std::collections::HashMap = opts + .imported_classes + .iter() + .filter_map(|imported| { + imported + .object_literal + .as_ref() + .map(|object| (object.local_binding.clone(), object.clone())) + }) + .collect(); + for object in imported_object_literals.values() { + llmod.add_external_global( + &format!( + "perry_global_{}__{}", + object.source_prefix, object.source_global_id + ), + DOUBLE, + ); + } + let mut cross_module = CrossModuleCtx { namespace_imports: opts.namespace_imports.iter().cloned().collect(), namespace_member_nested: opts.namespace_member_nested.iter().cloned().collect(), @@ -2295,6 +2316,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> } }), imported_vars: opts.imported_vars, + imported_object_literals, needs_stdlib: opts.needs_stdlib, needs_geisterhand: opts.needs_geisterhand, geisterhand_port: opts.geisterhand_port, diff --git a/crates/perry-codegen/src/codegen/opts.rs b/crates/perry-codegen/src/codegen/opts.rs index 97d21fe568..cc33764172 100644 --- a/crates/perry-codegen/src/codegen/opts.rs +++ b/crates/perry-codegen/src/codegen/opts.rs @@ -607,6 +607,48 @@ pub struct ImportedClass { /// facts. Kept beside the class metadata so the proof and the field layout /// it names enter the consumer atomically and share one object-cache key. pub return_shape_imports: Vec, + /// Producer-authored capability for an imported, immutable object-literal + /// binding. Such entries also carry the anonymous shape class above so the + /// consumer can validate its source class/ShapeId pair, but they are not + /// JavaScript class imports and are only consumed by the guarded own-method + /// lowering. + pub object_literal: Option, +} + +/// One concise own method published by an exported object-literal capability. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImportedObjectLiteralMethod { + pub name: String, + pub func_id: u32, + pub param_count: usize, + pub field_index: u32, +} + +/// Consumer-resolved capability for one imported object-literal binding. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImportedObjectLiteral { + /// The identifier carried by `Expr::ExternFuncRef` in the consumer. + pub local_binding: String, + /// Public export name at the defining module, retained for diagnostics. + pub source_export_name: String, + pub source_prefix: String, + /// Consumer-local name of the imported anonymous shape stub. + pub receiver_class_name: String, + /// LocalId of the defining module's immutable global binding. + pub source_global_id: u32, + pub methods: Vec, +} + +/// Defining-module fact harvested before parallel code generation. Absence is +/// authoritative: importers never infer an object capability from a getter or +/// from their own call-site observations. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExportedObjectLiteralCapability { + pub class_name: String, + pub class_id: u32, + pub global_id: u32, + pub field_names: Vec, + pub methods: Vec, } /// Constructor metadata for a class imported from another module. @@ -793,6 +835,9 @@ pub(crate) struct CrossModuleCtx { pub i18n: Option, /// Names of imports that are exported variables (not functions). pub imported_vars: std::collections::HashSet, + /// Producer-authored immutable imported object-literal capabilities, + /// keyed by the consumer's local import binding. + pub imported_object_literals: std::collections::HashMap, /// Whether perry-stdlib will be linked into the final binary. When /// false, compile_module_entry skips the `js_stdlib_init_dispatch()` /// call in main's prologue because only the runtime is linked and diff --git a/crates/perry-codegen/src/collectors/mod.rs b/crates/perry-codegen/src/collectors/mod.rs index 09add949a7..2e1b86a636 100644 --- a/crates/perry-codegen/src/collectors/mod.rs +++ b/crates/perry-codegen/src/collectors/mod.rs @@ -30,6 +30,7 @@ mod loop_bounded_i32; mod mutation; mod not_bigint_locals; mod number_by_construction; +mod object_literal_exports; mod param_ranges; mod pointer_locals; mod proven_args; @@ -85,6 +86,7 @@ pub(crate) use integer_locals::{ pub(crate) use local_refs::{expr_contains_local_get, mark_all_candidate_refs_in_expr}; pub(crate) use mutation::{body_contains_call, body_contains_closure, has_any_mutation}; pub(crate) use number_by_construction::collect_number_by_construction_locals; +pub(crate) use object_literal_exports::exported_object_literal_capabilities; pub(crate) use param_ranges::{collect_param_int_ranges, ParamIntRanges}; pub(crate) use pointer_locals::collect_pointer_typed_locals; pub(crate) use proven_args::{ diff --git a/crates/perry-codegen/src/collectors/object_literal_exports.rs b/crates/perry-codegen/src/collectors/object_literal_exports.rs new file mode 100644 index 0000000000..20f551e549 --- /dev/null +++ b/crates/perry-codegen/src/collectors/object_literal_exports.rs @@ -0,0 +1,205 @@ +//! Producer-side capabilities for stable exported object literals. +//! +//! The consumer cannot inspect another module's initializer or closure bodies, +//! so this collector is the sole authority for the guarded direct-call route. +//! It recognizes the source-ordered object-building IIFE emitted by HIR only +//! when that IIFE starts from a non-zero anonymous shape and finishes with an +//! own concise method in the corresponding inline field. + +use std::collections::{HashMap, HashSet}; + +use perry_hir::{Export, Expr, Module, Stmt}; + +use crate::codegen::{ExportedObjectLiteralCapability, ImportedObjectLiteralMethod}; + +fn local_get_is(expr: &Expr, expected: u32) -> bool { + matches!(expr, Expr::LocalGet(id) if *id == expected) +} + +fn capability_from_init( + hir: &Module, + global_id: u32, + init: &Expr, +) -> Option { + let Expr::Call { callee, args, .. } = init else { + return None; + }; + let Expr::Closure { + params, + body, + is_async: false, + is_generator: false, + .. + } = callee.as_ref() + else { + return None; + }; + let [param] = params.as_slice() else { + return None; + }; + if param.name != "__perry_obj_iife" { + return None; + } + let [Expr::New { + class_name, + args: seed_args, + .. + }] = args.as_slice() + else { + return None; + }; + let class = hir.classes.iter().find(|class| { + class.name == *class_name + && class.id != 0 + && class.fields.iter().all(|field| field.key_expr.is_none()) + })?; + if class.fields.len() != seed_args.len() + || !seed_args.iter().all(|arg| matches!(arg, Expr::Undefined)) + { + return None; + } + + // Last source write wins. A later data/function-valued write to the same + // key deliberately erases an earlier concise-method capability. + let mut final_methods: HashMap> = HashMap::new(); + let mut saw_return = false; + for stmt in body { + match stmt { + Stmt::Expr(Expr::IndexSet { object, index, .. }) if local_get_is(object, param.id) => { + let Expr::String(key) = index.as_ref() else { + return None; + }; + final_methods.insert(key.clone(), None); + } + Stmt::Expr(Expr::Call { callee, args, .. }) => { + let Expr::ExternFuncRef { name, .. } = callee.as_ref() else { + return None; + }; + if name != "js_object_set_method_by_name" { + return None; + } + let [receiver, Expr::String(key), value] = args.as_slice() else { + return None; + }; + if !local_get_is(receiver, param.id) { + return None; + } + let Expr::Closure { + func_id, + params, + captures_this: true, + is_arrow: false, + is_async: false, + is_generator: false, + .. + } = value + else { + final_methods.insert(key.clone(), None); + continue; + }; + // The first slice uses the existing exact-arity closure guard. + // Rest and synthesized `arguments` slots remain generic. + if params + .iter() + .any(|param| param.is_rest || param.arguments_object.is_some()) + { + final_methods.insert(key.clone(), None); + continue; + } + let field_index = class.fields.iter().position(|field| field.name == *key)? as u32; + final_methods.insert( + key.clone(), + Some(ImportedObjectLiteralMethod { + name: key.clone(), + func_id: *func_id, + param_count: params.len(), + field_index, + }), + ); + } + Stmt::Return(Some(value)) if local_get_is(value, param.id) && !saw_return => { + saw_return = true; + } + _ => return None, + } + } + if !saw_return { + return None; + } + + let field_names: Vec = class + .fields + .iter() + .map(|field| field.name.clone()) + .collect(); + let field_set: HashSet<&str> = field_names.iter().map(String::as_str).collect(); + if final_methods + .keys() + .any(|key| !field_set.contains(key.as_str())) + { + return None; + } + let mut methods: Vec = + final_methods.into_values().flatten().collect(); + methods.sort_by_key(|method| method.field_index); + if methods.is_empty() { + return None; + } + + Some(ExportedObjectLiteralCapability { + class_name: class.name.clone(), + class_id: class.id, + global_id, + field_names, + methods, + }) +} + +pub(crate) fn exported_object_literal_capabilities( + hir: &Module, +) -> HashMap { + let exported_objects: HashSet<&str> = hir.exported_objects.iter().map(String::as_str).collect(); + let mut exported_locals: HashSet<&str> = exported_objects.clone(); + for export in &hir.exports { + if let Export::Named { local, exported } = export { + if exported_objects.contains(exported.as_str()) { + exported_locals.insert(local.as_str()); + } + } + } + let mut by_local = HashMap::new(); + for stmt in crate::codegen::entry_outline::logical_entry_stmts(hir) { + let Stmt::Let { + id, + name, + mutable: false, + init: Some(init), + .. + } = stmt + else { + continue; + }; + if !exported_locals.contains(name.as_str()) { + continue; + } + if let Some(capability) = capability_from_init(hir, *id, init) { + by_local.insert(name.clone(), capability); + } + } + + let mut published = HashMap::new(); + for (local, capability) in &by_local { + // `export default { ... }` and direct named exports both use their + // public name in `exported_objects`; retain that fail-safe route even + // if an older HIR producer omitted a redundant `Export::Named` row. + published.insert(local.clone(), capability.clone()); + } + for export in &hir.exports { + if let Export::Named { local, exported } = export { + if let Some(capability) = by_local.get(local) { + published.insert(exported.clone(), capability.clone()); + } + } + } + published +} diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 54eb58b38b..fb58fc212d 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -1265,10 +1265,17 @@ pub(crate) struct FnCtx<'a> { /// receiver calls. pub local_value_aliases: std::collections::HashMap, + /// Immutable local aliases of producer-proven imported object literals. + /// The value is the original consumer import binding used to look up the + /// cross-module capability. + pub local_imported_object_aliases: std::collections::HashMap, + /// Names of imports that are exported variables (not functions). /// When an ExternFuncRef with one of these names appears as a value, /// the codegen calls the getter instead of wrapping as a closure. pub imported_vars: &'a std::collections::HashSet, + pub imported_object_literals: + &'a std::collections::HashMap, /// Compile-time constant values for specific module globals. When a /// global is a known compile-time constant (e.g., `__platform__`), diff --git a/crates/perry-codegen/src/lib.rs b/crates/perry-codegen/src/lib.rs index c0d5068640..fb9f1454f5 100644 --- a/crates/perry-codegen/src/lib.rs +++ b/crates/perry-codegen/src/lib.rs @@ -71,8 +71,9 @@ pub(crate) mod typed_shape; pub mod types; pub use codegen::{ - compile_module, resolve_target_triple, AppMetadata, CompileOptions, FpContractMode, - ImportedClass, NamespaceEntry, NamespaceEntryKind, + compile_module, resolve_target_triple, AppMetadata, CompileOptions, + ExportedObjectLiteralCapability, FpContractMode, ImportedClass, ImportedObjectLiteral, + ImportedObjectLiteralMethod, NamespaceEntry, NamespaceEntryKind, }; pub use collectors::CjsPreambleCensus; @@ -89,6 +90,14 @@ pub fn exported_proven_this_method_capabilities( collectors::exportable_proven_this_method_capabilities(hir) } +/// Return immutable exported object-literal capabilities for the compile +/// driver to resolve through ESM aliases and re-exports. +pub fn exported_object_literal_method_capabilities( + hir: &perry_hir::Module, +) -> std::collections::HashMap { + collectors::exported_object_literal_capabilities(hir) +} + /// The shadow-stack field offsets generated code bakes into its inline root /// stores (#7088). /// diff --git a/crates/perry-codegen/src/lower_call/property_get.rs b/crates/perry-codegen/src/lower_call/property_get.rs index 03762fb4a3..993c1f811a 100644 --- a/crates/perry-codegen/src/lower_call/property_get.rs +++ b/crates/perry-codegen/src/lower_call/property_get.rs @@ -24,6 +24,7 @@ use crate::types::{DOUBLE, I1, I64}; mod dynamic_dispatch; mod fetch_chain; mod helpers; +mod imported_object; mod map_set; mod number_string; mod promise_chain; @@ -145,6 +146,19 @@ pub fn try_lower_property_get_method_call( return Ok(Some(value)); } + // Producer-proven immutable ESM object literals must run before the + // builtin-name routes below: an adapter is allowed to own a method named + // `set`, `toString`, `trim`, etc. + if let Some(value) = imported_object::try_lower_imported_object_method_call( + ctx, + object, + property, + args, + call_byte_offset, + )? { + return Ok(Some(value)); + } + // Number `.toFixed`/`.toPrecision`/`.toExponential`, Buffer/Number // `.toString(encoding|radix)`, and the universal `.toString()` arms. if let Some(value) = diff --git a/crates/perry-codegen/src/lower_call/property_get/imported_object.rs b/crates/perry-codegen/src/lower_call/property_get/imported_object.rs new file mode 100644 index 0000000000..f9712c0ac2 --- /dev/null +++ b/crates/perry-codegen/src/lower_call/property_get/imported_object.rs @@ -0,0 +1,235 @@ +//! Guarded direct calls for stable imported object-literal own methods (#8775). + +use anyhow::Result; +use perry_hir::Expr; + +use crate::expr::{ + emit_typed_feedback_register_site, lower_expr, unbox_to_i64, FnCtx, TypedFeedbackContract, + TypedFeedbackKind, +}; +use crate::native_value::LoweredValue; +use crate::rooting::{any_operand_may_collect, open_rooted_group, Repr}; +use crate::types::{DOUBLE, I32, I64, I8, PTR}; + +fn receiver_binding(ctx: &FnCtx<'_>, object: &Expr) -> Option { + match object { + Expr::ExternFuncRef { name, .. } if ctx.imported_object_literals.contains_key(name) => { + Some(name.clone()) + } + Expr::LocalGet(id) => ctx.local_imported_object_aliases.get(id).cloned(), + _ => None, + } +} + +fn spill_args(ctx: &mut FnCtx<'_>, args: &[String]) -> (String, String) { + if args.is_empty() { + return ("null".to_string(), "0".to_string()); + } + let buf = ctx.func.alloca_entry_array(DOUBLE, args.len()); + for (index, value) in args.iter().enumerate() { + let slot = ctx.block().gep(DOUBLE, &buf, &[(I64, &index.to_string())]); + ctx.block().store(DOUBLE, value, &slot); + } + (buf, args.len().to_string()) +} + +pub(super) fn try_lower_imported_object_method_call( + ctx: &mut FnCtx<'_>, + object: &Expr, + property: &str, + args: &[Expr], + call_byte_offset: u32, +) -> Result> { + let Some(binding) = receiver_binding(ctx, object) else { + return Ok(None); + }; + let Some(capability) = ctx.imported_object_literals.get(&binding).cloned() else { + return Ok(None); + }; + let Some(method) = capability + .methods + .iter() + .find(|method| method.name == property && method.param_count == args.len()) + .cloned() + else { + // Function-valued properties, accessors, and arity-changing methods are + // intentionally outside the capability. Let the universal dispatcher + // preserve their dynamic receiver/call semantics. + return Ok(None); + }; + let Some(expected_class_id) = ctx.class_ids.get(&capability.receiver_class_name).copied() + else { + return Ok(None); + }; + let Some(keys_global) = ctx + .class_keys_globals + .get(&capability.receiver_class_name) + .cloned() + else { + return Ok(None); + }; + + // JavaScript evaluates the receiver before arguments. Keep that value (and + // each argument) rooted through both branches, then run all guards after + // argument evaluation so a mutating argument cannot slip past the proof. + let mut roots = open_rooted_group(args.len() + 1); + let recv = lower_expr(ctx, object)?; + let receiver_collects = any_operand_may_collect(ctx, args.iter()); + let receiver_root = roots.adopt_emitted(ctx, Repr::Boxed, &recv, receiver_collects); + for (index, arg) in args.iter().enumerate() { + let collects = any_operand_may_collect(ctx, args[index + 1..].iter()); + roots.lower(ctx, arg, collects)?; + } + let recv = roots.reread_emitted(ctx, receiver_root); + let lowered_args = roots.reread_all(ctx)?; + + let key_index = ctx.strings.intern(property); + let key_entry = ctx.strings.entry(key_index); + let method_guard_slot = (key_entry.dispatch_hash & 0xffff).to_string(); + let dispatch_global = ctx.strings.static_dispatch_global(key_index); + let expected_shape_id = + crate::typed_shape::load_class_shape_id(ctx, &capability.receiver_class_name, &keys_global); + let closure_symbol = format!( + "perry_closure_{}__{}", + capability.source_prefix, method.func_id + ); + let mut closure_params = Vec::with_capacity(method.param_count + 1); + closure_params.push(I64); + closure_params.extend(std::iter::repeat_n(DOUBLE, method.param_count)); + ctx.pending_declares + .push((closure_symbol.clone(), DOUBLE, closure_params)); + + let shape_idx = ctx.new_block("imported_object.shape_guard"); + let method_idx = ctx.new_block("imported_object.method_guard"); + let direct_idx = ctx.new_block("imported_object.direct"); + let fallback_idx = ctx.new_block("imported_object.fallback"); + let merge_idx = ctx.new_block("imported_object.merge"); + let shape_label = ctx.block_label(shape_idx); + let method_label = ctx.block_label(method_idx); + let direct_label = ctx.block_label(direct_idx); + let fallback_label = ctx.block_label(fallback_idx); + let merge_label = ctx.block_label(merge_idx); + + // Exact binding identity is separate from shape: another object may share + // the same anonymous layout and even the same method closure function. + let source_global = format!( + "@perry_global_{}__{}", + capability.source_prefix, capability.source_global_id + ); + let expected_receiver = ctx.block().load(DOUBLE, &source_global); + let recv_bits = ctx.block().bitcast_double_to_i64(&recv); + let expected_bits = ctx.block().bitcast_double_to_i64(&expected_receiver); + let receiver_matches = ctx.block().icmp_eq(I64, &recv_bits, &expected_bits); + ctx.block() + .cond_br(&receiver_matches, &shape_label, &fallback_label); + + ctx.current_block = shape_idx; + crate::lower_call::method_override::emit_inline_direct_method_shape_guard( + ctx, + &recv, + &expected_class_id.to_string(), + &expected_shape_id, + &method_guard_slot, + &method_label, + &fallback_label, + ); + + // The exact shape proves the own data slot. Load it directly, then validate + // the live closure's underlying function identity. Replacement, deletion, + // accessors, and bound/arrow substitutes all fail one of these guards. + ctx.current_block = method_idx; + let closure_value = { + let header_skip = + crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string(); + let blk = ctx.block(); + let recv_handle = blk.and(I64, &recv_bits, crate::nanbox::POINTER_MASK_I64); + let object_ptr = blk.inttoptr(I64, &recv_handle); + let fields = blk.gep(I8, &object_ptr, &[(I64, &header_skip)]); + let slot = blk.gep(DOUBLE, &fields, &[(I64, &method.field_index.to_string())]); + blk.load(DOUBLE, &slot) + }; + let site_id = emit_typed_feedback_register_site( + ctx, + TypedFeedbackKind::ClosureCall, + &format!("imported-object:{binding}.{property}"), + TypedFeedbackContract::closure_direct_call(), + ); + let arity = method.param_count.to_string(); + let guard = ctx.block().call( + I32, + "js_typed_feedback_closure_direct_call_guard", + &[ + (I64, &site_id), + (DOUBLE, &closure_value), + (PTR, &format!("@{closure_symbol}")), + (I32, &arity), + (I32, &arity), + ], + ); + let guard_passes = ctx.block().icmp_ne(I32, &guard, "0"); + ctx.block() + .cond_br(&guard_passes, &direct_label, &fallback_label); + + ctx.current_block = direct_idx; + let closure_handle = unbox_to_i64(ctx.block(), &closure_value); + let mut direct_args: Vec<(crate::types::LlvmType, &str)> = + Vec::with_capacity(lowered_args.len() + 1); + direct_args.push((I64, &closure_handle)); + direct_args.extend(lowered_args.iter().map(|arg| (DOUBLE, arg.as_str()))); + let direct_value = ctx.block().call(DOUBLE, &closure_symbol, &direct_args); + let direct_end = ctx.block().label.clone(); + if !ctx.block().is_terminated() { + ctx.block().br(&merge_label); + } + + ctx.current_block = fallback_idx; + let method_id = crate::strings::emit_static_dispatch_id(ctx.block(), &dispatch_global); + let (args_ptr, args_len) = spill_args(ctx, &lowered_args); + crate::expr::calls::emit_call_location_at(ctx, call_byte_offset); + let fallback_value = ctx.block().call( + DOUBLE, + "js_native_call_method_by_id", + &[ + (DOUBLE, &recv), + (I64, &method_id), + (PTR, &args_ptr), + (I64, &args_len), + ], + ); + let fallback_end = ctx.block().label.clone(); + if !ctx.block().is_terminated() { + ctx.block().br(&merge_label); + } + + ctx.current_block = merge_idx; + let result = ctx.block().phi( + DOUBLE, + &[ + (direct_value.as_str(), direct_end.as_str()), + (fallback_value.as_str(), fallback_end.as_str()), + ], + ); + roots.release(ctx); + ctx.record_lowered_value( + "MethodCall", + None, + "imported_object_literal_method_direct_call", + &LoweredValue::js_value(result.clone()), + None, + None, + None, + false, + false, + vec![ + "receiver_provenance=imported_object_literal_metadata".to_string(), + format!("source_export={}", capability.source_export_name), + format!("receiver_class={}", capability.receiver_class_name), + format!("method={property}"), + format!("selected_method_identity={closure_symbol}"), + format!("field_index={}", method.field_index), + "guards=receiver_identity,exact_shape,own_data_slot,function_identity".to_string(), + "generic_dispatch_fallback=js_native_call_method_by_id".to_string(), + ], + ); + Ok(Some(result)) +} diff --git a/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs b/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs index b684b90afe..d819b69c58 100644 --- a/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs +++ b/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs @@ -498,6 +498,7 @@ fn imported_remote() -> ImportedClass { ])], source_class_id: Some(55), return_shape_imports: Vec::new(), + object_literal: None, } } diff --git a/crates/perry-codegen/src/stmt/let_object_facts.rs b/crates/perry-codegen/src/stmt/let_object_facts.rs new file mode 100644 index 0000000000..47d5717160 --- /dev/null +++ b/crates/perry-codegen/src/stmt/let_object_facts.rs @@ -0,0 +1,54 @@ +//! Object-literal facts recorded while lowering immutable local bindings. + +use crate::expr::FnCtx; + +/// #5271: recognize both data-only object literals and method/getter IIFEs so +/// own members win over built-in prototype methods during lowering. +pub(super) fn is_object_literal_init(init: &perry_hir::Expr) -> bool { + use perry_hir::Expr; + match init { + Expr::Object(_) => true, + Expr::Call { callee, args, .. } => { + (matches!(args.first(), Some(Expr::Object(_))) + || matches!( + args.first(), + Some(Expr::New { class_name, .. }) + if class_name.starts_with("__AnonShape_") + )) + && matches!( + callee.as_ref(), + Expr::Closure { params, .. } + if params.first().is_some_and(|p| p.name == "__perry_obj_iife") + ) + } + _ => false, + } +} + +/// Propagate a producer-proven imported object through immutable local aliases. +/// Mutable or reassigned bindings remain generic so replacement stays visible. +pub(super) fn record_imported_object_alias( + ctx: &mut FnCtx<'_>, + id: u32, + init: Option<&perry_hir::Expr>, + mutable: bool, +) { + let binding = (!mutable && !ctx.reassigned_locals.contains(&id)) + .then(|| match init { + Some(perry_hir::Expr::ExternFuncRef { name, .. }) + if ctx.imported_object_literals.contains_key(name) => + { + Some(name.clone()) + } + Some(perry_hir::Expr::LocalGet(source_id)) => { + ctx.local_imported_object_aliases.get(source_id).cloned() + } + _ => None, + }) + .flatten(); + if let Some(binding) = binding { + ctx.local_imported_object_aliases.insert(id, binding); + } else { + ctx.local_imported_object_aliases.remove(&id); + } +} diff --git a/crates/perry-codegen/src/stmt/let_stmt.rs b/crates/perry-codegen/src/stmt/let_stmt.rs index 31f9fed412..d0c41efa1a 100644 --- a/crates/perry-codegen/src/stmt/let_stmt.rs +++ b/crates/perry-codegen/src/stmt/let_stmt.rs @@ -1,4 +1,5 @@ use super::let_buffer_views::{math_min_length_buffer_ids, register_noalias_buffer_view}; +use super::let_object_facts::{is_object_literal_init, record_imported_object_alias}; use super::let_stmt_facts::{ buffer_local_alias_source, collect_scalar_class_data, native_i32_alias_source, note_ptr_shape_scalar_replaced, pod_view_count_source, record_array_length_snapshot, @@ -17,24 +18,6 @@ use crate::native_value::{ use crate::type_analysis::is_string_expr; use crate::types::{DOUBLE, I1, I32, I64, I8, PTR}; -/// #5271: recognize both data-only object literals and method/getter IIFEs so -/// own members win over built-in prototype methods during lowering. -fn is_object_literal_init(init: &perry_hir::Expr) -> bool { - use perry_hir::Expr; - match init { - Expr::Object(_) => true, - Expr::Call { callee, args, .. } => { - matches!(args.first(), Some(Expr::Object(_))) - && matches!( - callee.as_ref(), - Expr::Closure { params, .. } - if params.first().is_some_and(|p| p.name == "__perry_obj_iife") - ) - } - _ => false, - } -} - fn is_global_this_value(expr: &perry_hir::Expr) -> bool { matches!(expr, perry_hir::Expr::GlobalGet(_)) || matches!( @@ -91,6 +74,7 @@ pub(crate) fn lower_let( ctx.local_func_ref_ids.insert(id, *func_id); } } + record_imported_object_alias(ctx, id, init, mutable); // Record immutable literal metadata before the module-global and boxed // storage paths return. The loop/PIC matchers reason from the HIR local id, // so the storage representation does not change the const proof. diff --git a/crates/perry-codegen/src/stmt/mod.rs b/crates/perry-codegen/src/stmt/mod.rs index 49afe99e53..98a682b315 100644 --- a/crates/perry-codegen/src/stmt/mod.rs +++ b/crates/perry-codegen/src/stmt/mod.rs @@ -21,6 +21,7 @@ mod element_shape_loop; mod element_shape_loop_tests; mod if_stmt; mod let_buffer_views; +mod let_object_facts; mod let_stmt; mod let_stmt_facts; mod loops; diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index 3008287468..aa69e8a842 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -569,6 +569,7 @@ fn compute_object_cache_key_with_env( a.name .cmp(&b.name) .then(a.source_prefix.cmp(&b.source_prefix)) + .then(a.local_alias.cmp(&b.local_alias)) }); let mut buf = String::new(); for c in v { @@ -693,6 +694,25 @@ fn compute_object_cache_key_with_env( let mut return_shape_imports = c.return_shape_imports.clone(); return_shape_imports.sort(); buf.push_str(&return_shape_imports.join(",")); + buf.push_str(":object_literal="); + if let Some(object) = &c.object_literal { + buf.push_str(&format!( + "{}@{}:{}:{}:{}:", + object.local_binding, + object.source_prefix, + object.source_export_name, + object.receiver_class_name, + object.source_global_id, + )); + let mut methods = object.methods.clone(); + methods.sort_by(|left, right| left.name.cmp(&right.name)); + for method in methods { + buf.push_str(&format!( + "{}={}/{}/{};", + method.name, method.func_id, method.param_count, method.field_index + )); + } + } buf.push('|'); } h.field("imported_classes", &buf); diff --git a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs index d31e8f9143..518d813d99 100644 --- a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs +++ b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs @@ -373,6 +373,7 @@ fn key_stable_for_nested_type_hashmap_order() { static_field_names: vec![], source_class_id: Some(7), return_shape_imports: vec![], + object_literal: None, }; a = empty_opts(); @@ -431,6 +432,7 @@ fn key_changes_with_imported_class_signature() { static_field_names: vec![], source_class_id: Some(42), return_shape_imports: vec![], + object_literal: None, }); b.imported_classes.push(ImportedClass { name: "Foo".into(), @@ -462,6 +464,7 @@ fn key_changes_with_imported_class_signature() { static_field_names: vec![], source_class_id: Some(42), return_shape_imports: vec![], + object_literal: None, }); assert_ne!( compute_object_cache_key(&a, 1, "0.5.156"), @@ -501,6 +504,7 @@ fn key_changes_with_imported_class_codegen_surface() { static_field_names: vec![], source_class_id: Some(42), return_shape_imports: vec![], + object_literal: None, }; let key_for = |class: ImportedClass| { let mut opts = empty_opts(); @@ -565,6 +569,22 @@ fn key_changes_with_imported_class_codegen_surface() { changed.return_shape_imports = vec!["makeRow".into()]; assert_ne!(base_key, key_for(changed)); + let mut changed = base.clone(); + changed.object_literal = Some(perry_codegen::ImportedObjectLiteral { + local_binding: "adapter".into(), + source_export_name: "default".into(), + source_prefix: "adapter_js".into(), + receiver_class_name: "__ImportedObject_adapter".into(), + source_global_id: 17, + methods: vec![perry_codegen::ImportedObjectLiteralMethod { + name: "run".into(), + func_id: 9, + param_count: 1, + field_index: 2, + }], + }); + assert_ne!(base_key, key_for(changed)); + let mut first_order = base.clone(); first_order.return_shape_imports = vec!["makeB".into(), "makeA".into()]; let mut second_order = base.clone(); diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index 2cd1e5794d..636f22284a 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -339,6 +339,61 @@ fn imported_class_from_hir( .collect(), source_class_id: Some(class.id), return_shape_imports: Vec::new(), + object_literal: None, + } +} + +fn imported_object_literal_from_capability( + capability: &perry_codegen::ExportedObjectLiteralCapability, + source_prefix: String, + source_export_name: String, + local_binding: String, +) -> perry_codegen::ImportedClass { + // Keep the anonymous shape distinct from user-visible class/import names + // in the consumer while retaining the producer's class name for external + // keys/constructor symbol formation. + let receiver_class_name = format!( + "__ImportedObject_{}_{}_{}", + source_prefix, capability.global_id, local_binding + ); + perry_codegen::ImportedClass { + name: capability.class_name.clone(), + local_alias: Some(receiver_class_name.clone()), + source_prefix: source_prefix.clone(), + constructor_param_count: capability.field_names.len(), + has_own_constructor: true, + constructor_has_rest: false, + has_instance_fields: !capability.field_names.is_empty(), + method_names: Vec::new(), + proven_this_method_names: Vec::new(), + proven_this_tower_method_names: Vec::new(), + method_return_types: Vec::new(), + method_param_counts: Vec::new(), + method_has_rest: Vec::new(), + method_has_synthetic_arguments: Vec::new(), + static_field_names: Vec::new(), + static_method_names: Vec::new(), + static_method_return_types: Vec::new(), + static_method_param_counts: Vec::new(), + static_method_has_rest: Vec::new(), + static_method_has_user_rest: Vec::new(), + static_method_has_synthetic_arguments: Vec::new(), + getter_names: Vec::new(), + getter_return_types: Vec::new(), + setter_names: Vec::new(), + parent_name: None, + field_names: capability.field_names.clone(), + field_types: vec![perry_hir::types::Type::Any; capability.field_names.len()], + source_class_id: Some(capability.class_id), + return_shape_imports: Vec::new(), + object_literal: Some(perry_codegen::ImportedObjectLiteral { + local_binding, + source_export_name, + source_prefix, + receiver_class_name, + source_global_id: capability.global_id, + methods: capability.methods.clone(), + }), } } @@ -929,6 +984,22 @@ pub fn run_with_parse_cache( } } + // Immutable object-literal method capabilities are keyed exactly like the + // other producer facts below. Import resolution later follows barrels to + // this defining path/name before installing a consumer-local binding. + let mut exported_object_literals: BTreeMap< + (String, String), + perry_codegen::ExportedObjectLiteralCapability, + > = BTreeMap::new(); + for (path, hir_module) in &ctx.native_modules { + let path_str = path.to_string_lossy().to_string(); + for (export_name, capability) in + perry_codegen::exported_object_literal_method_capabilities(hir_module) + { + exported_object_literals.insert((path_str.clone(), export_name), capability); + } + } + // Propagate enum re-exports: when module A has `export * from "./B"`, // all enums exported from B should also be accessible via A's path. loop { @@ -4067,6 +4138,20 @@ pub fn run_with_parse_cache( if local_name != exported_name { imported_vars.insert(local_name.clone()); } + + let origin_export_name = resolved_origin_name + .clone() + .unwrap_or_else(|| exported_name.clone()); + if let Some(capability) = exported_object_literals + .get(&(origin_path.clone(), origin_export_name.clone())) + { + imported_classes.push(imported_object_literal_from_capability( + capability, + effective_prefix.clone(), + origin_export_name, + local_name.clone(), + )); + } } // Imported classes diff --git a/crates/perry/tests/issue_8775_imported_object_specialization.rs b/crates/perry/tests/issue_8775_imported_object_specialization.rs new file mode 100644 index 0000000000..b89262d24f --- /dev/null +++ b/crates/perry/tests/issue_8775_imported_object_specialization.rs @@ -0,0 +1,261 @@ +//! Guarded specialization of stable imported object-literal methods (#8775). + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::Once; + +const GC_ENV_OVERRIDES: &[&str] = &[ + "PERRY_GEN_GC", + "PERRY_GC_SCAVENGE", + "PERRY_GC_SCAVENGE_NURSERY_MB", + "PERRY_GC_MOVING_SAFEPOINT", + "PERRY_GC_MOVING_LOOP_POLLS", + "PERRY_GC_FORCE_EVACUATE", + "PERRY_GC_VERIFY_EVACUATION", + "PERRY_CONSERVATIVE_STACK_SCAN", + "PERRY_WRITE_BARRIERS", + "PERRY_GC_INCREMENTAL", + "PERRY_GC_HEAP_LIMIT", +]; + +fn remove_gc_env_overrides(command: &mut Command) { + for key in GC_ENV_OVERRIDES { + command.env_remove(key); + } +} + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .expect("canonicalize workspace root") +} + +fn fixture_dir() -> PathBuf { + workspace_root().join("test-files/fixtures/issue_8775_imported_object") +} + +fn target_debug_dir() -> PathBuf { + let target = std::env::var_os("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| workspace_root().join("target")); + if cfg!(windows) { + target.join("x86_64-pc-windows-msvc").join("debug") + } else { + target.join("debug") + } +} + +fn assert_success(label: &str, output: &Output) { + assert!( + output.status.success(), + "{label} failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn ensure_runtime_archive() { + static BUILD_RUNTIME: Once = Once::new(); + BUILD_RUNTIME.call_once(|| { + let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); + let mut command = Command::new(cargo); + command + .current_dir(workspace_root()) + .arg("build") + .arg("-p") + .arg("perry-runtime-static"); + if cfg!(windows) { + command.arg("--target").arg("x86_64-pc-windows-msvc"); + } + let output = command.output().expect("build static runtime archive"); + assert_success("static runtime build", &output); + }); +} + +fn copy_fixture(dir: &Path) { + for file in [ + "package.json", + "adapter.js", + "barrel.js", + "main.js", + "semantics.js", + ] { + std::fs::copy(fixture_dir().join(file), dir.join(file)) + .unwrap_or_else(|error| panic!("copy {file}: {error}")); + } +} + +fn compile(dir: &Path, entry: &str, explain: bool) -> PathBuf { + ensure_runtime_archive(); + let binary = dir.join(format!("{entry}.bin")); + let mut command = Command::new(PathBuf::from(env!("CARGO_BIN_EXE_perry"))); + command + .current_dir(dir) + .arg("compile") + .arg(entry) + .arg("-o") + .arg(&binary) + .arg("--no-cache") + .arg("--trace") + .arg("llvm") + .env("PERRY_NO_AUTO_OPTIMIZE", "1") + .env("PERRY_RUNTIME_DIR", target_debug_dir()); + if explain { + command.arg("--opt-report=json").arg("--explain-lowering"); + } + remove_gc_env_overrides(&mut command); + let output = command.output().expect("run Perry compile"); + assert_success("Perry compile", &output); + binary +} + +fn run(binary: &Path, dir: &Path, moving_gc: bool) -> String { + let mut command = Command::new(binary); + command.current_dir(dir); + remove_gc_env_overrides(&mut command); + if moving_gc { + command + .env("PERRY_GC_SCAVENGE", "1") + .env("PERRY_GC_SCAVENGE_NURSERY_MB", "1") + .env("PERRY_GC_FORCE_EVACUATE", "1") + .env("PERRY_GC_VERIFY_EVACUATION", "1") + .env("PERRY_GC_INCREMENTAL", "0"); + } + let output = command.output().expect("run compiled fixture"); + assert_success("compiled fixture", &output); + String::from_utf8(output.stdout).expect("fixture stdout is UTF-8") +} + +fn run_node(dir: &Path, entry: &str) -> String { + let output = Command::new("node") + .current_dir(dir) + .arg(entry) + .output() + .expect("run Node oracle"); + assert_success("Node oracle", &output); + String::from_utf8(output.stdout).expect("Node stdout is UTF-8") +} + +fn read_native_records(dir: &Path) -> Vec { + let lowering = dir.join(".perry-trace/lowering"); + let run_dir = std::fs::read_dir(&lowering) + .expect("read lowering directory") + .filter_map(Result::ok) + .map(|entry| entry.path()) + .find(|path| path.is_dir()) + .expect("lowering run directory"); + let mut records = Vec::new(); + for entry in std::fs::read_dir(run_dir).expect("read lowering run") { + let path = entry.expect("lowering entry").path(); + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if !name.starts_with("perry_native_reps_") || !name.ends_with(".json") { + continue; + } + let artifact: serde_json::Value = serde_json::from_slice( + &std::fs::read(&path) + .unwrap_or_else(|error| panic!("read {}: {error}", path.display())), + ) + .unwrap_or_else(|error| panic!("parse {}: {error}", path.display())); + records.extend( + artifact["records"] + .as_array() + .unwrap_or_else(|| panic!("missing records in {}", path.display())) + .iter() + .cloned(), + ); + } + records +} + +fn record_notes(record: &serde_json::Value) -> Vec<&str> { + record["notes"] + .as_array() + .into_iter() + .flatten() + .filter_map(serde_json::Value::as_str) + .collect() +} + +#[test] +fn stable_imported_object_methods_use_guarded_direct_closure_bodies() { + let temp = tempfile::tempdir().expect("tempdir"); + copy_fixture(temp.path()); + let binary = compile(temp.path(), "main.js", true); + + let node = run_node(temp.path(), "main.js"); + assert_eq!(run(&binary, temp.path(), false), node); + assert_eq!(run(&binary, temp.path(), true), node); + assert_eq!(node.trim(), r#"{"checksum":400000,"remaining":0}"#); + + let main_ir = std::fs::read_to_string(temp.path().join(".perry-trace/llvm/main_js.ll")) + .expect("read main LLVM IR"); + let adapter_ir = std::fs::read_to_string(temp.path().join(".perry-trace/llvm/adapter_js.ll")) + .expect("read adapter LLVM IR"); + for func_id in [6, 7, 8, 9] { + let symbol = format!("perry_closure_adapter_js__{func_id}"); + assert!( + adapter_ir.contains(&format!("define double @{symbol}(")), + "producer closure must have external linkage: {symbol}" + ); + let fast_block = main_ir + .split("\n\n") + .find(|block| block.contains("imported_object.direct.") && block.contains(&symbol)) + .unwrap_or_else(|| panic!("no imported-object direct block for {symbol}:\n{main_ir}")); + assert!( + !fast_block.contains("js_native_call_method_by_id") + && !fast_block.contains("js_typed_feedback_native_call_method_by_id") + && !fast_block.contains("js_native_call_value"), + "direct block must not redispatch dynamically:\n{fast_block}" + ); + } + assert!(main_ir.contains("call double @js_native_call_method_by_id")); + assert!( + main_ir + .lines() + .any(|line| line.starts_with("@perry_global_adapter_js__") + && line.ends_with(" = external global double")), + "consumer must load the producer's exported object identity:\n{main_ir}" + ); + + let records = read_native_records(temp.path()); + let selected: Vec<_> = records + .iter() + .filter(|record| record["consumer"] == "imported_object_literal_method_direct_call") + .collect(); + assert!( + selected.len() >= 5, + "missing selected records: {records:#?}" + ); + for record in selected { + let notes = record_notes(record); + assert!(notes.contains(&"receiver_provenance=imported_object_literal_metadata")); + assert!(notes.contains(&"generic_dispatch_fallback=js_native_call_method_by_id")); + assert!( + notes.contains(&"guards=receiver_identity,exact_shape,own_data_slot,function_identity") + ); + } +} + +#[test] +fn mutations_rebinding_function_values_proxy_and_barrel_match_node() { + let temp = tempfile::tempdir().expect("tempdir"); + copy_fixture(temp.path()); + let binary = compile(temp.path(), "semantics.js", false); + let node = run_node(temp.path(), "semantics.js"); + assert_eq!(run(&binary, temp.path(), false), node); + assert_eq!(run(&binary, temp.path(), true), node); + + let ir = std::fs::read_to_string(temp.path().join(".perry-trace/llvm/semantics_js.ll")) + .expect("read semantics LLVM IR"); + assert!( + ir.contains("call double @perry_closure_adapter_js__7"), + "barrel import must retain producer method provenance:\n{ir}" + ); + assert!( + ir.contains("call double @js_native_call_method_by_id"), + "mutation-sensitive paths must retain the generic fallback:\n{ir}" + ); +} diff --git a/test-files/fixtures/issue_8775_imported_object/adapter.js b/test-files/fixtures/issue_8775_imported_object/adapter.js new file mode 100644 index 0000000000..543c078f60 --- /dev/null +++ b/test-files/fixtures/issue_8775_imported_object/adapter.js @@ -0,0 +1,37 @@ +class Store { + constructor() { + this.entities = []; + } + + create(id) { + const entity = { id, value: 0 }; + this.entities.push(entity); + return entity; + } + + add(entity) { + entity.value += 1; + } + + destroy(entity) { + const index = this.entities.indexOf(entity); + if (index !== -1) this.entities.splice(index, 1); + return entity.value; + } +} + +export default { + store: null, + setup() { + this.store = new Store(); + }, + createEntity(id) { + return this.store.create(id); + }, + addComponent(entity) { + this.store.add(entity); + }, + destroyEntity(entity) { + return this.store.destroy(entity); + }, +}; diff --git a/test-files/fixtures/issue_8775_imported_object/barrel.js b/test-files/fixtures/issue_8775_imported_object/barrel.js new file mode 100644 index 0000000000..6a01ad8f1a --- /dev/null +++ b/test-files/fixtures/issue_8775_imported_object/barrel.js @@ -0,0 +1 @@ +export { default } from "./adapter.js"; diff --git a/test-files/fixtures/issue_8775_imported_object/main.js b/test-files/fixtures/issue_8775_imported_object/main.js new file mode 100644 index 0000000000..66f62d231f --- /dev/null +++ b/test-files/fixtures/issue_8775_imported_object/main.js @@ -0,0 +1,15 @@ +import adapter from "./adapter.js"; + +adapter.setup(); +let checksum = 0; +const iterations = 200_000; +for (let i = 0; i < iterations; i++) { + const entity = adapter.createEntity(i); + adapter.addComponent(entity); + adapter.addComponent(entity); + checksum += adapter.destroyEntity(entity); +} +console.log(JSON.stringify({ + checksum, + remaining: adapter.store.entities.length, +})); diff --git a/test-files/fixtures/issue_8775_imported_object/package.json b/test-files/fixtures/issue_8775_imported_object/package.json new file mode 100644 index 0000000000..089153bcb5 --- /dev/null +++ b/test-files/fixtures/issue_8775_imported_object/package.json @@ -0,0 +1 @@ +{"type":"module"} diff --git a/test-files/fixtures/issue_8775_imported_object/semantics.js b/test-files/fixtures/issue_8775_imported_object/semantics.js new file mode 100644 index 0000000000..19cb62bb1e --- /dev/null +++ b/test-files/fixtures/issue_8775_imported_object/semantics.js @@ -0,0 +1,86 @@ +import adapter from "./barrel.js"; + +const out = {}; +out.keys = Object.keys(adapter).join(","); +const setupDescriptor = Object.getOwnPropertyDescriptor(adapter, "setup"); +out.descriptor = [ + setupDescriptor.enumerable, + setupDescriptor.writable, + setupDescriptor.configurable, +].join(","); +adapter.setup(); + +const stableAlias = adapter; +const stable = stableAlias.createEntity(5); +stableAlias.addComponent(stable); +out.stable = stableAlias.destroyEntity(stable); + +const originalCreate = adapter.createEntity; +adapter.createEntity = function (id) { + return { id: id + 100, components: 7 }; +}; +const replaced = adapter.createEntity(2); +out.replacement = replaced.id + replaced.components; +adapter.createEntity = originalCreate; + +const originalAdd = adapter.addComponent; +delete adapter.addComponent; +adapter.addComponent = function (entity) { + entity.components += 4; +}; +const recreated = { id: 3, components: 1 }; +adapter.addComponent(recreated); +out.deleteRecreate = recreated.components; +adapter.addComponent = originalAdd; + +const originalDestroy = adapter.destroyEntity; +Object.defineProperty(adapter, "destroyEntity", { + configurable: true, + get() { + return function (entity) { + return entity.id + 1000; + }; + }, +}); +out.accessor = adapter.destroyEntity({ id: 4, components: 0 }); +Object.defineProperty(adapter, "destroyEntity", { + configurable: true, + enumerable: true, + writable: true, + value: originalDestroy, +}); + +adapter.tag = 10; +adapter.functionValue = function (value) { + return this.tag + value; +}; +out.functionValue = adapter.functionValue(2); +const rebound = adapter.functionValue; +out.rebound = rebound.call({ tag: 30 }, 2); + +const extracted = adapter.createEntity; +out.extracted = extracted.call(adapter, 9).id; + +const proxy = new Proxy(adapter, { + get(target, key) { + return target[key]; + }, +}); +out.proxy = proxy.createEntity(12).id; + +Object.setPrototypeOf(adapter, { + createEntity() { + return { id: -1, components: -1 }; + }, +}); +out.prototypeMutation = adapter.createEntity(13).id; + +let reassigned = adapter; +reassigned = { + createEntity(id) { + return { id: id + 500, components: 0 }; + }, +}; +out.reassignedAlias = reassigned.createEntity(1).id; + +console.log(JSON.stringify(out)); From 31bb07daa61095ea8a500714b06fd109bed161b6 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Tue, 25 Aug 2026 00:52:08 +0200 Subject: [PATCH 2/4] docs(changelog): note imported object method specialization --- changelog.d/8785-imported-object-method-specialization.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/8785-imported-object-method-specialization.md diff --git a/changelog.d/8785-imported-object-method-specialization.md b/changelog.d/8785-imported-object-method-specialization.md new file mode 100644 index 0000000000..2461ae5f9f --- /dev/null +++ b/changelog.d/8785-imported-object-method-specialization.md @@ -0,0 +1 @@ +Stable imported object-literal methods now use guarded direct calls to their defining closure bodies. The fast path validates the exported receiver identity, exact own-property shape, and live function identity, while replacements, deletions, accessors, proxies, receiver changes, and other dynamic cases retain the universal method-dispatch fallback. From cd175dbaa957bd9ee07fe3ea48ffd4a467e75573 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Tue, 25 Aug 2026 07:41:10 +0200 Subject: [PATCH 3/4] fix(codegen): address imported method review feedback --- crates/perry-codegen/src/codegen/mod.rs | 12 +++++++----- scripts/shape_descriptor_census_baseline.json | 3 ++- .../fixtures/issue_8775_imported_object/semantics.js | 8 ++++++++ 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index 3be4a14d68..4b6525e450 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -2230,12 +2230,14 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> .map(|object| (object.local_binding.clone(), object.clone())) }) .collect(); - for object in imported_object_literals.values() { + let imported_object_producers: std::collections::BTreeSet<(String, u32)> = + imported_object_literals + .values() + .map(|object| (object.source_prefix.clone(), object.source_global_id)) + .collect(); + for (source_prefix, source_global_id) in imported_object_producers { llmod.add_external_global( - &format!( - "perry_global_{}__{}", - object.source_prefix, object.source_global_id - ), + &format!("perry_global_{source_prefix}__{source_global_id}"), DOUBLE, ); } diff --git a/scripts/shape_descriptor_census_baseline.json b/scripts/shape_descriptor_census_baseline.json index a94d82f969..97901c90a0 100644 --- a/scripts/shape_descriptor_census_baseline.json +++ b/scripts/shape_descriptor_census_baseline.json @@ -15,6 +15,7 @@ "crates/perry-codegen/src/expr/proxy_reflect.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 2, "crates/perry-codegen/src/lower_call/new.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 1, "crates/perry-codegen/src/lower_call/new_alloc.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple);": 1, + "crates/perry-codegen/src/lower_call/property_get/imported_object.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 1, "crates/perry-codegen/src/lower_call/scalar_method.rs|let header_skip = crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 1, "crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs|8 + crate::target_layout::object_header_size_bytes( ) + 8 * slots;": 1, "crates/perry-codegen/src/stmt/loops.rs|let object_header_size = crate::target_layout::object_header_size_bytes(ctx.target_triple);": 1, @@ -49,7 +50,7 @@ "crates/perry-runtime/src/object/object_ops.rs|keys_array|declaration|pub(crate) use keys_array::{": 1 }, "summary": { - "codegen_object_header_size_sites": 40, + "codegen_object_header_size_sites": 41, "raw_member_files": 7, "raw_member_sites": { "keys_array": 24 diff --git a/test-files/fixtures/issue_8775_imported_object/semantics.js b/test-files/fixtures/issue_8775_imported_object/semantics.js index 19cb62bb1e..b518ac8b0c 100644 --- a/test-files/fixtures/issue_8775_imported_object/semantics.js +++ b/test-files/fixtures/issue_8775_imported_object/semantics.js @@ -60,6 +60,14 @@ out.rebound = rebound.call({ tag: 30 }, 2); const extracted = adapter.createEntity; out.extracted = extracted.call(adapter, 9).id; +const foreignReceiver = { + store: { + create(id) { + return { id: id + 700, components: 44 }; + }, + }, +}; +out.foreignReceiver = extracted.call(foreignReceiver, 10).id; const proxy = new Proxy(adapter, { get(target, key) { From 28cf11bfaea3bd2ea633e963b2a08166350a6895 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Tue, 25 Aug 2026 08:06:44 +0200 Subject: [PATCH 4/4] fix(hir): compose imported methods with static literals --- crates/perry-hir/src/lower/context.rs | 1 + crates/perry-hir/src/lower/expr_object.rs | 64 +++++++++++++++++-- .../perry-hir/src/lower/lowering_context.rs | 5 ++ crates/perry-hir/src/lower/module_decl.rs | 31 ++++++++- crates/perry-hir/src/lower/tests.rs | 49 ++++++++++++++ 5 files changed, 144 insertions(+), 6 deletions(-) diff --git a/crates/perry-hir/src/lower/context.rs b/crates/perry-hir/src/lower/context.rs index dcf55a28ac..9c1d6d559e 100644 --- a/crates/perry-hir/src/lower/context.rs +++ b/crates/perry-hir/src/lower/context.rs @@ -199,6 +199,7 @@ impl LoweringContext { mixin_funcs: HashMap::new(), anon_shape_classes: HashMap::new(), anon_shape_fields: HashMap::new(), + prefer_exported_method_shape_seed: false, forward_class_names: std::collections::HashSet::new(), forward_class_decl_depth: std::collections::HashMap::new(), class_renames: std::collections::HashMap::new(), diff --git a/crates/perry-hir/src/lower/expr_object.rs b/crates/perry-hir/src/lower/expr_object.rs index fe3d5372a8..dee4cf761f 100644 --- a/crates/perry-hir/src/lower/expr_object.rs +++ b/crates/perry-hir/src/lower/expr_object.rs @@ -632,6 +632,11 @@ fn accessor_key_expr(key: MethodKeyKind) -> Expr { } pub(super) fn lower_object(ctx: &mut LoweringContext, obj: &ast::ObjectLit) -> Result { + // A directly exported object is the producer boundary for #8775. Consume + // the marker here so nested literals continue through their ordinary + // lowering paths. + let prefer_exported_method_shape_seed = + std::mem::take(&mut ctx.prefer_exported_method_shape_seed); // Phase 3: closed-shape object literals lower to `new __AnonShape_N()` // so downstream field access hits the direct-GEP fast path. The // anon class is synthesized as a shape-only class with constructor @@ -1074,7 +1079,8 @@ pub(super) fn lower_object(ctx: &mut LoweringContext, obj: &ast::ObjectLit) -> R collect_local_refs_expr(value, &mut refs, &mut visited_closures); !refs.contains(¶m_id) }; - let can_emit_static_object = has_method + let can_emit_static_object = !prefer_exported_method_shape_seed + && has_method && !has_spread && !has_accessor && !has_computed @@ -1105,8 +1111,58 @@ pub(super) fn lower_object(ctx: &mut LoweringContext, obj: &ast::ObjectLit) -> R return Ok(Expr::Object(props)); } - // Pass 2: build the IIFE wrapper. `__o` starts as an empty object - // and each op mutates it in source order. + // Imported-object capabilities need a producer-authoritative non-zero + // class and stable slot order. Keep directly exported, static-key + // method literals in the source-ordered IIFE, but seed it with a + // shape-only anonymous class instead of `{}`. This composes with the + // direct-object optimization above: non-exported literals still skip + // the IIFE, while exported literals retain the metadata required by a + // consumer's guarded direct call. + let static_shape_seed = if prefer_exported_method_shape_seed + && !has_spread + && !has_accessor + && !has_computed + && !has_proto_setter + { + let mut names = Vec::new(); + let mut seen = std::collections::HashSet::new(); + let mut eligible = true; + for op in &ops { + let name = match op { + SpreadOp::Set { + key: Expr::String(name), + infer_name: false, + .. + } + | SpreadOp::MethodByName { key: name, .. } => name, + _ => { + eligible = false; + break; + } + }; + if seen.insert(name.clone()) { + names.push(name.clone()); + } + } + eligible.then(|| { + let fields: Vec<(String, Type)> = + names.iter().map(|name| (name.clone(), Type::Any)).collect(); + let class_name = ctx.synthesize_anon_shape_class(&fields); + Expr::New { + class_name, + args: names.iter().map(|_| Expr::Undefined).collect(), + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + } + }) + } else { + None + }; + + // Pass 2: build the IIFE wrapper. `__o` starts as the exported stable + // shape seed when eligible, otherwise as an empty object, and each op + // mutates it in source order. let extern_call = |name: &str, args: Vec| Expr::Call { callee: Box::new(Expr::ExternFuncRef { name: name.to_string(), @@ -1250,7 +1306,7 @@ pub(super) fn lower_object(ctx: &mut LoweringContext, obj: &ast::ObjectLit) -> R }; return Ok(Expr::Call { callee: Box::new(closure), - args: vec![Expr::Object(Vec::new())], + args: vec![static_shape_seed.unwrap_or_else(|| Expr::Object(Vec::new()))], type_args: vec![], byte_offset: 0, }); diff --git a/crates/perry-hir/src/lower/lowering_context.rs b/crates/perry-hir/src/lower/lowering_context.rs index ac9e7aa562..8edf6ba83e 100644 --- a/crates/perry-hir/src/lower/lowering_context.rs +++ b/crates/perry-hir/src/lower/lowering_context.rs @@ -767,6 +767,11 @@ pub struct LoweringContext { /// (e.g. recognizing a bundled mysql2 `createPool(config)` by its option /// names) recovers them here. pub(crate) anon_shape_fields: HashMap>, + /// Set while lowering a directly exported binding/default expression. + /// Eligible method literals consume this flag and retain the seeded IIFE + /// representation needed to publish an exact cross-module own-method + /// capability. Ordinary local method literals keep the direct-object path. + pub(crate) prefer_exported_method_shape_seed: bool, /// Class DECLARATION names at the top level of the function body /// currently being lowered. JS resolves a method-body reference to a /// sibling class declared LATER in the same function at call time diff --git a/crates/perry-hir/src/lower/module_decl.rs b/crates/perry-hir/src/lower/module_decl.rs index 4ebb06a232..1bca78956b 100644 --- a/crates/perry-hir/src/lower/module_decl.rs +++ b/crates/perry-hir/src/lower/module_decl.rs @@ -22,6 +22,22 @@ use native_default_import::{ node_submodule_default_export_key, }; +fn is_direct_object_literal(expr: &ast::Expr) -> bool { + let mut current = expr; + loop { + match current { + ast::Expr::TsAs(wrapper) => current = &wrapper.expr, + ast::Expr::TsNonNull(wrapper) => current = &wrapper.expr, + ast::Expr::TsSatisfies(wrapper) => current = &wrapper.expr, + ast::Expr::TsTypeAssertion(wrapper) => current = &wrapper.expr, + ast::Expr::TsConstAssertion(wrapper) => current = &wrapper.expr, + ast::Expr::Paren(wrapper) => current = &wrapper.expr, + ast::Expr::Object(_) => return true, + _ => return false, + } + } +} + pub(crate) fn lower_module_decl( ctx: &mut LoweringContext, module: &mut Module, @@ -1179,7 +1195,13 @@ pub(crate) fn lower_module_decl( } } - let expr = lower_expr(ctx, init)?; + let previous_shape_seed = ctx.prefer_exported_method_shape_seed; + ctx.prefer_exported_method_shape_seed = var_decl.kind + == ast::VarDeclKind::Const + && is_direct_object_literal(init); + let expr = lower_expr(ctx, init); + ctx.prefer_exported_method_shape_seed = previous_shape_seed; + let expr = expr?; let id = if ctx.pre_registered_module_vars.remove(&name) { ctx.pre_registered_module_var_decls.remove(&name); let id = ctx.lookup_local(&name).unwrap(); @@ -1904,7 +1926,12 @@ pub(crate) fn lower_module_decl( } ast::ModuleDecl::ExportDefaultExpr(export_default_expr) => { // export default - let lowered = lower_expr(ctx, &export_default_expr.expr)?; + let previous_shape_seed = ctx.prefer_exported_method_shape_seed; + ctx.prefer_exported_method_shape_seed = + is_direct_object_literal(&export_default_expr.expr); + let lowered = lower_expr(ctx, &export_default_expr.expr); + ctx.prefer_exported_method_shape_seed = previous_shape_seed; + let lowered = lowered?; // If the expression is a FuncRef, add to exported_functions for proper wrapper generation if let Expr::FuncRef(func_id) = &lowered { diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index d93aeefc75..fef57b543d 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -155,6 +155,55 @@ const computed = { [key]() { return 1; } }; } } +#[test] +fn exported_static_method_literals_keep_a_stable_shape_seed() { + let source = r#" +export const named = { + value: 1, + read() { return this.value; }, +}; +export default { + value: 2, + read() { return this.value; }, +}; +"#; + let module = + perry_parser::parse_typescript(source, "exported-method-object.ts").expect("source parses"); + let hir = super::lower_module( + &module, + "exported-method-object", + "exported-method-object.ts", + ) + .expect("source lowers"); + + for name in ["named", "default"] { + let init = hir + .init + .iter() + .find_map(|stmt| match stmt { + Stmt::Let { + name: local_name, + init: Some(init), + .. + } if local_name == name => Some(init), + _ => None, + }) + .unwrap_or_else(|| panic!("missing init for {name}")); + let Expr::Call { callee, args, .. } = init else { + panic!("exported method object must retain its seeded IIFE: {init:#?}"); + }; + assert!(matches!( + callee.as_ref(), + Expr::Closure { params, .. } + if params.first().is_some_and(|param| param.name == "__perry_obj_iife") + )); + assert!(matches!( + args.as_slice(), + [Expr::New { class_name, .. }] if class_name.starts_with("__AnonShape_") + )); + } +} + #[test] fn test_lower_function_registration() { let mut ctx = make_ctx();