From 95ec7a0bcf3f7032826e69fb33ef743048239367 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sun, 30 Aug 2026 06:34:43 +0200 Subject: [PATCH 1/6] fix(compile): support OpenCode source dependency graph --- .../perry-codegen/src/codegen/ctor_arity.rs | 2 +- crates/perry-codegen/src/codegen/entry.rs | 36 ++- .../src/codegen/method_registry.rs | 12 +- crates/perry-codegen/src/codegen/mod.rs | 85 +++-- .../src/codegen/module_globals_emit.rs | 11 +- crates/perry-codegen/src/codegen/opts.rs | 69 ++++- .../perry-codegen/src/codegen/string_pool.rs | 20 +- crates/perry-codegen/src/expr/call_spread.rs | 23 +- crates/perry-codegen/src/expr/property_get.rs | 44 ++- .../src/expr/readonly_collection_tests.rs | 1 + .../perry-codegen/src/expr/static_method.rs | 21 +- crates/perry-codegen/src/lib.rs | 3 +- .../src/lower_call/namespace_call.rs | 20 +- crates/perry-codegen/src/lower_call/new.rs | 15 +- .../src/lower_call/typed_shape_bake_tests.rs | 1 + .../tests/perry_builtin_name_collision.rs | 1 + .../src/destructuring/var_decl_sources.rs | 46 ++- .../src/dynamic_import/binding_origin.rs | 2 +- crates/perry-hir/src/dynamic_import/tests.rs | 1 + crates/perry-hir/src/ir/decl.rs | 6 + crates/perry-hir/src/lower/context.rs | 4 + .../src/lower/expr_call/array_only_methods.rs | 56 ++-- .../src/lower/lower_expr/arm_ident.rs | 5 +- crates/perry-hir/src/lower/lower_module_fn.rs | 32 ++ crates/perry-hir/src/lower/module_decl.rs | 82 +++++ .../src/lower/shared_mutable_capture.rs | 33 +- crates/perry-hir/src/lower/tests.rs | 114 ++++++- crates/perry-hir/src/stable_hash/module.rs | 7 + crates/perry-hir/src/stable_hash/tests.rs | 9 + crates/perry-runtime/src/error.rs | 70 ++++- .../src/iterator_helpers/tests.rs | 27 ++ .../src/object/class_registry.rs | 9 +- .../src/object/class_registry/dispatch.rs | 83 +++-- .../src/object/class_registry/state.rs | 13 + .../src/object/field_get_set/has_property.rs | 56 +++- .../perry-runtime/src/object/global_this.rs | 2 + .../src/object/global_this/fetch_globals.rs | 23 ++ .../object/object_ops/define_properties.rs | 34 ++ crates/perry-runtime/src/regex/grammar.rs | 36 ++- .../src/inline/cross_module.rs | 1 + crates/perry-transform/src/inline/mod.rs | 1 + .../perry/src/commands/compile/bootstrap.rs | 2 + .../compile/cjs_wrap/hoist_classes.rs | 12 +- .../src/commands/compile/cjs_wrap/tests.rs | 27 ++ .../src/commands/compile/collect_modules.rs | 1 + .../compile/collect_modules/feature_detect.rs | 2 + .../perry/src/commands/compile/init_order.rs | 22 +- .../commands/compile/link/build_and_run.rs | 6 +- .../src/commands/compile/link/link_cache.rs | 20 +- .../src/commands/compile/object_cache.rs | 23 +- .../object_cache/object_cache_tests.rs | 25 +- .../src/commands/compile/run_pipeline.rs | 72 ++++- .../perry/src/commands/compile/strip_dedup.rs | 66 ++-- .../compile/strip_dedup/strip_dedup_tests.rs | 61 ++++ .../class_inherited_computed_static_in.rs | 74 +++++ .../issue_5763_setprototypeof_chain_end.rs | 32 ++ ...issue_5951_class_capture_shared_mutable.rs | 37 +++ .../perry/tests/issue_6074_rest_dispatch.rs | 30 ++ .../tests/module_forward_class_expression.rs | 116 +++++++ .../tests/namespace_variable_export_abi.rs | 293 ++++++++++++++++++ .../tests/source_graph_export_regressions.rs | 147 +++++++++ 61 files changed, 1954 insertions(+), 230 deletions(-) create mode 100644 crates/perry/tests/class_inherited_computed_static_in.rs create mode 100644 crates/perry/tests/module_forward_class_expression.rs diff --git a/crates/perry-codegen/src/codegen/ctor_arity.rs b/crates/perry-codegen/src/codegen/ctor_arity.rs index fa453f8160..efc3d68469 100644 --- a/crates/perry-codegen/src/codegen/ctor_arity.rs +++ b/crates/perry-codegen/src/codegen/ctor_arity.rs @@ -45,7 +45,7 @@ pub(super) fn synthesized_ctor_param_count( while let Some(pname) = cur { let imported_ctor_params = imported_classes .iter() - .find(|i| i.local_alias.as_deref().unwrap_or(&i.name) == pname.as_str()) + .find(|i| i.effective_name() == pname) .map(|ic| ic.constructor_param_count) .unwrap_or(0); if let Some(pclass) = class_table.get(pname.as_str()) { diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 445ccf6a97..8908fbc6b2 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -485,6 +485,25 @@ pub(super) fn compile_module_entry( (cn, len, prefix.clone()) }) .collect(); + // `PERRY_DEBUG_INIT` is a startup-order diagnostic, so keep all of its + // emitted code in the entry object. The old implementation put a + // `puts("INIT: ")` in every non-entry module body, which made + // enabling the diagnostic invalidate every object in a large source + // graph. OpenCode's 7k-module graph consequently needed a full LLVM + // rebuild just to identify one failing initializer. Parallel to the + // eager call list below, these constants let the entry print the next + // initializer before dispatching it while every dependency object + // remains byte-for-byte reusable. + let debug_init_chain: Vec = if std::env::var_os("PERRY_DEBUG_INIT").is_some() { + let constants = non_entry_module_prefixes + .iter() + .map(|prefix| llmod.add_string_constant(&format!("INIT: {}\0", prefix)).0) + .collect(); + llmod.declare_function("puts", I32, &[PTR]); + constants + } else { + Vec::new() + }; let main = if is_dylib { llmod.define_function("perry_module_init", VOID, vec![]) } else { @@ -681,10 +700,13 @@ pub(super) fn compile_module_entry( ], ); } - for prefix in non_entry_module_prefixes { + for (index, prefix) in non_entry_module_prefixes.iter().enumerate() { if cross_module.deferred_module_prefixes.contains(prefix) { continue; } + if let Some(const_name) = debug_init_chain.get(index) { + blk.call_void("puts", &[(PTR, &format!("@{}", const_name))]); + } blk.call_void(&format!("{}__init", prefix), &[]); } } @@ -1372,15 +1394,6 @@ pub(super) fn compile_module_entry( // only the wrapper above ever calls it, both within this module // and across modules via the wrapper's external symbol. let init_name = init_body_name; - // Debug: emit puts("INIT: ") at the top of each module init - let debug_init_const = if std::env::var("PERRY_DEBUG_INIT").is_ok() { - let debug_msg = format!("INIT: {}\0", module_prefix); - let (const_name, _) = llmod.add_string_constant(&debug_msg); - llmod.declare_function("puts", I32, &[PTR]); - Some(const_name) - } else { - None - }; let ic_base = llmod.ic_counter; let buffer_alias_base = llmod.buffer_alias_counter; let init_fn = llmod.define_function(&init_name, VOID, vec![]); @@ -1400,9 +1413,6 @@ pub(super) fn compile_module_entry( let _ = init_fn.create_block("entry"); { let blk = init_fn.block_mut(0).unwrap(); - if let Some(ref cname) = debug_init_const { - blk.call_void("puts", &[(PTR, &format!("@{}", cname))]); - } if write_barriers_enabled() { blk.call_void("js_gc_write_barriers_emitted", &[(I32, "1")]); } diff --git a/crates/perry-codegen/src/codegen/method_registry.rs b/crates/perry-codegen/src/codegen/method_registry.rs index 60d2a024ca..e2c79af07f 100644 --- a/crates/perry-codegen/src/codegen/method_registry.rs +++ b/crates/perry-codegen/src/codegen/method_registry.rs @@ -173,9 +173,9 @@ pub(crate) fn build_method_names( // registry and pre-declare them as extern LLVM functions so the // linker can resolve cross-module method calls. for ic in imported_classes { - let effective_name = ic.local_alias.as_deref().unwrap_or(&ic.name); + let effective_name = ic.effective_name(); // Skip if locally defined — local methods take precedence. - if hir.classes.iter().any(|c| c.name == *effective_name) { + if hir.classes.iter().any(|c| c.name == effective_name) { continue; } let src = &ic.source_prefix; @@ -192,7 +192,7 @@ pub(crate) fn build_method_names( sanitize_member(method_name), ); method_names - .entry((effective_name.to_string(), method_name.clone())) + .entry((effective_name.clone(), method_name.clone())) .or_insert_with(|| llvm_fn.clone()); // Declare extern: `double method(double this, double arg0, …)`. @@ -247,7 +247,7 @@ pub(crate) fn build_method_names( &format!("__get_{}", inner_fn_name), ); method_names - .entry((effective_name.to_string(), format!("__get_{}", prop))) + .entry((effective_name.clone(), format!("__get_{}", prop))) .or_insert_with(|| llvm_fn.clone()); // Getters take only `this` (NaN-boxed double) and return double. llmod.declare_function(&llvm_fn, DOUBLE, &[DOUBLE]); @@ -263,7 +263,7 @@ pub(crate) fn build_method_names( &format!("__set_{}", inner_fn_name), ); method_names - .entry((effective_name.to_string(), format!("__set_{}", prop))) + .entry((effective_name.clone(), format!("__set_{}", prop))) .or_insert_with(|| llvm_fn.clone()); // Setters take `this` plus the new value, both NaN-boxed // doubles, and return double (the assigned value). @@ -301,7 +301,7 @@ pub(crate) fn build_method_names( ) }; method_names - .entry((effective_name.to_string(), static_method_registry_key(sm))) + .entry((effective_name.clone(), static_method_registry_key(sm))) .or_insert_with(|| llvm_fn.clone()); // Declare conservatively with 6 double params; LLVM's direct-call // resolution doesn't require an exact arity match for declarations. diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index 0e8ca294ff..b585047af2 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -242,7 +242,8 @@ pub(crate) use helpers::{ module_callable_count, set_full_outline_ic, write_barriers_enabled, }; pub use opts::{ - AppMetadata, CompileOptions, ExportedObjectLiteralCapability, FpContractMode, ImportedClass, + namespace_member_class_key, namespace_member_func_key, namespace_member_var_key, AppMetadata, + CompileOptions, ExportedObjectLiteralCapability, FpContractMode, ImportedClass, ImportedObjectLiteral, ImportedObjectLiteralMethod, NamespaceEntry, NamespaceEntryKind, ObjectLiteralMethodCandidate, ShortSpreadMethodCandidate, }; @@ -597,10 +598,26 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> let class_id = ic .source_class_id .unwrap_or_else(|| next_class_id + (idx as u32)); - let effective_name = ic.local_alias.as_deref().unwrap_or(&ic.name); + let effective_name = ic.effective_name(); + let exported_name = ic.local_alias.as_deref().unwrap_or(&ic.name); + + // Namespace member class identity must be scoped to the namespace. + // A flat `class_ids[member]` lookup lets a class exported by one + // namespace hijack an equal-named value/function in another (Effect's + // SchemaAST.Boolean class versus Schema.Boolean schema value). The + // driver already resolved every member to its origin prefix, and an + // ImportedClass carries that same prefix plus its consumer-visible + // alias, so record the exact `(namespace, member)` identity here. + for ((namespace, member), source_prefix) in &opts.namespace_member_prefixes { + if source_prefix == &ic.source_prefix && member == exported_name { + class_ids + .entry(namespace_member_class_key(namespace, member)) + .or_insert(class_id); + } + } // Skip if already defined locally (local definition takes precedence). - if class_table.contains_key(effective_name) { + if class_table.contains_key(&effective_name) { // Issue #26 / #321: a locally-shadowed import is still needed for // *parent resolution* of OTHER imported classes. Effect's // ParseResult.ts declares its own local `class Type` @@ -614,7 +631,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> // can find it WITHOUT polluting the name-keyed dispatch maps. if !ic.field_names.is_empty() || ic.parent_name.is_some() { shadowed_parent_stubs.push(( - effective_name.to_string(), + effective_name.clone(), ic.source_prefix.clone(), ic.parent_name.clone(), ic.field_names @@ -644,11 +661,11 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> // must agree, otherwise the method registry builds symbols mixing // the FIRST writer's methods with the LAST writer's prefix + // canonical name, producing fnames the linker can't resolve. - class_ids - .entry(effective_name.to_string()) - .or_insert(class_id); - // Also register the canonical name if aliased. - if ic.local_alias.is_some() && !class_ids.contains_key(&ic.name) { + class_ids.entry(effective_name.clone()).or_insert(class_id); + // A lexical alias also exposes the canonical binding for legacy + // source-name lookups. Namespace members do not: `ns.Service` must + // never claim the unrelated bare `Service` binding in this module. + if ic.namespace.is_none() && ic.local_alias.is_some() && !class_ids.contains_key(&ic.name) { class_ids.insert(ic.name.clone(), class_id); } @@ -703,7 +720,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> // their names here keeps dispatch and field inference conservative. let stub = perry_hir::Class { id: 0, // imported — no local ClassId - name: effective_name.to_string(), + name: effective_name.clone(), // #6812: width hints don't cross module metadata; imported stubs // fall back to runtime learned sizing. alloc_width_hint: 0, @@ -713,7 +730,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> specialized_from: None, type_params: Vec::new(), extends: None, - extends_name: ic.parent_name.clone(), + extends_name: ic.effective_parent_name(), native_extends: None, extends_expr: None, heritage_lexically_shadowed: false, @@ -979,8 +996,8 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> // method-registry loop below recover the source name. let mut imported_class_source_name: HashMap = HashMap::new(); for ic in &opts.imported_classes { - let effective_name = ic.local_alias.as_deref().unwrap_or(&ic.name); - if hir.classes.iter().any(|c| c.name == *effective_name) { + let effective_name = ic.effective_name(); + if hir.classes.iter().any(|c| c.name == effective_name) { continue; } // Refs #665: first-writer-wins to match `class_table`'s @@ -993,11 +1010,11 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> // method symbols mangled under the wrong class — the linker can't // resolve them and the build fails with "undefined value". imported_class_prefix - .entry(effective_name.to_string()) + .entry(effective_name.clone()) .or_insert_with(|| ic.source_prefix.clone()); if effective_name != ic.name { imported_class_source_name - .entry(effective_name.to_string()) + .entry(effective_name) .or_insert_with(|| ic.name.clone()); } } @@ -1619,13 +1636,15 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> } } for ic in &opts.imported_classes { - let effective_name = ic.local_alias.as_deref().unwrap_or(&ic.name).to_string(); + let effective_name = ic.effective_name(); for (i, mname) in ic.method_names.iter().enumerate() { // Default to 0 if the source side hasn't populated method_param_counts // yet (legacy ImportedClass with no parallel Vec). 0 means "no padding". let count = ic.method_param_counts.get(i).copied().unwrap_or(0); // Register under the canonical class name and the local alias if any. - method_param_counts.insert((ic.name.clone(), mname.clone()), count); + if ic.namespace.is_none() { + method_param_counts.insert((ic.name.clone(), mname.clone()), count); + } if effective_name != ic.name { method_param_counts.insert((effective_name.clone(), mname.clone()), count); } @@ -1635,7 +1654,9 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> // args either dropped or silently spread into the next slot — // `c.cmd("SET", "k", "v")` reached the callee as `args = "k"`. if ic.method_has_rest.get(i).copied().unwrap_or(false) { - method_has_rest.insert((ic.name.clone(), mname.clone()), true); + if ic.namespace.is_none() { + method_has_rest.insert((ic.name.clone(), mname.clone()), true); + } if effective_name != ic.name { method_has_rest.insert((effective_name.clone(), mname.clone()), true); } @@ -1646,7 +1667,9 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> .copied() .unwrap_or(false) { - method_has_synthetic_arguments.insert((ic.name.clone(), mname.clone()), true); + if ic.namespace.is_none() { + method_has_synthetic_arguments.insert((ic.name.clone(), mname.clone()), true); + } if effective_name != ic.name { method_has_synthetic_arguments .insert((effective_name.clone(), mname.clone()), true); @@ -1668,12 +1691,16 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> for (i, method_name) in ic.static_method_names.iter().enumerate() { let registry_name = static_method_registry_key(method_name); let count = ic.static_method_param_counts.get(i).copied().unwrap_or(0); - method_param_counts.insert((ic.name.clone(), registry_name.clone()), count); + if ic.namespace.is_none() { + method_param_counts.insert((ic.name.clone(), registry_name.clone()), count); + } if effective_name != ic.name { method_param_counts.insert((effective_name.clone(), registry_name.clone()), count); } if ic.static_method_has_rest.get(i).copied().unwrap_or(false) { - method_has_rest.insert((ic.name.clone(), registry_name.clone()), true); + if ic.namespace.is_none() { + method_has_rest.insert((ic.name.clone(), registry_name.clone()), true); + } if effective_name != ic.name { method_has_rest.insert((effective_name.clone(), registry_name.clone()), true); } @@ -1684,8 +1711,10 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> .copied() .unwrap_or(false) { - method_has_synthetic_arguments - .insert((ic.name.clone(), registry_name.clone()), true); + if ic.namespace.is_none() { + method_has_synthetic_arguments + .insert((ic.name.clone(), registry_name.clone()), true); + } if effective_name != ic.name { method_has_synthetic_arguments .insert((effective_name.clone(), registry_name), true); @@ -2069,11 +2098,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> // The tower subset is producer-authored because only the defining module // can see enough of the body to price its additional keys-token check. for imported in &opts.imported_classes { - let effective_name = imported - .local_alias - .as_deref() - .unwrap_or(&imported.name) - .to_string(); + let effective_name = imported.effective_name(); if hir.classes.iter().any(|class| class.name == effective_name) { continue; } @@ -2413,10 +2438,10 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> .imported_classes .iter() .map(|ic| { - let effective_name = ic.local_alias.as_deref().unwrap_or(&ic.name); + let effective_name = ic.effective_name(); let ctor_name = format!("{}__{}_constructor", ic.source_prefix, ic.name); ( - effective_name.to_string(), + effective_name, ImportedCtor { symbol: ctor_name, param_count: ic.constructor_param_count, diff --git a/crates/perry-codegen/src/codegen/module_globals_emit.rs b/crates/perry-codegen/src/codegen/module_globals_emit.rs index 1dfa0a6951..3e4c057b18 100644 --- a/crates/perry-codegen/src/codegen/module_globals_emit.rs +++ b/crates/perry-codegen/src/codegen/module_globals_emit.rs @@ -137,6 +137,7 @@ fn module_shadows_shared_array_buffer_intrinsic( .any(|enum_decl| enum_decl.name == "SharedArrayBuffer") || hir.imports.iter().any(|import| { !import.type_only + && !import.runtime_erased && import.specifiers.iter().any(|specifier| match specifier { perry_hir::ImportSpecifier::Named { local, .. } | perry_hir::ImportSpecifier::Default { local } @@ -147,7 +148,7 @@ fn module_shadows_shared_array_buffer_intrinsic( }) || imported_classes .iter() - .any(|class| class.local_alias.as_deref().unwrap_or(&class.name) == "SharedArrayBuffer") + .any(|class| class.effective_name() == "SharedArrayBuffer") } /// Emit module-level globals (with exported-var getters) and static-class-field @@ -602,7 +603,7 @@ pub(crate) fn emit_module_globals( // (external_globals_emitted is declared above, shared with the local-class // loop, to avoid double-declarations.) for ic in imported_classes { - let effective_name = ic.local_alias.as_deref().unwrap_or(&ic.name); + let effective_name = ic.effective_name(); // Skip imported-class entries whose source matches this module's // prefix — the local-class loop above already emitted the defining // global. Re-declaring as external would produce a duplicate-symbol @@ -612,7 +613,7 @@ pub(crate) fn emit_module_globals( // Still register in the static_field_globals map so HIR lookups // by the imported alias resolve to the local definition. for sf_name in &ic.static_field_names { - let key = (effective_name.to_string(), sf_name.clone()); + let key = (effective_name.clone(), sf_name.clone()); static_field_globals.entry(key).or_insert_with(|| { let global_name = format!( "perry_static_{}__{}__{}", @@ -644,10 +645,10 @@ pub(crate) fn emit_module_globals( // Register under both the alias (if any) and the source name so // either resolves. static_field_globals.insert( - (effective_name.to_string(), sf_name.clone()), + (effective_name.clone(), sf_name.clone()), global_name.clone(), ); - if effective_name != ic.name { + if ic.namespace.is_none() && effective_name != ic.name { static_field_globals.insert((ic.name.clone(), sf_name.clone()), global_name); } } diff --git a/crates/perry-codegen/src/codegen/opts.rs b/crates/perry-codegen/src/codegen/opts.rs index ead427cd5e..3de45d3620 100644 --- a/crates/perry-codegen/src/codegen/opts.rs +++ b/crates/perry-codegen/src/codegen/opts.rs @@ -83,6 +83,34 @@ impl FpContractMode { } } +/// Build the collision-free key used to classify a variable-shaped export +/// reached through a namespace binding. Bare entries in `imported_vars` are +/// reserved for named/default imports; namespace members must include their +/// local namespace or an unrelated `Other.make` can change `Reducer.make` +/// from a function-body ABI into a zero-argument getter ABI. +/// +/// JavaScript identifiers cannot contain NUL, so neither component can alias +/// this internal key format. +pub fn namespace_member_var_key(namespace: &str, member: &str) -> String { + format!("\0perry_namespace_var\0{namespace}\0{member}") +} + +/// Internal key for a class-valued namespace member in the otherwise flat +/// class-id table. Namespace aliases are local to a module, and the leading +/// NUL keeps these synthetic entries disjoint from source-level class names. +pub fn namespace_member_class_key(namespace: &str, member: &str) -> String { + format!("\0perry_namespace_class\0{namespace}\0{member}") +} + +/// Internal key for a namespace member's function ABI metadata (declared +/// parameter count, rest flag, and synthetic `arguments` flag). Those maps are +/// otherwise keyed by a named import's local binding. A bare member key lets +/// `FastCheck.tuple(...arbs)` make the unrelated `SchemaAST.tuple(elements, +/// checks)` look like a rest function in every module that imports both. +pub fn namespace_member_func_key(namespace: &str, member: &str) -> String { + format!("\0perry_namespace_func\0{namespace}\0{member}") +} + /// Options controlling code generation for a single module. #[derive(Debug, Clone, Default)] pub struct CompileOptions { @@ -269,12 +297,15 @@ pub struct CompileOptions { pub imported_func_synthetic_arguments: std::collections::HashSet, /// Imported function return types, keyed by local function name. pub imported_func_return_types: std::collections::HashMap, - /// Names of imports that are exported VARIABLES (not functions). When an - /// `ExternFuncRef` with one of these names appears as a value (not as a - /// Call callee), the codegen calls the getter function to fetch the value - /// instead of wrapping it as a closure reference. Without this, `import - /// { HONE_VERSION } from './version'` followed by `let v = HONE_VERSION` - /// would create a closure wrapper around the getter, not the actual string. + /// Names of imports that are exported VARIABLES (not functions). Bare keys + /// classify named/default bindings. Namespace-member keys are produced by + /// `namespace_member_var_key` so equal member names from different + /// namespaces cannot contaminate one another. When an `ExternFuncRef` with + /// one of the bare names appears as a value (not as a Call callee), the + /// codegen calls the getter function to fetch the value instead of wrapping + /// it as a closure reference. Without this, `import { HONE_VERSION } from + /// './version'` followed by `let v = HONE_VERSION` would create a closure + /// wrapper around the getter, not the actual string. pub imported_vars: std::collections::HashSet, // ── Feature plumbing ── @@ -507,6 +538,11 @@ pub struct ImportedClass { pub name: String, /// Optional local alias (`import { Foo as Bar }`). pub local_alias: Option, + /// Namespace that exclusively owns this binding (`import * as ns` or + /// `import { NamespaceReExport }`). Namespace members are not lexical + /// bindings in the importing module, so they must never be registered + /// under either their exported or canonical bare class name. + pub namespace: Option, /// Symbol prefix of the origin module (for cross-module method calls). pub source_prefix: String, /// Number of constructor parameters (needed for dispatch). @@ -636,6 +672,27 @@ pub struct ImportedClass { pub object_literal: Option, } +impl ImportedClass { + /// Consumer-side registry key for this class. + pub fn effective_name(&self) -> String { + let member = self.local_alias.as_deref().unwrap_or(&self.name); + self.namespace + .as_deref() + .map(|namespace| namespace_member_class_key(namespace, member)) + .unwrap_or_else(|| member.to_string()) + } + + /// Parent name in the same consumer namespace, when applicable. + pub fn effective_parent_name(&self) -> Option { + self.parent_name.as_ref().map(|parent| { + self.namespace + .as_deref() + .map(|namespace| namespace_member_class_key(namespace, parent)) + .unwrap_or_else(|| parent.clone()) + }) + } +} + /// Producer-authored capability for one concrete class method that can be /// called by the guarded short trailing-spread lowering (#8772). #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/perry-codegen/src/codegen/string_pool.rs b/crates/perry-codegen/src/codegen/string_pool.rs index f424245bf3..9c1d9bfd3b 100644 --- a/crates/perry-codegen/src/codegen/string_pool.rs +++ b/crates/perry-codegen/src/codegen/string_pool.rs @@ -732,11 +732,15 @@ pub(super) fn emit_string_pool( // so an apply/dynamic dispatch (`recv.method(...spread)`) bundles // the call args into the rest array instead of passing `rest = // args[0]` as a scalar (marked's `this.use(...e)` blocker). + // A method that reads `arguments` after declaring `...rest` has + // two trailing array parameters in HIR: `[...rest, arguments]`. + // Looking only at the final (synthetic) slot loses the user-rest + // bit, so bound/runtime vtable dispatch packs a single array and + // binds the first scalar argument directly to `rest`. let has_rest = method .params - .last() - .map(|p| p.is_rest && p.arguments_object.is_none()) - .unwrap_or(false); + .iter() + .any(|p| p.is_rest && p.arguments_object.is_none()); // Spec `.length`: count leading formal params before the first one // with a default or rest (and excluding the synthesized `arguments` // slot). Distinct from the total param_count used for call dispatch. @@ -822,8 +826,14 @@ pub(super) fn emit_string_pool( { let last = class.constructor.as_ref().and_then(|c| c.params.last()); let ctor_has_synth = last.map(|p| p.arguments_object.is_some()).unwrap_or(false); - let ctor_has_rest = last - .map(|p| p.is_rest && p.arguments_object.is_none()) + let ctor_has_rest = class + .constructor + .as_ref() + .map(|c| { + c.params + .iter() + .any(|p| p.is_rest && p.arguments_object.is_none()) + }) .unwrap_or(false); if ctor_has_synth || ctor_has_rest { ctor_flag_regs.push((cid, ctor_has_synth, ctor_has_rest)); diff --git a/crates/perry-codegen/src/expr/call_spread.rs b/crates/perry-codegen/src/expr/call_spread.rs index 83ae3ac027..3d234e9228 100644 --- a/crates/perry-codegen/src/expr/call_spread.rs +++ b/crates/perry-codegen/src/expr/call_spread.rs @@ -416,15 +416,20 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } = callee.as_ref() { if let Expr::ExternFuncRef { name: ns_name, .. } = object.as_ref() { - if ctx.namespace_imports.contains(ns_name) - && ctx.imported_func_has_rest.contains(property) - && ctx - .imported_func_param_counts - .get(property) - .copied() - .unwrap_or(1) - == 1 - { + let scoped_func_key = crate::namespace_member_func_key(ns_name, property); + let scoped_declared_count = ctx + .imported_func_param_counts + .get(&scoped_func_key) + .copied(); + let declared_count = scoped_declared_count + .or_else(|| ctx.imported_func_param_counts.get(property).copied()) + .unwrap_or(1); + let has_rest = if scoped_declared_count.is_some() { + ctx.imported_func_has_rest.contains(&scoped_func_key) + } else { + ctx.imported_func_has_rest.contains(property) + }; + if ctx.namespace_imports.contains(ns_name) && has_rest && declared_count == 1 { let source_prefix_opt = ctx .namespace_member_prefixes .get(&(ns_name.clone(), property.clone())) diff --git a/crates/perry-codegen/src/expr/property_get.rs b/crates/perry-codegen/src/expr/property_get.rs index ec9b0cf31a..0ee91e88e3 100644 --- a/crates/perry-codegen/src/expr/property_get.rs +++ b/crates/perry-codegen/src/expr/property_get.rs @@ -844,10 +844,19 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // Route through the static-field global map populated from // `opts.imported_classes` at codegen entry. Refs #420. if let Expr::ExternFuncRef { name, .. } = object.as_ref() { - let key = (name.clone(), property.clone()); - if let Some(global_name) = ctx.static_field_globals.get(&key).cloned() { - let g_ref = format!("@{}", global_name); - return Ok(ctx.block().load(DOUBLE, &g_ref)); + // A namespace binding can have the same local name as a class + // exported by that namespace (`import * as Sharding` from a + // module that exports `class Sharding`). The namespace is the + // lexical binding, so its first member read must reach the + // namespace dispatcher below; treating it as the equal-named + // imported class makes `Sharding.layer` read a static field on + // the class and return undefined. + if !ctx.namespace_imports.contains(name) { + let key = (name.clone(), property.clone()); + if let Some(global_name) = ctx.static_field_globals.get(&key).cloned() { + let g_ref = format!("@{}", global_name); + return Ok(ctx.block().load(DOUBLE, &g_ref)); + } } } // Issue #618-followup: dynamic property access on a local class @@ -863,7 +872,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // discards the INT32 tag during the unbox and ends up returning // undefined. let is_class_ref_object = matches!(object.as_ref(), Expr::ClassRef(_)) - || matches!(object.as_ref(), Expr::ExternFuncRef { name, .. } if ctx.class_ids.contains_key(name)); + || matches!(object.as_ref(), Expr::ExternFuncRef { name, .. } + if !ctx.namespace_imports.contains(name) && ctx.class_ids.contains_key(name)); if is_class_ref_object { let obj_box = lower_expr(ctx, object)?; let key_idx = ctx.strings.intern(property); @@ -1164,11 +1174,10 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // misses the class ref and falls back to the global // `Number`, dropping all inherited statics (effect's // `S.Number.ast`). - let class_cid = ctx.class_ids.get(property).copied().or_else(|| { - ctx.import_function_origin_names - .get(property) - .and_then(|origin| ctx.class_ids.get(origin).copied()) - }); + let class_cid = ctx + .class_ids + .get(&crate::namespace_member_class_key(name, property)) + .copied(); if let Some(cid) = class_cid { let bits = crate::nanbox::INT32_TAG | (cid as u64 & 0xFFFF_FFFF); return Ok(double_literal(f64::from_bits(bits))); @@ -1252,16 +1261,23 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { _ns_lookup_name.as_deref().unwrap_or(""), property, ); - if ctx.imported_vars.contains(property) { + if ctx.imported_vars.contains(&crate::namespace_member_var_key( + _ns_lookup_name.as_deref().unwrap_or(""), + property, + )) { let getter = format!("perry_fn_{}__{}", source_prefix, origin_suffix); ctx.pending_declares.push((getter.clone(), DOUBLE, vec![])); return Ok(ctx.block().call(DOUBLE, &getter, &[])); } let target_name = format!("perry_fn_{}__{}", source_prefix, origin_suffix); let wrap_name = format!("__perry_wrap_{}", target_name); - let param_count = ctx - .imported_func_param_counts - .get(property) + let param_count = _ns_lookup_name + .as_deref() + .and_then(|namespace| { + ctx.imported_func_param_counts + .get(&crate::namespace_member_func_key(namespace, property)) + }) + .or_else(|| ctx.imported_func_param_counts.get(property)) .copied() .unwrap_or(0) .min(5); diff --git a/crates/perry-codegen/src/expr/readonly_collection_tests.rs b/crates/perry-codegen/src/expr/readonly_collection_tests.rs index c8efc147cf..3e709d7b64 100644 --- a/crates/perry-codegen/src/expr/readonly_collection_tests.rs +++ b/crates/perry-codegen/src/expr/readonly_collection_tests.rs @@ -177,6 +177,7 @@ fn imported_archetype() -> ImportedClass { ImportedClass { name: "Archetype".to_string(), local_alias: None, + namespace: None, source_prefix: "archetype_ts".to_string(), constructor_param_count: 0, has_own_constructor: true, diff --git a/crates/perry-codegen/src/expr/static_method.rs b/crates/perry-codegen/src/expr/static_method.rs index 2e4fe5a83a..e58f0b2bd6 100644 --- a/crates/perry-codegen/src/expr/static_method.rs +++ b/crates/perry-codegen/src/expr/static_method.rs @@ -278,7 +278,10 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // // Both wildcard imports and namespaces reached through a // re-export use the same getter ABI. - if ctx.imported_vars.contains(method_name) { + if ctx + .imported_vars + .contains(&crate::namespace_member_var_key(class_name, method_name)) + { ctx.pending_declares.push((fn_name.clone(), DOUBLE, vec![])); // Preserve JavaScript evaluation order: fetch the // callable namespace member before evaluating any @@ -345,12 +348,20 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // arguments as one array parameter; passing the source // arguments directly makes `Event.inventory(...defs)` read // a scalar as an array and return `undefined`. - let declared_count = ctx + let scoped_func_key = crate::namespace_member_func_key(class_name, method_name); + let scoped_declared_count = ctx .imported_func_param_counts - .get(method_name) - .copied() + .get(&scoped_func_key) + .copied(); + let declared_count = scoped_declared_count + .or_else(|| ctx.imported_func_param_counts.get(method_name).copied()) .unwrap_or(args.len()); - if ctx.imported_func_has_rest.contains(method_name) { + let has_rest = if scoped_declared_count.is_some() { + ctx.imported_func_has_rest.contains(&scoped_func_key) + } else { + ctx.imported_func_has_rest.contains(method_name) + }; + if has_rest { let fixed_count = declared_count.saturating_sub(1); let (lowered, guard) = crate::lower_call::lower_rest_call_args_rooted( ctx, diff --git a/crates/perry-codegen/src/lib.rs b/crates/perry-codegen/src/lib.rs index 34e865f910..9bcb7326ee 100644 --- a/crates/perry-codegen/src/lib.rs +++ b/crates/perry-codegen/src/lib.rs @@ -71,7 +71,8 @@ pub(crate) mod typed_shape; pub mod types; pub use codegen::{ - compile_module, resolve_target_triple, short_spread_method_capabilities, AppMetadata, + compile_module, namespace_member_class_key, namespace_member_func_key, + namespace_member_var_key, resolve_target_triple, short_spread_method_capabilities, AppMetadata, CompileOptions, ExportedObjectLiteralCapability, FpContractMode, ImportedClass, ImportedObjectLiteral, ImportedObjectLiteralMethod, NamespaceEntry, NamespaceEntryKind, ObjectLiteralMethodCandidate, ShortSpreadMethodCandidate, diff --git a/crates/perry-codegen/src/lower_call/namespace_call.rs b/crates/perry-codegen/src/lower_call/namespace_call.rs index b55723baf8..3d5a91e551 100644 --- a/crates/perry-codegen/src/lower_call/namespace_call.rs +++ b/crates/perry-codegen/src/lower_call/namespace_call.rs @@ -302,7 +302,10 @@ pub fn try_lower_namespace_member_call( property, ); let symbol = format!("perry_fn_{}__{}", source_prefix, origin_suffix); - if ctx.imported_vars.contains(property) { + if ctx + .imported_vars + .contains(&crate::namespace_member_var_key(ns_name, property)) + { // Var-shaped export: fetch closure via zero-arg // getter, then closure-call with the user args. ctx.pending_declares.push((symbol.clone(), DOUBLE, vec![])); @@ -378,12 +381,19 @@ pub fn try_lower_namespace_member_call( return Ok(Some(result)); } // Function-decl-shaped export: direct call with rest bundling. - let declared_count = ctx + let scoped_func_key = crate::namespace_member_func_key(ns_name, property); + let scoped_declared_count = ctx .imported_func_param_counts - .get(property) - .copied() + .get(&scoped_func_key) + .copied(); + let declared_count = scoped_declared_count + .or_else(|| ctx.imported_func_param_counts.get(property).copied()) .unwrap_or(args.len()); - let has_rest = ctx.imported_func_has_rest.contains(property); + let has_rest = if scoped_declared_count.is_some() { + ctx.imported_func_has_rest.contains(&scoped_func_key) + } else { + ctx.imported_func_has_rest.contains(property) + }; if has_rest { // #7154's accumulator shape, verbatim: `current` was a raw // `*mut ArrayHeader` in a bare SSA register holding the only reference diff --git a/crates/perry-codegen/src/lower_call/new.rs b/crates/perry-codegen/src/lower_call/new.rs index d51c9b8baf..66831eb586 100644 --- a/crates/perry-codegen/src/lower_call/new.rs +++ b/crates/perry-codegen/src/lower_call/new.rs @@ -1031,7 +1031,16 @@ fn lower_new_impl_inner<'a>( // SQLiteBaseInteger's `autoIncrement = this.config.autoIncrement` // depends on Column's body running `this.config = config` first). let has_own_ctor = class.constructor.is_some(); - let has_extends = class.extends_name.is_some(); + // A runtime-valued heritage clause is still real heritage. Class + // expressions whose parent identifier resolves through a lexical binding + // intentionally carry only `extends_expr` (no `extends_name`), so treating + // them as base classes applies their own fields before the synthesized + // `super(...args)`. Drizzle's emitted `var SQLiteBoolean = class extends + // SQLiteBaseInteger { mode = this.config.mode }` then reads `config` before + // the Column constructor has installed it. Stage those fields exactly like + // statically named derived classes: ancestors first, dynamic super, self + // fields last. + let has_extends = class.extends_name.is_some() || class.extends_expr.is_some(); let has_imported_ctor = ctx.imported_class_ctors.contains_key(class_name); // A local class whose imported parent is represented by both a static // name and a runtime heritage value must construct through that runtime @@ -1908,8 +1917,8 @@ fn lower_new_impl_inner<'a>( // `extends_expr` (dynamic-parent, e.g. zod 4's `$constructor`) classes also // need their own field initializers re-applied here — AFTER the parent body // ran via `js_fetch_or_value_super` above. ECMAScript runs derived-class - // field initializers after `super()` returns; `has_extends` only covers - // static `extends_name`, so include the `extends_expr` case (SelfOnly, + // field initializers after `super()` returns; `has_extends` covers both + // static `extends_name` and dynamic `extends_expr` heritage (SelfOnly, // mirroring the explicit-`SuperCall` dynamic-parent arm in this_super_call.rs). if !has_own_ctor && (has_extends || class.extends_expr.is_some()) && !has_imported_ctor { if let Some(owner) = dynamic_parent_owner { 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 ee2b5254a8..f143848de3 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 @@ -486,6 +486,7 @@ fn imported_remote() -> ImportedClass { ImportedClass { name: "Remote".to_string(), local_alias: None, + namespace: None, source_prefix: "producer_ts".to_string(), constructor_param_count: 1, has_own_constructor: true, diff --git a/crates/perry-codegen/tests/perry_builtin_name_collision.rs b/crates/perry-codegen/tests/perry_builtin_name_collision.rs index faa5395174..4308c6916d 100644 --- a/crates/perry-codegen/tests/perry_builtin_name_collision.rs +++ b/crates/perry-codegen/tests/perry_builtin_name_collision.rs @@ -86,6 +86,7 @@ fn import_from(source: &str, name: &str, kind: ModuleKind) -> Import { module_kind: kind, resolved_path: Some(source.to_string()), type_only: false, + runtime_erased: false, is_dynamic: false, is_dynamic_target: false, is_deferred_require: false, diff --git a/crates/perry-hir/src/destructuring/var_decl_sources.rs b/crates/perry-hir/src/destructuring/var_decl_sources.rs index 73aa53b0f2..9897b1719a 100644 --- a/crates/perry-hir/src/destructuring/var_decl_sources.rs +++ b/crates/perry-hir/src/destructuring/var_decl_sources.rs @@ -83,6 +83,47 @@ pub(crate) fn require_is_shadowed_by_local(ctx: &LoweringContext) -> bool { || ctx.lookup_imported_func("require").is_some() } +/// The CJS-to-ESM wrapper's synthetic `require` is deliberately a real local +/// function, so the ordinary native-require fast paths must not steal calls +/// from it (see #8342). There is one narrower exception: a destructured +/// constructor supplied by a Perry native npm shim has no runtime namespace +/// value to destructure in the first place. The wrapper-generated helper +/// pair identifies that compiler-owned function without mistaking an ordinary +/// user `function require(...) { ... }` for the intrinsic. +fn require_is_perry_cjs_wrapper(ctx: &LoweringContext) -> bool { + ctx.lookup_func("require").is_some() + && ctx.lookup_func("__perry_cjs_require_error").is_some() + && ctx.lookup_func("__perry_cjs_require_is_builtin").is_some() +} + +/// Native npm-shim destructures that can be lowered exactly like named ESM +/// imports even inside Perry's CJS wrapper. Keep this an explicit surface: +/// broadening it to every native module would regress #8342's builtin-module +/// namespace semantics, while broadening it to arbitrary lru-cache exports +/// would pretend the partial shim implements API that it does not have. +fn cjs_wrapper_static_native_destructure( + ctx: &LoweringContext, + init: &ast::Expr, + obj_pat: &ast::ObjectPat, +) -> bool { + if !require_is_perry_cjs_wrapper(ctx) + || require_literal_specifier(init).as_deref() != Some("lru-cache") + { + return false; + } + + !obj_pat.props.is_empty() + && obj_pat.props.iter().all(|prop| match prop { + ast::ObjectPatProp::Assign(assign) => assign.key.sym.as_ref() == "LRUCache", + ast::ObjectPatProp::KeyValue(kv) => match &kv.key { + ast::PropName::Ident(key) => key.sym.as_ref() == "LRUCache", + ast::PropName::Str(key) => key.value.as_str() == Some("LRUCache"), + _ => false, + }, + ast::ObjectPatProp::Rest(_) => false, + }) +} + /// #5216: the canonical (`node:`-stripped) native module name for a require /// specifier `raw`, iff it resolves to a Perry-supported native/Node-builtin /// module; otherwise `None`. `node:`-prefixed specifiers must name a real Node @@ -184,7 +225,10 @@ pub(super) fn register_destructured_stream_ctors( // here — the native namespace isn't initialized in a CJS-wrapped module, // so the bindings would be undefined at runtime. Let the destructure run // off the runtime `require(...)` call result instead. - if require_is_shadowed_by_local(ctx) && require_literal_specifier(init).is_some() { + if require_is_shadowed_by_local(ctx) + && require_literal_specifier(init).is_some() + && !cjs_wrapper_static_native_destructure(ctx, init, obj_pat) + { return Vec::new(); } diff --git a/crates/perry-hir/src/dynamic_import/binding_origin.rs b/crates/perry-hir/src/dynamic_import/binding_origin.rs index 2abeb3ac81..1ca3b89ee0 100644 --- a/crates/perry-hir/src/dynamic_import/binding_origin.rs +++ b/crates/perry-hir/src/dynamic_import/binding_origin.rs @@ -34,7 +34,7 @@ fn defines_local_binding(module: &Module, name: &str) -> bool { /// Native imports are excluded because their source has no compiled HIR owner. fn find_import_binding(module: &Module, name: &str) -> Option<(String, ImportBindingKind)> { for import in &module.imports { - if import.type_only || import.is_native { + if import.type_only || import.runtime_erased || import.is_native { continue; } for spec in &import.specifiers { diff --git a/crates/perry-hir/src/dynamic_import/tests.rs b/crates/perry-hir/src/dynamic_import/tests.rs index 6a4043737b..da20cfa7b4 100644 --- a/crates/perry-hir/src/dynamic_import/tests.rs +++ b/crates/perry-hir/src/dynamic_import/tests.rs @@ -1003,6 +1003,7 @@ fn named_import(source: &str, imported: &str, local: &str) -> crate::ir::Import module_kind: crate::ir::ModuleKind::NativeCompiled, resolved_path: None, type_only: false, + runtime_erased: false, is_dynamic: false, is_dynamic_target: false, is_deferred_require: false, diff --git a/crates/perry-hir/src/ir/decl.rs b/crates/perry-hir/src/ir/decl.rs index dbbe74e3cc..b47e36604f 100644 --- a/crates/perry-hir/src/ir/decl.rs +++ b/crates/perry-hir/src/ir/decl.rs @@ -99,6 +99,12 @@ pub struct Import { /// bar }`) is still tracked because the same declaration also has /// value specifiers — only the whole-decl flag is runtime-meaningless. pub type_only: bool, + /// True when a syntactically value-shaped declaration contains only + /// per-specifier type imports (`import { type Foo, type Bar }`). Perry + /// still collects the source module so its producer-authored type/class + /// metadata remains available, but the edge creates no runtime binding or + /// module-initialization dependency. + pub runtime_erased: bool, /// Issue #100: synthesized from a dynamic `import()` call whose path /// const-folded to this source. Dynamic edges enter the import graph /// but do NOT pin the target as eager — if no static edge reaches it diff --git a/crates/perry-hir/src/lower/context.rs b/crates/perry-hir/src/lower/context.rs index 466384c522..0af6816e32 100644 --- a/crates/perry-hir/src/lower/context.rs +++ b/crates/perry-hir/src/lower/context.rs @@ -1248,6 +1248,10 @@ impl LoweringContext { } pub(crate) fn register_imported_func(&mut self, local_name: String, original_name: String) { + if let Some(&idx) = self.imported_functions_index.get(&local_name) { + self.imported_functions[idx].1 = original_name; + return; + } let idx = self.imported_functions.len(); self.imported_functions_index .insert(local_name.clone(), idx); diff --git a/crates/perry-hir/src/lower/expr_call/array_only_methods.rs b/crates/perry-hir/src/lower/expr_call/array_only_methods.rs index f5f39c163b..4bf033218e 100644 --- a/crates/perry-hir/src/lower/expr_call/array_only_methods.rs +++ b/crates/perry-hir/src/lower/expr_call/array_only_methods.rs @@ -630,6 +630,32 @@ pub(super) fn try_array_only_methods( || chain_roots_at_stream(ctx, member_obj) || chain_roots_at_builtin_iterator(ctx, member_obj) || is_util_mime_params_receiver(ctx, member_obj); + // `entries` / `keys` / `values` are not Array-only names. + // Maps, Sets, iterator-like facades, and ordinary user objects + // can all provide them. In particular, OpenCode's + // `EventManifest.Latest` is a frozen plain object implementing + // `ReadonlyMap`; folding `Latest.values()` to `ArrayValues` + // makes codegen call `js_array_values_iter_obj` on an + // ObjectHeader. Its subsequent iterator-helper chain then + // reads the malformed result and spread reports "value is not + // iterable" during module initialization. + // + // Dynamic method dispatch is shape-aware and still reaches the + // dense Array helpers for a real array, so specialize only + // with positive Array evidence. This mirrors the bare-local + // gate in `local_array_methods.rs` instead of reviving the old + // any-receiver fallback for property/call receivers. + let recv_is_proven_array = matches!(method_name, "entries" | "keys" | "values") + && { + let recv_ty = crate::lower_types::infer_type_from_expr(&member.obj, ctx); + matches!(recv_ty, Type::Array(_) | Type::Tuple(_)) + || matches!( + &recv_ty, + Type::Generic { base, .. } + if base == "Array" || base == "ReadonlyArray" + ) + || chain_roots_at_array(ctx, &member.obj) + }; // thisArg routing: the dense `Expr::Array` fast paths // carry only the callback and silently drop a 2nd positional // `thisArg` argument, so `[x].every(cb, thisArg)` ran the @@ -833,40 +859,24 @@ pub(super) fn try_array_only_methods( callback: Box::new(cb), })); } - // #597: arr.entries() / .keys() / .values() on - // any-typed receivers (`function f(arr: any) { for (const [i,v] of arr.entries()) ... }`). - // Pre-fix this fell through to a generic - // `js_native_call_method` dispatch that returned - // an iterator-shaped object whose `.length` was - // 0 / undefined, so the index-based for-of loop - // (the index lowering at lower_decl.rs:4445) - // saw `__arr_N.length === 0` and ran 0 times. - // The static-Array path already folds at - // line 3966 above; this catch-all extends the - // same fold to dynamic-receiver shapes — - // `js_array_entries` / `_keys` / `_values` - // tolerates non-array receivers (returns empty) - // so the lowered loop's behavior on non-array - // values matches Node's empty-iterator semantics. - // recv_is_class gating preserves user classes - // that happen to expose an `entries` method. - // Drizzle's `dialect.buildInsertQuery` uses - // `for (const [valueIndex, value] of values.entries())` - // where `values` arrives via destructuring of an - // any-typed function param. + // #597: proven arrays keep the direct dense-iterator path. + // Ambiguous receivers use generic method dispatch, which + // selects Array/Map/Set or an own user method by runtime + // shape and therefore preserves iterator-helper results. "entries" if args.is_empty() && !recv_is_class + && recv_is_proven_array && !is_fs_dir_receiver(ctx, &member.obj) => { let array_expr = lower_expr(ctx, &member.obj)?; return Ok(Ok(Expr::ArrayEntries(Box::new(array_expr)))); } - "keys" if args.is_empty() && !recv_is_class => { + "keys" if args.is_empty() && !recv_is_class && recv_is_proven_array => { let array_expr = lower_expr(ctx, &member.obj)?; return Ok(Ok(Expr::ArrayKeys(Box::new(array_expr)))); } - "values" if args.is_empty() && !recv_is_class => { + "values" if args.is_empty() && !recv_is_class && recv_is_proven_array => { let array_expr = lower_expr(ctx, &member.obj)?; return Ok(Ok(Expr::ArrayValues(Box::new(array_expr)))); } diff --git a/crates/perry-hir/src/lower/lower_expr/arm_ident.rs b/crates/perry-hir/src/lower/lower_expr/arm_ident.rs index cf41386bfa..1f1535a78f 100644 --- a/crates/perry-hir/src/lower/lower_expr/arm_ident.rs +++ b/crates/perry-hir/src/lower/lower_expr/arm_ident.rs @@ -302,8 +302,9 @@ pub(crate) fn lower_ident_expr(ctx: &mut LoweringContext, ident: &ast::Ident) -> // call path already provides. if ctx.unresolved_ident_as_global { eprintln!( - " Warning: unknown identifier '{}' — assuming global; resolved by name on globalThis (incl. Object.prototype-inherited members) at runtime", - name + " Warning: unknown identifier '{}' in {} — assuming global; resolved by name on globalThis (incl. Object.prototype-inherited members) at runtime", + name, + ctx.source_file_path ); } // #5253: localize the `X is not defined` ReferenceError to diff --git a/crates/perry-hir/src/lower/lower_module_fn.rs b/crates/perry-hir/src/lower/lower_module_fn.rs index 2c0b1d5c26..64f243d8df 100644 --- a/crates/perry-hir/src/lower/lower_module_fn.rs +++ b/crates/perry-hir/src/lower/lower_module_fn.rs @@ -319,6 +319,7 @@ fn enable_react_automatic_jsx(module: &mut Module, ctx: &mut LoweringContext) { module_kind: ModuleKind::NativeCompiled, resolved_path: None, type_only: false, + runtime_erased: false, is_dynamic: false, is_dynamic_target: false, is_deferred_require: false, @@ -883,6 +884,10 @@ pub fn lower_module_full( // aliases before any pre-pass extracts annotations or lowers expressions, // including when the declaration appears after its first source use. module_decl::native_profile_import::pre_register_native_profile_imports(&mut ctx, ast_module); + // Static ESM imports are hoisted. Register ordinary source-module value + // bindings before any initializer/function body can reference them; the + // declaration pass below still records the imports in source order. + module_decl::pre_register_static_import_bindings(&mut ctx, ast_module); // #6812 (w16): scan the module lowering actually consumes (post-fold) for // constant-bounded dynamic-key builder widths; `lower_object` attaches // them to the per-site empty-literal classes as alloc_width_hint. @@ -1133,6 +1138,33 @@ pub fn lower_module_full( // undefined local instead of the class ref. Skip the local so // `Ident("X")` lowers to `Expr::ClassRef("X")`. if decl_init_is_class_expr(decl) { + // A class-expression binding is not represented by a + // pre-registered LocalId (the source-position lowering + // below binds its ClassRef directly), but it still has to + // participate in the module's forward-name pre-scan. + // + // Published ESM commonly emits adjacent declarations such + // as: + // + // var Builder = class { build() { return new Later() } }; + // var Later = class { ... }; + // + // The first class's method body is lowered before the + // second declarator reaches stmt.rs. Without recording + // `Later` here, that method bakes in an unresolved-global + // lookup and throws at call time even though the binding is + // initialized by then. `forward_class_names` gives the + // earlier body a ClassRef without allocating the shadowing, + // never-written local that #4461 removed. Once the + // declarator itself is lowered, its normal value binding + // takes over subsequent lexical reads. + if let ast::Pat::Ident(ident) = &decl.name { + let name = ident.id.sym.to_string(); + ctx.forward_class_decl_depth + .entry(name.clone()) + .or_insert(0); + ctx.forward_class_names.insert(name); + } continue; } if let ast::Pat::Ident(ident) = &decl.name { diff --git a/crates/perry-hir/src/lower/module_decl.rs b/crates/perry-hir/src/lower/module_decl.rs index f575561bc8..36d19914c8 100644 --- a/crates/perry-hir/src/lower/module_decl.rs +++ b/crates/perry-hir/src/lower/module_decl.rs @@ -24,6 +24,70 @@ use native_default_import::{ }; use object_literal::is_direct_object_literal; +/// Register ordinary source-module import bindings before statement lowering. +/// +/// ESM imports are module-scoped and hoisted regardless of where their +/// declarations appear in the source. The main declaration pass still emits +/// the HIR `Import` records in source order; this pre-pass only makes the +/// bindings visible to expressions that precede the declaration. +pub(super) fn pre_register_static_import_bindings( + ctx: &mut LoweringContext, + ast_module: &ast::Module, +) { + for item in &ast_module.body { + let ast::ModuleItem::ModuleDecl(ast::ModuleDecl::Import(import_decl)) = item else { + continue; + }; + let raw_source = import_decl.src.value.as_str().unwrap_or("").to_string(); + let source = canonicalize_native_import_source(&raw_source); + + // Native imports need their module/method-specific registration, which + // the ordinary declaration pass performs. This pass fixes source + // modules, whose value bindings all share the imported-function path. + if is_native_module(&source) + || is_node_builtin_module(&source) + || source == "reflect-metadata" + { + continue; + } + + for specifier in &import_decl.specifiers { + match specifier { + ast::ImportSpecifier::Named(named) => { + if import_decl.type_only || named.is_type_only { + continue; + } + let local = named.local.sym.to_string(); + ctx.register_imported_func(local.clone(), local); + } + ast::ImportSpecifier::Default(default) => { + if import_decl.type_only { + continue; + } + let local = default.local.sym.to_string(); + ctx.register_imported_func(local.clone(), local.clone()); + if source == "react" { + ctx.react_default_import_local = Some(local); + } + } + ast::ImportSpecifier::Namespace(namespace) => { + if import_decl.type_only { + continue; + } + let local = namespace.local.sym.to_string(); + ctx.register_imported_func(local.clone(), local.clone()); + ctx.namespace_import_locals.insert(local.clone()); + ctx.namespace_import_sources + .insert(local.clone(), source.clone()); + if source == "react" { + ctx.react_default_import_local = Some(local); + } + } + } + } + } +} + pub(crate) fn lower_module_decl( ctx: &mut LoweringContext, module: &mut Module, @@ -128,6 +192,23 @@ pub(crate) fn lower_module_decl( return Ok(()); } let whole_decl_type_only = import_decl.type_only; + // TypeScript's per-specifier spelling + // + // import { type Foo, type Bar } from "./types" + // + // is just as runtime-erased as `import type { Foo, Bar }`. Keep + // the named specifiers below so class/interface metadata can still + // reach consumers, but mark the declaration itself type-only when + // every specifier is erased. Mixed imports retain their runtime + // edge because at least one specifier carries a value binding. + let runtime_erased = !whole_decl_type_only + && !import_decl.specifiers.is_empty() + && import_decl.specifiers.iter().all(|specifier| { + matches!( + specifier, + ast::ImportSpecifier::Named(named) if named.is_type_only + ) + }); // Parse import specifiers let mut specifiers = Vec::new(); @@ -482,6 +563,7 @@ pub(crate) fn lower_module_decl( module_kind, resolved_path: None, // Will be set by compiler driver during module resolution type_only: whole_decl_type_only, + runtime_erased, is_dynamic: false, is_dynamic_target: false, is_deferred_require: false, diff --git a/crates/perry-hir/src/lower/shared_mutable_capture.rs b/crates/perry-hir/src/lower/shared_mutable_capture.rs index 429c1c136a..792d01e2c2 100644 --- a/crates/perry-hir/src/lower/shared_mutable_capture.rs +++ b/crates/perry-hir/src/lower/shared_mutable_capture.rs @@ -1045,8 +1045,39 @@ fn rewrite_expr(expr: &mut Expr, shared: &HashSet, index_uses: &HashSet return; } // The closure body is a `Vec` the expr walker does not descend. - Expr::Closure { body, .. } => { + // + // A closure nested in a module/function body owns its own parameter + // scope. When a parameter is the shared class capture, rewriting its + // reads to `param[0]` is only valid after replacing the incoming value + // with the one-element cell at closure entry. The top-level Function + // path above already did this, but nested arrow/function expressions + // did not: `const make = (options?) => { class C { m() { return + // options.x } } }` dereferenced `undefined[0]` before optional chaining + // could short-circuit. Mirror the Function treatment here and insert + // the wrapper only after rewriting the original body, so its initializer + // remains the incoming scalar value rather than `param[0]`. + Expr::Closure { params, body, .. } => { + let shared_params: Vec = params + .iter_mut() + .filter_map(|param| { + if index_uses.contains(¶m.id) { + param.ty = Type::Any; + Some(param.id) + } else { + None + } + }) + .collect(); rewrite_stmts(body, shared, index_uses); + for id in shared_params.into_iter().rev() { + body.insert( + 0, + Stmt::Expr(Expr::LocalSet( + id, + Box::new(Expr::Array(vec![Expr::LocalGet(id)])), + )), + ); + } // Param defaults are still visited by walk_expr_children_mut below. } _ => {} diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index 9f29aacd44..f674cb981f 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -8,13 +8,91 @@ #![cfg(test)] use super::*; -use crate::ir::{EnumValue, Expr, Stmt}; +use crate::ir::{EnumValue, Expr, ImportSpecifier, Stmt}; use crate::types::{Type, TypeParam}; fn make_ctx() -> LoweringContext { LoweringContext::new("test.ts") } +#[test] +fn static_source_import_is_visible_before_its_declaration() { + let source = r#" + export const node = LayerNode.make(42); + import { LayerNode } from "./layer-node"; + "#; + let module = + perry_parser::parse_typescript(source, "forward-import.ts").expect("source parses"); + let hir = + super::lower_module(&module, "forward-import", "forward-import.ts").expect("source lowers"); + let dump = format!("{hir:?}"); + assert!( + !dump.contains("js_global_get_or_throw_unresolved"), + "the hoisted import was lowered as an unresolved global: {dump}" + ); + assert!( + hir.imports.iter().any(|import| import.specifiers.iter().any( + |specifier| matches!(specifier, ImportSpecifier::Named { local, .. } if local == "LayerNode") + )), + "the later import declaration must still be emitted: {dump}" + ); +} + +#[test] +fn property_values_call_is_not_assumed_to_be_an_array_iterator() { + let source = r#" + const backing = new Map([["a", 1], ["b", 2]]); + const manifest = { + Latest: Object.freeze({ + values: () => backing.values(), + [Symbol.iterator]: () => backing[Symbol.iterator](), + }), + }; + const schemas = manifest.Latest.values() + .flatMap((value) => value === 1 ? [] : [value, value * 10]) + .toArray(); + console.log([...schemas]); + "#; + let module = + perry_parser::parse_typescript(source, "readonly-map-facade.ts").expect("source parses"); + let hir = super::lower_module(&module, "readonly-map-facade", "readonly-map-facade.ts") + .expect("source lowers"); + let dump = format!("{hir:?}"); + assert!( + !dump.contains("ArrayValues"), + "a property receiver with its own values() method is not proven to be an Array: {dump}" + ); + assert!( + !dump.contains("ArrayFlatMap"), + "flatMap() on the returned iterator must stay on dynamic iterator-helper dispatch: {dump}" + ); +} + +#[test] +fn all_type_named_specifiers_are_runtime_erased_but_not_whole_type_only() { + let source = r#" + import { type RpcShape, type RpcEvent } from "./worker"; + "#; + let module = perry_parser::parse_typescript(source, "main.ts").expect("source parses"); + let hir = super::lower_module(&module, "main", "main.ts").expect("source lowers"); + let import = hir.imports.first().expect("lowered import"); + assert!(!import.type_only, "the source remains metadata-collectible"); + assert!( + import.runtime_erased, + "all per-specifier type bindings create no runtime init edge" + ); + + let mixed = r#" + import { type RpcShape, createClient } from "./worker"; + "#; + let module = perry_parser::parse_typescript(mixed, "mixed.ts").expect("source parses"); + let hir = super::lower_module(&module, "mixed", "mixed.ts").expect("source lowers"); + assert!( + !hir.imports[0].runtime_erased, + "a mixed declaration keeps its runtime value edge" + ); +} + #[test] fn test_lower_define_and_lookup_local() { let mut ctx = make_ctx(); @@ -1529,6 +1607,40 @@ fn test_function_require_with_body_still_shadows_the_namespace_fast_path() { ); } +/// A compilePackages CJS module receives Perry's synthetic `require` function. +/// Native npm shims do not materialize a complete runtime namespace object, so +/// destructuring their constructor from that function must become the same +/// static native alias as an ESM named import. hosted-git-info uses this exact +/// shape for lru-cache at module initialization. +#[test] +fn test_cjs_wrapper_lru_cache_destructure_uses_static_constructor() { + let source = r#" + function __perry_cjs_require_error(kind: string, code: string, message: string): any { + return { kind, code, message }; + } + function __perry_cjs_require_is_builtin(specifier: string): boolean { + return false; + } + function require(specifier: string): any { + return undefined; + } + const { LRUCache } = require("lru-cache"); + const cache = new LRUCache({ max: 2 }); + cache.set("answer", 42); + "#; + let module = perry_parser::parse_typescript(source, "t.ts").expect("source parses"); + let hir = super::lower_module(&module, "t", "t.ts").expect("source lowers"); + let dump = format!("{hir:?}"); + assert!( + dump.contains("New { class_name: \"LRUCache\""), + "the CJS shim destructure must lower to the static LRUCache constructor: {dump}" + ); + assert!( + !dump.contains("name: \"LRUCache\", ty: Any") && !dump.contains("NewDynamic"), + "the unreified runtime namespace local must not survive: {dump}" + ); +} + /// #8470: the plain, non-reactive documented form /// `widget.animateOpacity(target, dur)` must lower to the perry/ui animation /// call. The reactive desugar bailed when no argument read `State.value`, so diff --git a/crates/perry-hir/src/stable_hash/module.rs b/crates/perry-hir/src/stable_hash/module.rs index 78b5143ef4..5299c43a3c 100644 --- a/crates/perry-hir/src/stable_hash/module.rs +++ b/crates/perry-hir/src/stable_hash/module.rs @@ -154,6 +154,7 @@ impl SH for Import { module_kind, resolved_path, type_only, + runtime_erased, is_dynamic, is_dynamic_target, is_deferred_require, @@ -165,6 +166,12 @@ impl SH for Import { module_kind.hash(h); resolved_path.hash(h); type_only.hash(h); + // Preserve every pre-existing ordinary-import fingerprint exactly. + // Only the newly distinguished all-type specifier form needs a new + // cache identity. + if *runtime_erased { + tag(h, 0x5254); + } is_dynamic.hash(h); is_dynamic_target.hash(h); is_deferred_require.hash(h); diff --git a/crates/perry-hir/src/stable_hash/tests.rs b/crates/perry-hir/src/stable_hash/tests.rs index afdedb04c7..213cf91266 100644 --- a/crates/perry-hir/src/stable_hash/tests.rs +++ b/crates/perry-hir/src/stable_hash/tests.rs @@ -263,6 +263,7 @@ fn module_metadata_affects_hash() { module_kind: ModuleKind::NativeCompiled, resolved_path: None, type_only: false, + runtime_erased: false, is_dynamic: false, is_dynamic_target: false, is_deferred_require: false, @@ -270,6 +271,14 @@ fn module_metadata_affects_hash() { }); assert_ne!(base_hash, hash_module(&m_imp)); + let mut m_erased = m_imp.clone(); + m_erased.imports[0].runtime_erased = true; + assert_ne!( + hash_module(&m_imp), + hash_module(&m_erased), + "runtime-erased consumers need a distinct object-cache fingerprint" + ); + // Add a class let mut m_class = empty_module(); m_class.classes.push(Class { diff --git a/crates/perry-runtime/src/error.rs b/crates/perry-runtime/src/error.rs index 0a30476216..40ecd1e51e 100644 --- a/crates/perry-runtime/src/error.rs +++ b/crates/perry-runtime/src/error.rs @@ -1056,6 +1056,13 @@ static KEEP_JS_GLOBAL_GET_OR_THROW_UNRESOLVED: extern "C-unwind" fn(f64) -> f64 /// can run when the debug runtime is linked. #[no_mangle] pub extern "C-unwind" fn js_global_get_or_throw_unresolved(name_value: f64) -> f64 { + // `js_get_global_this` lazily builds the realm on first use and can collect + // while doing so. Generated code may pass a nursery string here from a + // registered module-root slot, but this argument is only a copied NaN-box: + // the collector rewrites the slot, not this Rust local. Root it before the + // first allocation and reload it at every later GC-capable boundary. + let scope = crate::gc::RuntimeHandleScope::new(); + let name_handle = scope.root_nanbox_f64(name_value); let g = crate::object::js_get_global_this(); let gj = crate::value::JSValue::from_bits(g.to_bits()); if gj.is_pointer() { @@ -1064,9 +1071,8 @@ pub extern "C-unwind" fn js_global_get_or_throw_unresolved(name_value: f64) -> f // was extracted into a raw Rust local *before* the coercion and // dereferenced by `js_object_get_field_by_name` after it. Root the // receiver and re-derive the header from the refreshed value. - let scope = crate::gc::RuntimeHandleScope::new(); let g_handle = scope.root_heap_word_u64(g.to_bits()); - let key = crate::builtins::js_string_coerce(name_value); + let key = crate::builtins::js_string_coerce(name_handle.get_nanbox_f64()); let g = f64::from_bits(g_handle.get_heap_word_u64()); let gptr = (g.to_bits() & crate::value::POINTER_MASK) as *const crate::object::ObjectHeader; if !gptr.is_null() && !key.is_null() { @@ -1083,14 +1089,14 @@ pub extern "C-unwind" fn js_global_get_or_throw_unresolved(name_value: f64) -> f // binding always is) before falling through to the throw. let has = crate::object::js_object_has_own( f64::from_bits(g_handle.get_heap_word_u64()), - name_value, + name_handle.get_nanbox_f64(), ); if crate::value::js_is_truthy(has) != 0 { return f64::from_bits(crate::value::JSValue::undefined().bits()); } } } - let name = value_to_lossy_string(name_value); + let name = value_to_lossy_string(name_handle.get_nanbox_f64()); let msg = format!("{} is not defined", name); let msg_str = js_string_from_bytes(msg.as_ptr(), msg.len() as u32); let err_ptr = js_referenceerror_new(msg_str); @@ -1121,6 +1127,8 @@ static KEEP_JS_GLOBAL_UPDATE: extern "C" fn(f64, f64, f64) -> f64 = js_global_up pub extern "C" fn js_global_update(name_value: f64, is_increment: f64, is_prefix: f64) -> f64 { let is_increment = crate::value::js_is_truthy(is_increment); let is_prefix = crate::value::js_is_truthy(is_prefix) != 0; + let scope = crate::gc::RuntimeHandleScope::new(); + let name_handle = scope.root_nanbox_f64(name_value); let g = crate::object::js_get_global_this(); let gj = crate::value::JSValue::from_bits(g.to_bits()); // #6943: `js_string_coerce` allocates for every non-heap-string name, and @@ -1130,9 +1138,8 @@ pub extern "C" fn js_global_update(name_value: f64, is_increment: f64, is_prefix // from the pre-coercion `gj`) and the coerced key string were raw Rust // locals across all of it, and `gptr` is the receiver of the WRITE-BACK at // the end. Root both and re-derive the header at each use. - let scope = crate::gc::RuntimeHandleScope::new(); let g_handle = scope.root_heap_word_u64(g.to_bits()); - let key = crate::builtins::js_string_coerce(name_value); + let key = crate::builtins::js_string_coerce(name_handle.get_nanbox_f64()); let key_handle = scope.root_string_ptr(key); let mut present = false; let old = if gj.is_pointer() && !key.is_null() { @@ -1146,7 +1153,7 @@ pub extern "C" fn js_global_update(name_value: f64, is_increment: f64, is_prefix if !v.is_undefined() || crate::object::js_object_has_own( f64::from_bits(g_handle.get_heap_word_u64()), - name_value, + name_handle.get_nanbox_f64(), ) .to_bits() == crate::value::TAG_TRUE @@ -1161,7 +1168,7 @@ pub extern "C" fn js_global_update(name_value: f64, is_increment: f64, is_prefix f64::from_bits(crate::value::TAG_UNDEFINED) }; if !present { - let name = value_to_lossy_string(name_value); + let name = value_to_lossy_string(name_handle.get_nanbox_f64()); let msg = format!("{} is not defined", name); let msg_str = js_string_from_bytes(msg.as_ptr(), msg.len() as u32); let err_ptr = js_referenceerror_new(msg_str); @@ -1212,6 +1219,9 @@ static KEEP_JS_GLOBAL_ASSIGN_EXISTING_OR_THROW: extern "C" fn(f64, f64) -> f64 = /// that may CREATE the binding. #[no_mangle] pub extern "C" fn js_global_assign_existing_or_throw(name_value: f64, value: f64) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let name_handle = scope.root_nanbox_f64(name_value); + let value_handle = scope.root_nanbox_f64(value); let g = crate::object::js_get_global_this(); let gj = crate::value::JSValue::from_bits(g.to_bits()); // #6943: the textbook shape of this family — a receiver AND the value being @@ -1220,10 +1230,8 @@ pub extern "C" fn js_global_assign_existing_or_throw(name_value: f64, value: f64 // the not-defined path (`js_string_from_bytes`, `js_referenceerror_new`) // allocate on top of that, and `gptr` is the receiver of the final write. // Root the global, the coerced key and `value` for the whole helper. - let scope = crate::gc::RuntimeHandleScope::new(); let g_handle = scope.root_heap_word_u64(g.to_bits()); - let value_handle = scope.root_nanbox_f64(value); - let key = crate::builtins::js_string_coerce(name_value); + let key = crate::builtins::js_string_coerce(name_handle.get_nanbox_f64()); let key_handle = scope.root_string_ptr(key); let mut present = false; if gj.is_pointer() && !key.is_null() { @@ -1237,7 +1245,7 @@ pub extern "C" fn js_global_assign_existing_or_throw(name_value: f64, value: f64 if !v.is_undefined() || crate::object::js_object_has_own( f64::from_bits(g_handle.get_heap_word_u64()), - name_value, + name_handle.get_nanbox_f64(), ) .to_bits() == crate::value::TAG_TRUE @@ -1247,7 +1255,7 @@ pub extern "C" fn js_global_assign_existing_or_throw(name_value: f64, value: f64 } } if !present { - let name = value_to_lossy_string(name_value); + let name = value_to_lossy_string(name_handle.get_nanbox_f64()); let msg = format!("{} is not defined", name); let msg_str = js_string_from_bytes(msg.as_ptr(), msg.len() as u32); let err_ptr = js_referenceerror_new(msg_str); @@ -1271,14 +1279,15 @@ pub extern "C" fn js_global_assign_existing_or_throw(name_value: f64, value: f64 /// property set — #3575) must still be observed. #[no_mangle] pub extern "C" fn js_global_get_optional(name_value: f64) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let name_handle = scope.root_nanbox_f64(name_value); let g = crate::object::js_get_global_this(); let gj = crate::value::JSValue::from_bits(g.to_bits()); if gj.is_pointer() { // #6943: root the global across the GC-capable coercion and re-derive // its header afterwards — see `js_global_get_or_throw_unresolved`. - let scope = crate::gc::RuntimeHandleScope::new(); let g_handle = scope.root_heap_word_u64(g.to_bits()); - let key = crate::builtins::js_string_coerce(name_value); + let key = crate::builtins::js_string_coerce(name_handle.get_nanbox_f64()); let g = f64::from_bits(g_handle.get_heap_word_u64()); let gptr = (g.to_bits() & crate::value::POINTER_MASK) as *const crate::object::ObjectHeader; if !gptr.is_null() && !key.is_null() { @@ -1863,6 +1872,37 @@ mod tostring_tests { js_throw_type_error_not_a_function; } + #[test] + fn unresolved_global_name_survives_collection_during_global_this_init() { + let _copying_nursery = crate::gc::CopyingNurseryTestGuard::new(0); + let _triggers = crate::gc::GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _force_evacuation = crate::gc::knob_overrides::ForcedEvacuationTestGuard::on(); + crate::gc::register_runtime_handle_root_scanner_for_tests(); + + // Mirror a generated module string slot: the collector rewrites this + // registered root, but it cannot rewrite the by-value f64 copied into + // `js_global_get_optional`. That callee must establish its own handle + // before lazy global initialization reaches a collection point. + let key_ptr = s(b"navigator"); + let key_before = key_ptr as usize; + let mut key_value = f64::from_bits( + crate::value::STRING_TAG | (key_before as u64 & crate::value::POINTER_MASK), + ); + crate::gc::js_gc_register_global_root((&mut key_value as *mut f64) as i64); + crate::object::collect_before_global_this_alloc_for_test(); + + let navigator = js_global_get_optional(key_value); + let key_after = (key_value.to_bits() & crate::value::POINTER_MASK) as usize; + assert_ne!( + key_after, key_before, + "the forced collection must relocate the caller's rooted key" + ); + assert!( + crate::value::JSValue::from_bits(navigator.to_bits()).is_pointer(), + "the refreshed key must still resolve globalThis.navigator" + ); + } + fn s(bytes: &[u8]) -> *mut StringHeader { js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32) } diff --git a/crates/perry-runtime/src/iterator_helpers/tests.rs b/crates/perry-runtime/src/iterator_helpers/tests.rs index 912bfd175c..cb96a7af71 100644 --- a/crates/perry-runtime/src/iterator_helpers/tests.rs +++ b/crates/perry-runtime/src/iterator_helpers/tests.rs @@ -377,6 +377,33 @@ fn flat_map_returns_a_helper_and_flattens_one_level() { } } +#[test] +fn map_iterator_inherits_flat_map_and_to_array_through_the_tower() { + let _serialized = crate::array::test_serialize(); + unsafe { + let map = crate::map::js_map_alloc(2); + crate::map::js_map_set(map, 1.0, 1.0); + crate::map::js_map_set(map, 2.0, 2.0); + let raw_iter = crate::collection_iter_object::js_map_values_iter_obj(map); + let iter = crate::value::js_nanbox_pointer(raw_iter); + + let flattened = tower(iter, "flatMap", &[closure1(pair_with_ten_times)]); + let helper_ptr = crate::value::js_nanbox_get_pointer(flattened); + assert_ne!( + helper_ptr, 0, + "MapIterator.prototype.flatMap must return a helper object" + ); + assert_eq!( + (*(helper_ptr as *const ObjectHeader)).class_id, + ITERATOR_HELPER_CLASS_ID + ); + assert_eq!( + array_numbers(tower(flattened, "toArray", &[])), + vec![1.0, 10.0, 2.0, 20.0] + ); + } +} + #[test] fn chained_helpers_compose() { unsafe { diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index 27e330209c..8288d234b6 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -67,10 +67,11 @@ pub(crate) use state::{ class_object_value_root_store, class_own_enumerable_field_names, class_own_static_field_value, class_parent_closure, class_parent_closure_root_store, class_prototype_method_is_enumerable, class_prototype_method_set_enumerable, class_prototype_method_value_cache_root_store, - class_prototype_object_root_store, class_static_defined_attrs, class_static_set_defined_attrs, - class_unmark_key_deleted, global_object_prototype_bits, - is_bound_native_constructor_closure_value, is_non_constructable_builtin_function_value, - parent_closure_in_chain, throw_non_constructable_builtin_function, + class_prototype_object_root_clear, class_prototype_object_root_store, + class_static_defined_attrs, class_static_set_defined_attrs, class_unmark_key_deleted, + global_object_prototype_bits, is_bound_native_constructor_closure_value, + is_non_constructable_builtin_function_value, parent_closure_in_chain, + throw_non_constructable_builtin_function, }; pub use state::{ ClassVTable, VTableMethodEntry, CLASS_DECL_PROTOTYPE_OBJECTS, CLASS_DYNAMIC_PARENT_VALUE, diff --git a/crates/perry-runtime/src/object/class_registry/dispatch.rs b/crates/perry-runtime/src/object/class_registry/dispatch.rs index 2157217063..0090ec699b 100644 --- a/crates/perry-runtime/src/object/class_registry/dispatch.rs +++ b/crates/perry-runtime/src/object/class_registry/dispatch.rs @@ -486,29 +486,69 @@ unsafe fn call_vtable_method_inner( // `(number).forEach is not a function`. The synthesized-`arguments` slot // holds ALL passed args; a user rest slot holds only args from the rest // position onward (so `method(a, ...rest)` keeps `a` positional). + // Root the receiver and supplied arguments before allocating either + // trailing array. Handles are re-read after a copying collection. + let dispatch_scope = crate::gc::RuntimeHandleScope::new(); + let needs_packed_args = has_synthetic_arguments || has_rest; + let this_handle = needs_packed_args.then(|| dispatch_scope.root_nanbox_f64(this_f64)); + let explicit_private_brand_handle = if needs_packed_args { + explicit_private_brand.map(|value| dispatch_scope.root_nanbox_f64(value)) + } else { + None + }; + + // A user rest parameter and the hidden `arguments` parameter are distinct + // ABI slots. A method containing both lowers as + // `[fixed..., user_rest, synthetic_arguments]`: the first array contains + // only the tail after the fixed formals, while the second contains every + // supplied argument. Treating the flags as mutually exclusive bound the + // first scalar argument directly to `user_rest`. let adjusted_args_storage: Option>; - let (call_args_ptr, call_args_len) = if has_synthetic_arguments || has_rest { - let visible_params = (param_count as usize).saturating_sub(1); - let pack_start = if has_synthetic_arguments { - 0 + let (call_args_ptr, call_args_len) = if needs_packed_args { + let supplied_args: Vec = (0..args_len) + .map(|i| arg_or_undefined(args_ptr, args_len, i)) + .collect(); + let supplied_arg_handles = dispatch_scope.root_nanbox_f64_slice(&supplied_args); + let trailing_slots = usize::from(has_rest) + usize::from(has_synthetic_arguments); + let fixed_params = (param_count as usize).saturating_sub(trailing_slots); + + let user_rest_handle = if has_rest { + let refreshed = + crate::gc::RuntimeHandleScope::refreshed_nanbox_f64_slice(&supplied_arg_handles); + let rest_start = fixed_params.min(refreshed.len()); + Some( + dispatch_scope.root_nanbox_f64(crate::closure::build_rest_array( + &refreshed[rest_start..], + false, + )), + ) + } else { + None + }; + + let synthetic_arguments_handle = if has_synthetic_arguments { + let refreshed = + crate::gc::RuntimeHandleScope::refreshed_nanbox_f64_slice(&supplied_arg_handles); + Some(dispatch_scope.root_nanbox_f64(crate::closure::build_rest_array(&refreshed, true))) } else { - visible_params.min(args_len) + None }; - let packed_len = args_len.saturating_sub(pack_start); - let raw_args = crate::array::js_array_alloc_with_length(packed_len as u32); - for (slot, i) in (pack_start..args_len).enumerate() { - crate::array::js_array_set_f64( - raw_args, - slot as u32, - arg_or_undefined(args_ptr, args_len, i), + + let mut args = Vec::with_capacity(param_count as usize); + for i in 0..fixed_params { + args.push( + supplied_arg_handles + .get(i) + .map(|handle| handle.get_nanbox_f64()) + .unwrap_or_else(|| f64::from_bits(crate::value::TAG_UNDEFINED)), ); } - let raw_args_value = crate::value::js_nanbox_pointer(raw_args as i64); - let mut args = Vec::with_capacity(param_count as usize); - for i in 0..visible_params { - args.push(arg_or_undefined(args_ptr, args_len, i)); + if let Some(handle) = user_rest_handle.as_ref() { + args.push(handle.get_nanbox_f64()); + } + if let Some(handle) = synthetic_arguments_handle.as_ref() { + args.push(handle.get_nanbox_f64()); } - args.push(raw_args_value); adjusted_args_storage = Some(args); let adjusted_args = adjusted_args_storage.as_ref().unwrap(); (adjusted_args.as_ptr(), adjusted_args.len()) @@ -534,7 +574,14 @@ unsafe fn call_vtable_method_inner( param_count, MAX_VTABLE_DISPATCH_ARITY ); - let private_brand = explicit_private_brand + let this_f64 = this_handle + .as_ref() + .map(|handle| handle.get_nanbox_f64()) + .unwrap_or(this_f64); + let private_brand = explicit_private_brand_handle + .as_ref() + .map(|handle| handle.get_nanbox_f64()) + .or(explicit_private_brand) .or_else(|| crate::object::private_evaluation_brand_value(this_f64)) .unwrap_or_else(|| f64::from_bits(crate::value::TAG_UNDEFINED)); let derived_super_depth = crate::object::derived_super_binding_stack_savepoint(); diff --git a/crates/perry-runtime/src/object/class_registry/state.rs b/crates/perry-runtime/src/object/class_registry/state.rs index 610e06c438..8c89d2f7cc 100644 --- a/crates/perry-runtime/src/object/class_registry/state.rs +++ b/crates/perry-runtime/src/object/class_registry/state.rs @@ -468,6 +468,19 @@ pub(crate) fn class_prototype_object_root_store(class_id: u32, proto_ptr: *mut O crate::gc::runtime_write_barrier_root_raw_ptr(proto_ptr); } +pub(crate) fn class_prototype_object_root_clear(class_id: u32) { + if class_id == 0 { + return; + } + CLASS_PROTOTYPE_OBJECTS.with(|table| { + if let Ok(mut guard) = table.write() { + if let Some(map) = guard.as_mut() { + map.remove(&class_id); + } + } + }); +} + pub(crate) fn class_decl_prototype_object_root_store(class_id: u32, proto_ptr: *mut ObjectHeader) { if class_id == 0 || proto_ptr.is_null() { return; diff --git a/crates/perry-runtime/src/object/field_get_set/has_property.rs b/crates/perry-runtime/src/object/field_get_set/has_property.rs index 8da38582cd..cce718d288 100644 --- a/crates/perry-runtime/src/object/field_get_set/has_property.rs +++ b/crates/perry-runtime/src/object/field_get_set/has_property.rs @@ -157,6 +157,50 @@ fn in_rhs_is_object(obj: f64) -> bool { && crate::value::addr_class::is_stream_id_band(f as usize) } +/// Presence-only counterpart of the inherited static DATA-field read in +/// `js_object_get_field_by_name`'s ClassRef arm. +/// +/// A dynamically-computed string key takes the generic `in` path, so it cannot +/// benefit from codegen's declared-class static lookup. Class declarations +/// inherit static data in two runtime representations: +/// +/// * ordinary declared parents store fields in `CLASS_DYNAMIC_PROPS` along the +/// registered parent-class-id chain; +/// * a class-expression parent (`class D extends make() {}`) is a real heap +/// class object pinned in `CLASS_PROTOTYPE_OBJECTS`, with per-evaluation +/// statics in its own keys array. +/// +/// `in` must test presence without reading a value: an explicitly-undefined +/// field is still present, and an accessor must not run. `ordinary_has_property` +/// provides exactly that operation for a pinned class object. +unsafe fn class_ref_has_inherited_static_data( + class_id: u32, + key: *const crate::StringHeader, + name: &str, +) -> bool { + let mut child = class_id; + let mut depth = 0usize; + while depth < 32 { + let proto = super::super::class_registry::class_prototype_object(child); + if !proto.is_null() && ordinary_has_property(proto as *const ObjectHeader, key) { + return true; + } + + let parent = match super::super::class_registry::get_parent_class_id(child) { + Some(parent) if parent != 0 && parent != child => parent, + _ => break, + }; + if !super::super::class_registry::class_is_key_deleted(parent, name) + && super::super::class_registry::class_has_own_dynamic_prop(parent, name) + { + return true; + } + child = parent; + depth += 1; + } + false +} + /// The `in` operator: `key in obj`. ECMA-262 13.10.1 (RelationalExpression `in`) /// step 5 requires the right operand to be an Object, throwing a `TypeError` /// otherwise. This is the dedicated codegen entry point for the source-level @@ -368,6 +412,15 @@ pub extern "C" fn js_object_has_property(obj: f64, key: f64) -> f64 { if let Some(name) = unsafe { crate::string::js_string_key_bytes(key_val, &mut sso) } .and_then(|b| std::str::from_utf8(b).ok()) { + let inherited_data = !matches!(name, "name" | "length") + && unsafe { + class_ref_has_inherited_static_data( + class_id, + crate::value::js_get_string_pointer_unified(key) + as *const crate::StringHeader, + name, + ) + }; let present = matches!(name, "prototype" | "name" | "length") || (!super::super::class_registry::class_is_key_deleted(class_id, name) && (super::super::class_registry::class_has_own_dynamic_prop( @@ -379,7 +432,8 @@ pub extern "C" fn js_object_has_property(obj: f64, key: f64) -> f64 { || super::super::class_registry::class_own_static_accessor_ptrs( class_id, name, ) - .is_some())); + .is_some() + || inherited_data)); if present { return nanbox_true; } diff --git a/crates/perry-runtime/src/object/global_this.rs b/crates/perry-runtime/src/object/global_this.rs index 8dd1613f0a..6c3dddcfce 100644 --- a/crates/perry-runtime/src/object/global_this.rs +++ b/crates/perry-runtime/src/object/global_this.rs @@ -108,6 +108,8 @@ pub(crate) use ctor_thunks::{ webcrypto_illegal_constructor_thunk, webcrypto_method_value, webcrypto_random_uuid_thunk, webcrypto_subtle_getter_thunk, }; +#[cfg(test)] +pub(crate) use fetch_globals::collect_before_global_this_alloc_for_test; #[cfg(feature = "temporal")] pub(crate) use fetch_globals::temporal_subclass_super; pub(crate) use fetch_globals::{ diff --git a/crates/perry-runtime/src/object/global_this/fetch_globals.rs b/crates/perry-runtime/src/object/global_this/fetch_globals.rs index 0140e9987d..8252c3c739 100644 --- a/crates/perry-runtime/src/object/global_this/fetch_globals.rs +++ b/crates/perry-runtime/src/object/global_this/fetch_globals.rs @@ -12,6 +12,25 @@ crate::perry_thread_local! { static THREAD_GLOBAL_THIS: std::cell::Cell = const { std::cell::Cell::new(0) }; } +#[cfg(test)] +crate::perry_thread_local! { + /// One-shot regression hook: collect after GC initialization but before + /// the first `globalThis` allocation. This is the exact window in which a + /// caller's by-value lookup key used to become stale. + static TEST_COLLECT_BEFORE_GLOBAL_THIS_ALLOC: std::cell::Cell = + const { std::cell::Cell::new(false) }; +} + +#[cfg(test)] +pub(crate) fn collect_before_global_this_alloc_for_test() { + assert_eq!( + THREAD_GLOBAL_THIS.with(|c| c.get()), + 0, + "globalThis regression hook must be armed before first access" + ); + TEST_COLLECT_BEFORE_GLOBAL_THIS_ALLOC.with(|c| c.set(true)); +} + crate::perry_thread_local! { /// Module top-level `this` (Node-CJS `module.exports` stand-in) — a /// lazily-allocated plain object distinct from `globalThis`. See @@ -62,6 +81,10 @@ pub extern "C" fn js_get_global_this() -> f64 { // would reclaim the global mid-use, leaving a dangling intrinsic. No-op in // production (already initialized) and inside the GC tests' controlled scopes. crate::gc::ensure_gc_initialized(); + #[cfg(test)] + if TEST_COLLECT_BEFORE_GLOBAL_THIS_ALLOC.with(|c| c.replace(false)) { + let _ = crate::gc::gc_collect_minor(); + } // First access on this thread — allocate our own global. let new_ptr = js_object_alloc(0, 0) as i64; THREAD_GLOBAL_THIS.with(|c| c.set(new_ptr)); diff --git a/crates/perry-runtime/src/object/object_ops/define_properties.rs b/crates/perry-runtime/src/object/object_ops/define_properties.rs index 5b0d6afe8d..3c3568cd02 100644 --- a/crates/perry-runtime/src/object/object_ops/define_properties.rs +++ b/crates/perry-runtime/src/object/object_ops/define_properties.rs @@ -360,6 +360,40 @@ pub extern "C" fn js_object_set_prototype_of(obj_value: f64, proto: f64) -> f64 } } + // Declared ES classes are represented by INT32-tagged ClassRefs rather + // than heap Function objects. Preserve Object.setPrototypeOf on a ClassRef + // as the class object's static prototype. Effect's Schema.Opaque depends + // on this exact shape: + // + // class Opaque {} + // Object.setPrototypeOf(Opaque, schema) + // class Partial extends Opaque {} + // Partial.ast + // + // Ordinary object and closure targets already have prototype side tables, + // but the ClassRef previously fell through as a no-op. The class-static + // inheritance walk already consults CLASS_PROTOTYPE_OBJECTS, so record an + // ordinary object prototype there. A null prototype clears an earlier + // link. Other valid prototype kinds retain their existing behavior. + if let Some(class_id) = super::super::class_ref_id(obj_value) { + if proto_is_null { + super::super::class_registry::class_prototype_object_root_clear(class_id); + return obj_value; + } + if (proto_bits & 0xFFFF_0000_0000_0000) == POINTER_TAG { + let proto_ptr = crate::value::js_nanbox_get_pointer(proto) as *mut ObjectHeader; + if !proto_ptr.is_null() + && !crate::closure::is_closure_ptr(proto_ptr as usize) + && is_valid_obj_ptr(proto_ptr as *const u8) + { + super::super::class_registry::class_prototype_object_root_store( + class_id, proto_ptr, + ); + return obj_value; + } + } + } + // #2820: setting the prototype of a primitive target is a spec no-op that // returns the (boxed) primitive value. `value_is_object_like` is false for // numbers/strings/booleans, and class refs are handled by the recording diff --git a/crates/perry-runtime/src/regex/grammar.rs b/crates/perry-runtime/src/regex/grammar.rs index 4abad899b4..b1bb623af7 100644 --- a/crates/perry-runtime/src/regex/grammar.rs +++ b/crates/perry-runtime/src/regex/grammar.rs @@ -893,8 +893,17 @@ fn fold_surrogate_pairs(pattern: &str) -> String { let chars: Vec = pattern.chars().collect(); let mut out = String::with_capacity(pattern.len()); let mut i = 0; + let mut in_class = false; + let mut escaped = false; while i < chars.len() { - let at_unit_start = (chars[i] == '\\' && chars.get(i + 1) == Some(&'u')) || chars[i] == '['; + // Bare surrogate escapes inside one character class are alternative + // members, not a high/low sequence. Leave them for the class-aware + // pass below; otherwise the upper endpoint of one range can be folded + // with the lower endpoint of the next range (for example + // `[\uD800-\uDB7F\uDC00-\uDFFF]`). A complete high-surrogate class at + // `[` is still eligible to pair with the following low-surrogate unit. + let at_unit_start = + !in_class && ((chars[i] == '\\' && chars.get(i + 1) == Some(&'u')) || chars[i] == '['); if at_unit_start { if let Some((hi, j)) = parse_surrogate_unit(&chars, i) { if hi @@ -935,6 +944,16 @@ fn fold_surrogate_pairs(pattern: &str) -> String { } } out.push(chars[i]); + if escaped { + escaped = false; + } else { + match chars[i] { + '\\' => escaped = true, + '[' if !in_class => in_class = true, + ']' if in_class => in_class = false, + _ => {} + } + } i += 1; } out @@ -1931,6 +1950,21 @@ mod tests { assert_eq!(js_regex_to_rust("[\\ud800-z]"), "[\\ud800-z]"); } + #[test] + fn split_surrogate_class_ranges_compile() { + // TypeScript/parser startup code constructs this split surrogate class + // dynamically. Both members are valid JavaScript UTF-16 code-unit + // ranges and must be translated before Rust's scalar-only regex parser + // sees them. + let pat = r"[\uD800-\uDB7F\uDC00-\uDFFF]"; + let translated = js_regex_to_rust(pat); + let re = regex::Regex::new(&translated) + .unwrap_or_else(|e| panic!("split surrogate class failed: {translated}: {e}")); + assert!(re.is_match("\u{10000}")); + assert!(re.is_match("\u{10FFFF}")); + assert!(!re.is_match("a")); + } + #[test] fn rgi_emoji_string_property_expands_and_matches_like_node() { // #4889: `string-width@7+` builds `/^\p{RGI_Emoji}$/v` at module top diff --git a/crates/perry-transform/src/inline/cross_module.rs b/crates/perry-transform/src/inline/cross_module.rs index 19e62e0dc7..ad9361f045 100644 --- a/crates/perry-transform/src/inline/cross_module.rs +++ b/crates/perry-transform/src/inline/cross_module.rs @@ -655,6 +655,7 @@ pub(crate) fn localize_cross_module_functions( module_kind: perry_hir::ModuleKind::NativeCompiled, resolved_path: Some(path), type_only: false, + runtime_erased: false, is_dynamic: false, is_dynamic_target: false, is_deferred_require: false, diff --git a/crates/perry-transform/src/inline/mod.rs b/crates/perry-transform/src/inline/mod.rs index 411f798176..af66f341f8 100644 --- a/crates/perry-transform/src/inline/mod.rs +++ b/crates/perry-transform/src/inline/mod.rs @@ -664,6 +664,7 @@ fn inline_functions_inner( module_kind: perry_hir::ModuleKind::NativeCompiled, resolved_path: Some(path), type_only: false, + runtime_erased: false, is_dynamic: false, is_dynamic_target: false, is_deferred_require: false, diff --git a/crates/perry/src/commands/compile/bootstrap.rs b/crates/perry/src/commands/compile/bootstrap.rs index bcc8a927d1..94f6ef0c02 100644 --- a/crates/perry/src/commands/compile/bootstrap.rs +++ b/crates/perry/src/commands/compile/bootstrap.rs @@ -409,6 +409,7 @@ pub(super) fn enforce_package_default_exports(ctx: &mut CompilationContext) -> R .flat_map(|(importer_path, module)| { module.imports.iter().filter_map(|import| { if import.type_only + || import.runtime_erased || import.is_dynamic || import.is_native // Issue #5257: an adopted `require('S')` (CJS wrap synthesized @@ -537,6 +538,7 @@ mod js_runtime_gate_tests { module_kind: perry_hir::ModuleKind::NativeCompiled, resolved_path: Some(package_path.to_string_lossy().to_string()), type_only: false, + runtime_erased: false, is_dynamic: false, is_dynamic_target: false, is_deferred_require: false, diff --git a/crates/perry/src/commands/compile/cjs_wrap/hoist_classes.rs b/crates/perry/src/commands/compile/cjs_wrap/hoist_classes.rs index c39fb3c59d..6c1272da68 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/hoist_classes.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/hoist_classes.rs @@ -709,7 +709,17 @@ fn collect_pattern_binding_names(bytes: &[u8], start: usize, names: &mut Vec Vec { - let bytes = source.as_bytes(); + // Use the shared lexical masker for structural scanning. In particular, + // regex literals may contain quote bytes (`/\\\"/g`, `/['\"]/`) that + // are not JavaScript string delimiters. Scanning the raw source made such + // a quote start the string-skipping arm below, which could hide the braces + // that restore top-level depth. Every later `const`/`let`/`var` then looked + // nested and was omitted from `iife_locals`, allowing a dependent class to + // be hoisted out of the CommonJS factory ahead of its local superclass. + // `strip_comments_and_strings` preserves byte positions, identifiers, + // delimiters and newlines while masking regex/string/comment contents. + let masked_source = super::detect::strip_comments_and_strings(source); + let bytes = masked_source.as_bytes(); let mut names: Vec = Vec::new(); let mut depth: i32 = 0; let mut i = 0usize; diff --git a/crates/perry/src/commands/compile/cjs_wrap/tests.rs b/crates/perry/src/commands/compile/cjs_wrap/tests.rs index 77af6753d0..adb048f851 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/tests.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/tests.rs @@ -1985,3 +1985,30 @@ fn cjs_wrap_builtin_require_not_hoisted_as_static_import() { "the built-in require case must not reference the dropped import local; got:\n{wrapped}" ); } + +#[test] +fn regex_quote_before_local_superclass_keeps_class_in_cjs_iife() { + // @smithy/core's serde CJS emit contains this sequence. The quote inside + // the regex is not a string delimiter; treating it as one desynchronized + // the top-level-binding scanner, hid `ReadableStreamRef`, and hoisted only + // `ChecksumStream` ahead of the CommonJS IIFE. + let src = r#"const splitHeader = (value) => { + return value.replace(/\\"/g, '"'); +}; +const ReadableStreamRef = typeof ReadableStream === "function" + ? ReadableStream + : function () {}; +class ChecksumStream extends ReadableStreamRef {} +module.exports = { ChecksumStream }; +"#; + + let (blocks, hoisted_names, rest) = extract_top_level_class_decls(src); + assert!( + !hoisted_names.iter().any(|name| name == "ChecksumStream"), + "a class depending on a CJS-local superclass must not hoist; hoisted block:\n{blocks}" + ); + assert!( + rest.contains("class ChecksumStream extends ReadableStreamRef"), + "the class declaration must remain at its source position inside the factory" + ); +} diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index 31a1ac1cd9..684a0ec4e6 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -1020,6 +1020,7 @@ fn collect_module_one( module_kind, resolved_path: None, type_only: false, + runtime_erased: false, is_dynamic: true, is_dynamic_target: false, is_deferred_require: false, diff --git a/crates/perry/src/commands/compile/collect_modules/feature_detect.rs b/crates/perry/src/commands/compile/collect_modules/feature_detect.rs index e2d16fe1f6..c281ef1ec7 100644 --- a/crates/perry/src/commands/compile/collect_modules/feature_detect.rs +++ b/crates/perry/src/commands/compile/collect_modules/feature_detect.rs @@ -90,6 +90,7 @@ fn debug_hir_uses_global_math_member(hir_debug: &str) -> bool { fn imports_fs_promises_glob(hir_module: &perry_hir::Module) -> bool { hir_module.imports.iter().any(|import| { !import.type_only + && !import.runtime_erased && import .source .strip_prefix("node:") @@ -702,6 +703,7 @@ mod tests { module_kind: ModuleKind::NativeCompiled, resolved_path: None, type_only: false, + runtime_erased: false, is_dynamic: false, is_dynamic_target: false, is_deferred_require: false, diff --git a/crates/perry/src/commands/compile/init_order.rs b/crates/perry/src/commands/compile/init_order.rs index 6844e8ef3d..f5f22368e7 100644 --- a/crates/perry/src/commands/compile/init_order.rs +++ b/crates/perry/src/commands/compile/init_order.rs @@ -55,7 +55,9 @@ pub(super) fn classify_eager_modules(ctx: &mut CompilationContext, entry_path: & let static_targets: Vec = module .imports .iter() - .filter(|i| !i.is_dynamic && !i.type_only && !i.is_deferred_require) + .filter(|i| { + !i.is_dynamic && !i.type_only && !i.runtime_erased && !i.is_deferred_require + }) .filter_map(|i| i.resolved_path.as_ref().map(PathBuf::from)) .collect(); let reexport_sources: Vec = module @@ -146,7 +148,17 @@ pub(super) fn topo_sort_non_entry_modules( // `is_deferred_require`: a function-local `require('S')` is not an // init-order edge — S inits lazily when the require shim runs, not // as part of this module's eager init. - if import.type_only || import.is_deferred_require { + // Dynamic imports are lazy evaluation edges, not eager module-init + // dependencies. Including one here can manufacture a cycle that + // does not exist in ESM's startup graph and reverse a real static + // edge when the DFS breaks that cycle. `classify_eager_modules` + // and the generated per-module init wrappers both exclude these + // edges; the global order must use the same graph. + if import.is_dynamic + || import.type_only + || import.runtime_erased + || import.is_deferred_require + { continue; } if let Some(ref resolved) = import.resolved_path { @@ -248,7 +260,11 @@ pub(super) fn topo_sort_non_entry_modules( // alphabetical order for determinism. if let Some(entry_module) = ctx.native_modules.get(entry_path) { for import in &entry_module.imports { - if import.is_dynamic || import.type_only || import.is_deferred_require { + if import.is_dynamic + || import.type_only + || import.runtime_erased + || import.is_deferred_require + { continue; } if let Some(ref resolved) = import.resolved_path { diff --git a/crates/perry/src/commands/compile/link/build_and_run.rs b/crates/perry/src/commands/compile/link/build_and_run.rs index 2f0b316baf..2c46590b3d 100644 --- a/crates/perry/src/commands/compile/link/build_and_run.rs +++ b/crates/perry/src/commands/compile/link/build_and_run.rs @@ -402,8 +402,12 @@ pub(crate) fn build_and_run_link( }), None => wk.clone(), }; - strip_duplicate_objects_from_well_known_lib(&wk).unwrap_or_else(|_| { + strip_duplicate_objects_from_well_known_lib(&wk).unwrap_or_else(|error| { cacheable.set(false); + eprintln!( + "[strip-dedup] wrapper symbol rewrite skipped for {} (non-fatal): {error}", + wk.display() + ); wk }) }) diff --git a/crates/perry/src/commands/compile/link/link_cache.rs b/crates/perry/src/commands/compile/link/link_cache.rs index 1a11e47765..397cf99b4d 100644 --- a/crates/perry/src/commands/compile/link/link_cache.rs +++ b/crates/perry/src/commands/compile/link/link_cache.rs @@ -104,7 +104,7 @@ struct SearchDir { struct LinkFingerprintContext<'a> { cwd: PathBuf, exe_path: &'a Path, - obj_paths: &'a [PathBuf], + object_identities: std::collections::HashSet, stats: LinkCacheInputStats, lib_dirs: Vec, framework_dirs: Vec, @@ -225,7 +225,10 @@ fn compute_link_cache_state( let mut ctx = LinkFingerprintContext { cwd: cwd.clone(), exe_path, - obj_paths, + object_identities: obj_paths + .iter() + .map(|path| absolute_path_identity_from(path, &cwd)) + .collect(), stats, lib_dirs: library_path_search_dirs(cmd, &cwd), framework_dirs: default_framework_search_dirs(cmd, &cwd), @@ -429,16 +432,9 @@ fn feed_explicit_file_arg( feed_hash_field(hasher, role, ""); return Some(()); } - if ctx - .obj_paths - .iter() - .any(|obj_path| same_path_from(&candidate, obj_path, &ctx.cwd)) - { - feed_hash_field( - hasher, - role, - &absolute_path_identity_from(&candidate, &ctx.cwd), - ); + let candidate_identity = absolute_path_identity_from(&candidate, &ctx.cwd); + if ctx.object_identities.contains(&candidate_identity) { + feed_hash_field(hasher, role, &candidate_identity); return Some(()); } if candidate.is_file() { diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index 2f4693f480..36d7643a7b 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -570,6 +570,7 @@ fn compute_object_cache_key_with_env( .cmp(&b.name) .then(a.source_prefix.cmp(&b.source_prefix)) .then(a.local_alias.cmp(&b.local_alias)) + .then(a.namespace.cmp(&b.namespace)) }); let mut buf = String::new(); for c in v { @@ -591,6 +592,15 @@ fn compute_object_cache_key_with_env( .collect::>() .join(","), )); + // Preserve pre-namespace cache keys byte-for-byte for ordinary + // imported classes. Only namespace-owned bindings can generate + // different code under the scoped registry fix, so only those + // records need a new cache-key component. + if let Some(namespace) = &c.namespace { + buf.push_str(":namespace="); + buf.push_str(namespace); + buf.push('|'); + } buf.push_str("method_rest="); buf.push_str( &c.method_has_rest @@ -950,8 +960,8 @@ fn compute_object_cache_key_with_env( // Environment variables read by `perry-codegen` that influence the // emitted .o bytes. Not part of `CompileOptions`, but just as real an // input to `compile_module` / `compile_ll_to_object`: - // - PERRY_DEBUG_INIT=1 bakes a `puts("INIT: ")` call into - // every module's `__init` (codegen.rs). + // - PERRY_DEBUG_INIT=1 bakes `puts("INIT: ")` calls around the + // eager initializer chain in the entry object (entry.rs). // - PERRY_DEBUG_SYMBOLS=1 adds `-g` to clang → embeds DWARF sections // into the object (linker.rs). // - PERRY_LLVM_CLANG selects which clang binary compiles .ll → .o; @@ -978,10 +988,11 @@ fn compute_object_cache_key_with_env( // like PERRY_LLVM_CLANG=/opt/homebrew/opt/llvm/bin/clang in a shell rc // still gets cache reuse across runs, while flipping a debug flag on // or off cleanly invalidates. - h.field( - "env_debug_init", - env_var("PERRY_DEBUG_INIT").as_deref().unwrap_or(""), - ); + let debug_init = opts + .is_entry_module + .then(|| env_var("PERRY_DEBUG_INIT")) + .flatten(); + h.field("env_debug_init", debug_init.as_deref().unwrap_or("")); h.field( "env_debug_symbols", env_var("PERRY_DEBUG_SYMBOLS").as_deref().unwrap_or(""), 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 c2432c9761..ac035d7fe2 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 @@ -348,6 +348,7 @@ fn key_stable_for_nested_type_hashmap_order() { let class_for = |field_type| ImportedClass { name: "RowBox".into(), local_alias: None, + namespace: None, source_prefix: "feature_ts".into(), constructor_param_count: 0, has_own_constructor: false, @@ -408,6 +409,7 @@ fn key_changes_with_imported_class_signature() { a.imported_classes.push(ImportedClass { name: "Foo".into(), local_alias: None, + namespace: None, source_prefix: "src".into(), constructor_param_count: 1, has_own_constructor: true, @@ -441,6 +443,7 @@ fn key_changes_with_imported_class_signature() { b.imported_classes.push(ImportedClass { name: "Foo".into(), local_alias: None, + namespace: None, source_prefix: "src".into(), constructor_param_count: 2, // different arity has_own_constructor: true, @@ -482,6 +485,7 @@ fn key_changes_with_imported_class_codegen_surface() { let base = ImportedClass { name: "Foo".into(), local_alias: None, + namespace: None, source_prefix: "src".into(), constructor_param_count: 1, has_own_constructor: true, @@ -618,6 +622,10 @@ fn key_changes_with_imported_class_codegen_surface() { changed.setter_names = vec!["value".into()]; assert_ne!(base_key, key_for(changed)); + let mut changed = base.clone(); + changed.namespace = Some("Plugin".into()); + assert_ne!(base_key, key_for(changed)); + let mut changed = base; changed.field_types = vec![perry_hir::types::Type::String]; assert_ne!(base_key, key_for(changed)); @@ -716,7 +724,6 @@ fn key_changes_with_codegen_env_vars() { // let opts = empty_opts(); for var in [ - "PERRY_DEBUG_INIT", "PERRY_DEBUG_SYMBOLS", "PERRY_LLVM_CLANG", "PERRY_LLVM_INPROCESS", @@ -788,6 +795,22 @@ fn key_changes_with_codegen_env_vars() { } } +#[test] +fn debug_init_only_invalidates_the_entry_object() { + let key = |opts: &CompileOptions, enabled: bool| { + compute_object_cache_key_with_env(opts, 1, "0.5.156", |name| { + (enabled && name == "PERRY_DEBUG_INIT").then(|| "1".to_string()) + }) + }; + + let mut entry_opts = empty_opts(); + entry_opts.is_entry_module = true; + assert_ne!(key(&entry_opts, false), key(&entry_opts, true)); + + let dependency_opts = empty_opts(); + assert_eq!(key(&dependency_opts, false), key(&dependency_opts, true)); +} + /// #6439: the FFI manifest must survive a store→lookup round trip, or a /// warm cache silently drops the provider crates the link line needs. #[test] diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index 5ee4491b5b..f56a8295e4 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -265,6 +265,7 @@ fn imported_class_from_hir( perry_codegen::ImportedClass { name: class.name.clone(), local_alias, + namespace: None, source_prefix, constructor_param_count: class .constructor @@ -403,6 +404,7 @@ fn imported_object_literal_from_capability( perry_codegen::ImportedClass { name: capability.class_name.clone(), local_alias: Some(receiver_class_name.clone()), + namespace: None, source_prefix: source_prefix.clone(), constructor_param_count: capability.field_names.len(), has_own_constructor: true, @@ -3217,7 +3219,11 @@ pub fn run_with_parse_cache( // `is_deferred_require`: a function-local `require('S')` // (lazy in Node). S must NOT chain into this module's init // — it inits only when the require shim is actually called. - if import.is_dynamic || import.type_only || import.is_deferred_require { + if import.is_dynamic + || import.type_only + || import.runtime_erased + || import.is_deferred_require + { continue; } if let Some(resolved) = &import.resolved_path { @@ -3418,7 +3424,7 @@ pub fn run_with_parse_cache( // value specifiers; the whole-decl flag is the one that // makes the entire import a no-op. for import in &hir_module.imports { - if import.type_only { + if import.type_only || import.runtime_erased { continue; } for spec in &import.specifiers { @@ -3456,7 +3462,7 @@ pub fn run_with_parse_cache( // function/namespace maps, imported vars, native libraries, // or module-init dependencies: those were the collision and // phantom-load hazards #684 removed. - if import.type_only { + if import.type_only || import.runtime_erased { for spec in &import.specifiers { let perry_hir::ImportSpecifier::Named { imported, local } = spec else { continue; @@ -3761,14 +3767,20 @@ pub fn run_with_parse_cache( ); let key = (origin_path.clone(), export_name.clone()); + let scoped_func_key = + perry_codegen::namespace_member_func_key(local, export_name); if let Some(¶m_count) = exported_func_param_counts.get(&key) { imported_param_counts.insert(export_name.clone(), param_count); + imported_param_counts + .insert(scoped_func_key.clone(), param_count); } if exported_func_has_rest.get(&key).copied().unwrap_or(false) { imported_has_rest.insert(export_name.clone()); + imported_has_rest.insert(scoped_func_key.clone()); } if exported_func_synthetic_arguments.contains(&key) { imported_synthetic_arguments.insert(export_name.clone()); + imported_synthetic_arguments.insert(scoped_func_key); } // Issue #636: namespace-imported vars must // route through the zero-arg getter at @@ -3781,7 +3793,10 @@ pub fn run_with_parse_cache( // as the call result instead of invoking // the closure with `args`. Mirrors the // named-import branch at the var-detection - // arm below. + // arm below. The key is namespace-qualified: + // a flat `make` entry lets one namespace's + // variable export misclassify another + // namespace's declared function as a getter. // // Issue #4841: when the namespace member is a // re-export of a CJS submodule's `default` @@ -3806,7 +3821,10 @@ pub fn run_with_parse_cache( .map(|k| exported_var_names.contains(k)) .unwrap_or(false) { - imported_vars.insert(export_name.clone()); + imported_vars.insert(perry_codegen::namespace_member_var_key( + local, + export_name, + )); } if let Some(class) = exported_classes.get(&key) { let class_prefix = canonical_class_source_prefix( @@ -3820,7 +3838,7 @@ pub fn run_with_parse_cache( } else { Some(export_name.clone()) }; - imported_classes.push(imported_class_from_hir( + let mut imported_class = imported_class_from_hir( class, class_prefix, local_alias, @@ -3832,7 +3850,14 @@ pub fn run_with_parse_cache( class, &class_proven_this_tower_methods, ), - )); + ); + // A namespace member is reachable only as + // `local.export_name`; it is not a lexical + // binding named `export_name`. Keeping that + // ownership explicit prevents same-named + // classes from replacing direct imports. + imported_class.namespace = Some(local.clone()); + imported_classes.push(imported_class); } if let Some(members) = exported_enums.get(&key) { imported_enums.push((export_name.clone(), members.clone())); @@ -3896,7 +3921,9 @@ pub fn run_with_parse_cache( .entry(member.to_string()) .or_insert_with(|| "default".to_string()); if member == "module.exports" || default_is_var { - imported_vars.insert(member.to_string()); + imported_vars.insert(perry_codegen::namespace_member_var_key( + local, member, + )); } } } @@ -4227,23 +4254,33 @@ pub fn run_with_parse_cache( ); let key = (origin_path.clone(), export_name.clone()); + let scoped_func_key = + perry_codegen::namespace_member_func_key( + &local_name, + export_name, + ); if let Some(¶m_count) = exported_func_param_counts.get(&key) { imported_param_counts .insert(export_name.clone(), param_count); + imported_param_counts + .insert(scoped_func_key.clone(), param_count); } if exported_func_has_rest.get(&key).copied().unwrap_or(false) { imported_has_rest.insert(export_name.clone()); + imported_has_rest.insert(scoped_func_key.clone()); } if exported_func_synthetic_arguments.contains(&key) { imported_synthetic_arguments.insert(export_name.clone()); + imported_synthetic_arguments.insert(scoped_func_key); } // Issue #321: NamespaceReExport members // that are var-shaped exports (the // canonical `export const succeed = (v) => // ...` shape in effect/Effect.ts and // co-equivalent re-export hubs) must land - // in `imported_vars` so the codegen's + // in `imported_vars` under a namespace- + // qualified key so the codegen's // StaticMethodCall and namespace-member // call sites route through the zero-arg // getter + `js_closure_callN`. Without @@ -4266,7 +4303,12 @@ pub fn run_with_parse_cache( .map(|key| exported_var_names.contains(key)) .unwrap_or(false) { - imported_vars.insert(export_name.clone()); + imported_vars.insert( + perry_codegen::namespace_member_var_key( + &local_name, + export_name, + ), + ); } if let Some(class) = exported_classes.get(&key) { let class_prefix = canonical_class_source_prefix( @@ -4280,7 +4322,7 @@ pub fn run_with_parse_cache( } else { Some(export_name.clone()) }; - imported_classes.push(imported_class_from_hir( + let mut imported_class = imported_class_from_hir( class, class_prefix, local_alias, @@ -4292,7 +4334,9 @@ pub fn run_with_parse_cache( class, &class_proven_this_tower_methods, ), - )); + ); + imported_class.namespace = Some(local_name.clone()); + imported_classes.push(imported_class); } if let Some(members) = exported_enums.get(&key) { imported_enums.push((export_name.clone(), members.clone())); @@ -4757,7 +4801,7 @@ pub fn run_with_parse_cache( // a `perry_fn_...` symbol because every codegen site probes // `import_function_v8_specifiers` first. for import in &hir_module.imports { - if import.type_only { + if import.type_only || import.runtime_erased { continue; } if import.module_kind != perry_hir::ModuleKind::Interpreted { @@ -4864,7 +4908,7 @@ pub fn run_with_parse_cache( // (`collect_modules.rs::known_node_submodule_key`); they // now flow through and land here. for import in &hir_module.imports { - if import.type_only { + if import.type_only || import.runtime_erased { continue; } let submod_key = match self::collect_modules::known_node_submodule_key(&import.source) { diff --git a/crates/perry/src/commands/compile/strip_dedup.rs b/crates/perry/src/commands/compile/strip_dedup.rs index d0916c1bf7..a30385b6ba 100644 --- a/crates/perry/src/commands/compile/strip_dedup.rs +++ b/crates/perry/src/commands/compile/strip_dedup.rs @@ -1072,6 +1072,10 @@ fn rebuild_archive( pub(super) fn strip_duplicate_objects_from_well_known_lib(lib_path: &PathBuf) -> Result { let lib_name = lib_path.file_name().and_then(|f| f.to_str()).unwrap_or("?"); + let is_coff = lib_path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("lib")); eprintln!( "[strip-dedup] Processing well-known wrapper: {}", lib_path.display() @@ -1162,44 +1166,64 @@ pub(super) fn strip_duplicate_objects_from_well_known_lib(lib_path: &PathBuf) -> return Err(anyhow::anyhow!("failed to extract {lib_name}: {stderr}")); } - for (member, symbols) in &forced_symbols_by_member { - let member_path = extract_dir.join(member); + let archive_tag: String = lib_name + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() { + character + } else { + '_' + } + }) + .collect(); + for (member_index, (member, symbols)) in forced_symbols_by_member.iter().enumerate() { + let member_path = extracted_archive_member(&extract_dir, member) + .ok_or_else(|| anyhow::anyhow!("failed to locate extracted archive member {member}"))?; // On ELF, localizing the panic/unwind personality symbols (including the // compiler-emitted `DW.ref.rust_eh_personality`) breaks PIE relocations // → "undefined hidden symbol ... can not be used when making a PIE // object". That dedup is only needed for tier-3 Mach-O (-Zbuild-std); // skip it for ELF members and keep localizing the allocator shims. let skip_panic_unwind = object_is_elf(&member_path); - for symbol in symbols { + for (symbol_index, symbol) in symbols.iter().enumerate() { if skip_panic_unwind && is_panic_unwind_symbol(symbol) { continue; } - let out = Command::new(&objcopy) - .arg("--localize-symbol") - .arg(symbol) - .arg(&member_path) - .output()?; + // LLVM objcopy does not implement --localize-symbol for COFF, and + // --strip-symbol rejects definitions mentioned by relocations + // (including their own debug records). Rename the wrapper copy + // instead: objcopy updates those relocations, the new name is + // unique across archives, and unresolved sibling references keep + // binding to the canonical runtime / stdlib definition. + let mut command = Command::new(&objcopy); + if is_coff { + let renamed = + format!("__perry_wrapper_local_{archive_tag}_{member_index}_{symbol_index}"); + command + .arg("--redefine-sym") + .arg(format!("{symbol}={renamed}")); + } else { + command.arg("--localize-symbol").arg(symbol); + } + let out = command.arg(&member_path).output()?; if !out.status.success() { let stderr = String::from_utf8_lossy(&out.stderr); return Err(anyhow::anyhow!( - "failed to localize {symbol} in {member}: {stderr}" + "failed to rewrite {symbol} in {member}: {stderr}" )); } } } - let mut ar_cmd = Command::new(&llvm_ar); - ar_cmd.arg("crs").arg(&trimmed_lib); - for member in &members { - ar_cmd.arg(extract_dir.join(member)); - } - let ar_out = ar_cmd.output()?; - if !ar_out.status.success() { - let stderr = String::from_utf8_lossy(&ar_out.stderr); - return Err(anyhow::anyhow!( - "failed to create well-known wrapper archive for {lib_name}: {stderr}" - )); - } + let member_paths: Vec = members + .iter() + .map(|member| { + extracted_archive_member(&extract_dir, member).ok_or_else(|| { + anyhow::anyhow!("failed to locate extracted archive member {member}") + }) + }) + .collect::>()?; + rebuild_archive(&llvm_ar, &trimmed_lib, &member_paths, is_coff)?; eprintln!( "[strip-dedup] {lib_name}: localized wrapper-only globals in {} member(s)", diff --git a/crates/perry/src/commands/compile/strip_dedup/strip_dedup_tests.rs b/crates/perry/src/commands/compile/strip_dedup/strip_dedup_tests.rs index 3b46e44e17..76d73f35cb 100644 --- a/crates/perry/src/commands/compile/strip_dedup/strip_dedup_tests.rs +++ b/crates/perry/src/commands/compile/strip_dedup/strip_dedup_tests.rs @@ -393,6 +393,67 @@ fn coff_archive_dedup_drops_only_fully_provided_members() { assert!(!symbols.contains("runtime_canonical")); } +#[cfg(target_os = "windows")] +#[test] +fn coff_well_known_wrapper_strips_forced_symbols() { + use super::{ + collect_archive_symbols_flat, find_llvm_tool_or_beside_lld, rebuild_archive, + strip_duplicate_objects_from_well_known_lib, + }; + use std::path::Path; + use std::process::Command; + + fn compile_object(source: &Path, output: &Path) { + let rustc = std::env::var_os("RUSTC") + .map(std::path::PathBuf::from) + .or_else(|| { + std::env::var_os("CARGO") + .map(std::path::PathBuf::from) + .and_then(|cargo| cargo.parent().map(|dir| dir.join("rustc"))) + .filter(|candidate| candidate.exists()) + }) + .unwrap_or_else(|| std::path::PathBuf::from("rustc")); + let result = Command::new(rustc) + .arg("--crate-name") + .arg("well_known_wrapper_fixture") + .arg("--crate-type=lib") + .arg("--emit=obj") + .arg("-Cpanic=abort") + .arg(source) + .arg("-o") + .arg(output) + .output() + .expect("rustc must run"); + assert!( + result.status.success(), + "rustc failed: {}", + String::from_utf8_lossy(&result.stderr) + ); + } + + let temp = tempfile::tempdir().expect("temporary COFF wrapper fixture directory"); + let source = temp.path().join("wrapper.rs"); + std::fs::write( + &source, + "#[no_mangle]\npub extern \"C\" fn __rust_alloc() {}\n\ + #[no_mangle]\npub extern \"C\" fn wrapper_entry() { __rust_alloc(); }\n", + ) + .unwrap(); + let object = temp.path().join("wrapper.obj"); + compile_object(&source, &object); + + let llvm_ar = find_llvm_tool_or_beside_lld("llvm-ar").expect("llvm-ar present"); + let llvm_nm = find_llvm_tool_or_beside_lld("llvm-nm").expect("llvm-nm present"); + let wrapper = temp.path().join("perry_ext_fixture.lib"); + rebuild_archive(&llvm_ar, &wrapper, std::slice::from_ref(&object), true).unwrap(); + + let rewritten = strip_duplicate_objects_from_well_known_lib(&wrapper) + .expect("COFF well-known symbol rewrite must succeed"); + let symbols = collect_archive_symbols_flat(&llvm_nm, &rewritten); + assert!(symbols.contains("wrapper_entry")); + assert!(!symbols.contains("__rust_alloc")); +} + /// #8455: the dedup evidence set must equal the archives actually on the /// link line. A member whose symbols are provided ONLY by perry-stdlib /// must be dropped when stdlib is linked and KEPT when it is not — before diff --git a/crates/perry/tests/class_inherited_computed_static_in.rs b/crates/perry/tests/class_inherited_computed_static_in.rs new file mode 100644 index 0000000000..1631d616b3 --- /dev/null +++ b/crates/perry/tests/class_inherited_computed_static_in.rs @@ -0,0 +1,74 @@ +//! Regression for Effect Schema's `isSchema` predicate. Effect brands the +//! class object returned by a factory with a computed string static, then asks +//! whether that key exists on a declared subclass through generic `key in u`. +//! Static reads already inherited the value; generic `in` incorrectly reported +//! false because the ClassRef path only checked own dynamic data fields. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +#[test] +fn generic_in_finds_computed_string_static_on_class_expression_parent() { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.ts"); + let output = dir.path().join("main_bin"); + std::fs::write( + &entry, + r#" +const TypeId = "~effect/Schema/Schema"; + +function makeClass() { + return class { + static readonly [TypeId] = TypeId; + static readonly presentButUndefined = undefined; + }; +} + +const Base = makeClass(); +class Derived extends Base {} + +function hasProperty(value: unknown, key: PropertyKey) { + return (typeof value === "object" && value !== null || typeof value === "function") + && key in value; +} + +console.log(hasProperty(Derived, TypeId)); +console.log((Derived as any)[TypeId] === TypeId); +console.log(hasProperty(Derived, "presentButUndefined")); +"#, + ) + .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) + ); + assert_eq!(String::from_utf8_lossy(&run.stdout), "true\ntrue\ntrue\n"); +} diff --git a/crates/perry/tests/issue_5763_setprototypeof_chain_end.rs b/crates/perry/tests/issue_5763_setprototypeof_chain_end.rs index d5cde53b5d..63a84c9286 100644 --- a/crates/perry/tests/issue_5763_setprototypeof_chain_end.rs +++ b/crates/perry/tests/issue_5763_setprototypeof_chain_end.rs @@ -126,6 +126,38 @@ console.log("statics linked"); ); } +/// Perry represents a declared class as an INT32 ClassRef, not as the heap +/// function object used for an ordinary function declaration. The ClassRef +/// therefore needs its own static-prototype recording path. Effect's +/// `Schema.Opaque` uses this pattern and subclasses must inherit the schema +/// object's `ast` field through the generated base class. +#[test] +fn set_prototype_of_class_ref_links_static_object_properties() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +const schema = { ast: { marker: "schema-ast" } }; + +function opaque() { + class Opaque {} + return Object.setPrototypeOf(Opaque, schema); +} + +const Opaque = opaque(); +class PartialRequest extends Opaque {} +console.log(PartialRequest.ast.marker); + +Object.setPrototypeOf(Opaque, null); +console.log(PartialRequest.ast); +"#, + ); + assert_eq!( + stdout, "schema-ast\nundefined\n", + "ClassRef Object.setPrototypeOf must link and clear inherited static object fields" + ); +} + /// Bug 2 guard: comment-json's `__extends` feature test. The object-literal /// `{ __proto__: [] }` routes through the same cycle walk with an exotic /// receiver whose getPrototypeOf reports undefined mid-walk; undefined must diff --git a/crates/perry/tests/issue_5951_class_capture_shared_mutable.rs b/crates/perry/tests/issue_5951_class_capture_shared_mutable.rs index 00210ec35c..6aa8203325 100644 --- a/crates/perry/tests/issue_5951_class_capture_shared_mutable.rs +++ b/crates/perry/tests/issue_5951_class_capture_shared_mutable.rs @@ -163,3 +163,40 @@ fn shared_mutable_method_writes() { ); assert_eq!(out, "1 2\n"); } + +/// A top-level arrow lives as a closure inside the module initializer rather +/// than in `module.functions`. Its optional parameter's synthesized default +/// assignment makes the class-capture pass conservatively treat the binding as +/// mutable. The pass must create the same one-element cell at closure entry +/// before rewriting reads to `options[0]`. +#[test] +fn nested_arrow_optional_parameter_capture_is_boxed_at_entry() { + let dir = tempfile::tempdir().expect("tempdir"); + let out = run( + dir.path(), + r#" +const make = ( + tag: string, + options?: { + success?: number + primaryKey?: (value: object) => string + } +) => { + const success = options?.success ?? 7 + let payload: unknown + if (options?.primaryKey) { + payload = class Payload { + key(): string { + return options.primaryKey!({}) + } + } + } + return tag + ":" + success +} + +console.log(make("Ping")) +console.log(make("Pong", { success: 3 })) +"#, + ); + assert_eq!(out, "Ping:7\nPong:3\n"); +} diff --git a/crates/perry/tests/issue_6074_rest_dispatch.rs b/crates/perry/tests/issue_6074_rest_dispatch.rs index 2220e90990..d95a5a4102 100644 --- a/crates/perry/tests/issue_6074_rest_dispatch.rs +++ b/crates/perry/tests/issue_6074_rest_dispatch.rs @@ -101,3 +101,33 @@ console.log("method15", obj.f(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, ); assert_eq!(stdout, "direct15 26\nmethod15 26\n"); } + +/// y18n exposes its translator as a bound instance method and deliberately +/// uses both parameter views: `...args` for mutation and `arguments[0]` for +/// overload selection. HIR therefore gives the method two trailing array +/// slots. Runtime vtable dispatch must populate both in declaration order. +#[test] +fn bound_method_with_user_rest_and_arguments_receives_two_arrays() { + let stdout = compile_and_run( + r#" +class Translator { + __(...parts: any[]) { + const firstFromArguments = arguments[0]; + const firstFromRest = parts.shift(); + console.log( + Array.isArray(parts), + firstFromRest, + parts.length, + arguments.length, + firstFromArguments, + ); + } +} + +const translator = new Translator(); +const bound: any = translator.__.bind(translator); +bound("Positionals:", "tail"); +"#, + ); + assert_eq!(stdout, "true Positionals: 1 2 Positionals:\n"); +} diff --git a/crates/perry/tests/module_forward_class_expression.rs b/crates/perry/tests/module_forward_class_expression.rs new file mode 100644 index 0000000000..01d4290d70 --- /dev/null +++ b/crates/perry/tests/module_forward_class_expression.rs @@ -0,0 +1,116 @@ +//! Regression coverage for forward references between module-level class +//! expression bindings. Dependency builds commonly contain this emitted ESM +//! shape (`var A = class { ... new B() ... }; var B = class { ... }`). + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +#[test] +fn method_resolves_later_module_class_expression_binding() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + std::fs::write( + root.join("main.ts"), + r#" +var Builder = class { + build() { + return new Later(42); + } +}; + +var Later = class { + value: number; + constructor(value: number) { + this.value = value; + } +}; + +console.log("value=" + new Builder().build().value); +"#, + ) + .expect("write source"); + + let output = root.join("main_bin"); + let compile = Command::new(perry_bin()) + .current_dir(root) + .arg("compile") + .arg(root.join("main.ts")) + .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).output().expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + assert_eq!(String::from_utf8_lossy(&run.stdout), "value=42\n"); +} + +#[test] +fn implicit_derived_class_expression_initializes_parent_before_own_fields() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + std::fs::write( + root.join("main.ts"), + r#" +var Base = class { + config: { mode: string }; + constructor(_table: unknown, config: { mode: string }) { + this.config = config; + } +}; + +var Middle = class extends Base {}; + +var Child = class extends Middle { + mode = this.config.mode; +}; + +console.log("mode=" + new Child({}, { mode: "boolean" }).mode); +"#, + ) + .expect("write source"); + + let output = root.join("main_bin"); + let compile = Command::new(perry_bin()) + .current_dir(root) + .arg("compile") + .arg(root.join("main.ts")) + .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).output().expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + assert_eq!(String::from_utf8_lossy(&run.stdout), "mode=boolean\n"); +} diff --git a/crates/perry/tests/namespace_variable_export_abi.rs b/crates/perry/tests/namespace_variable_export_abi.rs index 3d2eb854b3..fde5ebcde8 100644 --- a/crates/perry/tests/namespace_variable_export_abi.rs +++ b/crates/perry/tests/namespace_variable_export_abi.rs @@ -130,3 +130,296 @@ console.log(CJS.make("seven")); "make:one\nalso:two\ndeclared:three\ndefault:four\nmake:five\nb:six:a\nready\ncjs:seven\n" ); } + +/// A variable export in one namespace must not make an equal-named declared +/// function in another namespace use the variable getter ABI. Effect's +/// `Array.ts` imports many namespace modules that export `make`; before this +/// regression was fixed, `Reducer.make` evaluated to the object returned by an +/// accidental zero-argument invocation rather than to the function itself. +#[test] +fn namespace_variable_classification_is_scoped_to_the_namespace() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + + std::fs::write( + root.join("declared.ts"), + r#" +export function make(value: string) { + return "declared:" + value; +} +"#, + ) + .expect("write declared module"); + std::fs::write( + root.join("variable.ts"), + r#" +export const make = (value: string) => "variable:" + value; +"#, + ) + .expect("write variable module"); + std::fs::write( + root.join("main.ts"), + r#" +import * as Declared from "./declared.ts"; +import * as Variable from "./variable.ts"; +import * as declared from "./declared.ts"; +import * as variable from "./variable.ts"; + +console.log("upper-declared-type:", typeof Declared.make); +console.log("upper-declared-call:", Declared.make("one")); +console.log("upper-variable-type:", typeof Variable.make); +console.log("upper-variable-call:", Variable.make("two")); +console.log("lower-declared-type:", typeof declared.make); +console.log("lower-declared-call:", declared.make("three")); +console.log("lower-variable-type:", typeof variable.make); +console.log("lower-variable-call:", variable.make("four")); +"#, + ) + .expect("write entry"); + + let output = root.join("collision_bin"); + let compile = Command::new(perry_bin()) + .current_dir(root) + .arg("compile") + .arg(root.join("main.ts")) + .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).output().expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&run.stdout), + concat!( + "upper-declared-type: function\n", + "upper-declared-call: declared:one\n", + "upper-variable-type: function\n", + "upper-variable-call: variable:two\n", + "lower-declared-type: function\n", + "lower-declared-call: declared:three\n", + "lower-variable-type: function\n", + "lower-variable-call: variable:four\n", + ) + ); +} + +/// A class in one namespace must not make an equal-named value in another +/// namespace lower as that class. Effect exercises this with the +/// `SchemaAST.Boolean` class and the `Schema.Boolean` schema object. +#[test] +fn namespace_class_classification_is_scoped_to_the_namespace() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + + std::fs::write( + root.join("classes.ts"), + r#" +export class Token {} +"#, + ) + .expect("write class module"); + std::fs::write( + root.join("values.ts"), + r#" +export const Token = { encoding: "value-token" }; +"#, + ) + .expect("write value module"); + std::fs::write( + root.join("main.ts"), + r#" +import * as Classes from "./classes.ts"; +import * as Values from "./values.ts"; + +console.log("class-type:", typeof Classes.Token); +console.log("value-field:", Values.Token.encoding); +"#, + ) + .expect("write entry"); + + let output = root.join("class_collision_bin"); + let compile = Command::new(perry_bin()) + .current_dir(root) + .arg("compile") + .arg(root.join("main.ts")) + .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).output().expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&run.stdout), + concat!("class-type: function\n", "value-field: value-token\n",) + ); +} + +/// A namespace binding may have the same name as a class exported by that +/// namespace. The binding itself must remain a namespace: `Sharding.layer` +/// reads the exported variable, rather than looking for a static `layer` +/// property on the exported `Sharding` class. +#[test] +fn namespace_binding_is_not_replaced_by_an_equal_named_exported_class() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + + std::fs::write( + root.join("sharding.ts"), + r#" +export class Sharding {} +export const layer: { pipe: (value: string) => string } = { + pipe: (value: string) => "layer:" + value +}; +"#, + ) + .expect("write sharding module"); + std::fs::write( + root.join("main.ts"), + r#" +import * as Sharding from "./sharding.ts"; + +console.log("class-type:", typeof Sharding.Sharding); +console.log(Sharding.layer.pipe("ok")); +"#, + ) + .expect("write entry"); + + let output = root.join("namespace_class_name_collision_bin"); + let compile = Command::new(perry_bin()) + .current_dir(root) + .arg("compile") + .arg(root.join("main.ts")) + .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).output().expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&run.stdout), + "class-type: function\nlayer:ok\n" + ); +} + +/// Function ABI metadata is namespace-local too. A rest export in one +/// namespace must not make an equal-named ordinary function in another bundle +/// its arguments into a synthetic rest array. Effect exercises this with +/// fast-check's `tuple(...arbs)` and `SchemaAST.tuple(elements, checks)`. +#[test] +fn namespace_function_abi_is_scoped_to_the_namespace() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + + std::fs::write( + root.join("plain.ts"), + r#" +export function tuple(elements: Array, checks: string | undefined = undefined) { + return "plain:" + elements.length + ":" + checks; +} +"#, + ) + .expect("write plain module"); + std::fs::write( + root.join("rest.ts"), + r#" +export function tuple(...values: Array) { + return "rest:" + values.length; +} +"#, + ) + .expect("write rest module"); + std::fs::write( + root.join("barrel.ts"), + r#" +export * as Plain from "./plain.ts"; +export * as Rest from "./rest.ts"; +"#, + ) + .expect("write barrel module"); + std::fs::write( + root.join("main.ts"), + r#" +import * as Plain from "./plain.ts"; +import * as Rest from "./rest.ts"; +import { Plain as BarrelPlain, Rest as BarrelRest } from "./barrel.ts"; + +console.log(Plain.tuple(["a", "b"], "checked")); +console.log(Rest.tuple("a", "b", "c")); +console.log(BarrelPlain.tuple(["a", "b"], "checked")); +console.log(BarrelRest.tuple("a", "b", "c")); +"#, + ) + .expect("write entry"); + + let output = root.join("function_abi_collision_bin"); + let compile = Command::new(perry_bin()) + .current_dir(root) + .arg("compile") + .arg(root.join("main.ts")) + .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).output().expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&run.stdout), + "plain:2:checked\nrest:3\nplain:2:checked\nrest:3\n" + ); +} diff --git a/crates/perry/tests/source_graph_export_regressions.rs b/crates/perry/tests/source_graph_export_regressions.rs index 7338da391c..1a5789d142 100644 --- a/crates/perry/tests/source_graph_export_regressions.rs +++ b/crates/perry/tests/source_graph_export_regressions.rs @@ -668,6 +668,59 @@ fn self_namespace_reexport_is_not_treated_as_a_function() { assert_eq!(compile_and_run(dir.path(), "main.ts"), "42 3 3\n"); } +#[test] +fn named_self_namespace_import_is_hoisted_before_source_use() { + let dir = tempfile::tempdir().expect("tempdir"); + write( + dir.path(), + "layer-node.ts", + "export function make(value: number) { return value; }\n\ + export * as LayerNode from './layer-node';\n", + ); + write( + dir.path(), + "main.ts", + "export const node = LayerNode.make(42);\n\ + import { LayerNode } from './layer-node';\n\ + console.log(node);\n", + ); + + assert_eq!(compile_and_run(dir.path(), "main.ts"), "42\n"); +} + +#[test] +fn dynamic_import_does_not_reverse_static_eager_init_order() { + let dir = tempfile::tempdir().expect("tempdir"); + write( + dir.path(), + "plugin.ts", + "export const node = { name: 'plugin' };\n\ + export const loadServer = () => import('./server');\n\ + export * as Plugin from './plugin';\n", + ); + write( + dir.path(), + "provider-auth.ts", + "import { Plugin } from './plugin';\n\ + export const providerAuth = { dependency: Plugin.node };\n", + ); + write( + dir.path(), + "server.ts", + "import { providerAuth } from './provider-auth';\n\ + export const server = providerAuth;\n", + ); + write( + dir.path(), + "main.ts", + "import { Plugin } from './plugin';\n\ + import { providerAuth } from './provider-auth';\n\ + console.log(Plugin.node.name, providerAuth.dependency.name);\n", + ); + + assert_eq!(compile_and_run(dir.path(), "main.ts"), "plugin plugin\n"); +} + #[test] fn materialized_namespace_keeps_nested_namespace_exports() { let dir = tempfile::tempdir().expect("tempdir"); @@ -791,3 +844,97 @@ fn imported_class_reexport_uses_the_defining_constructor() { assert_eq!(compile_and_run(dir.path(), "main.ts"), "42\n"); } + +/// OpenCode's bootstrap module imports the `Plugin` namespace before directly +/// importing its own class named `Service`. A namespace member is not a bare +/// lexical binding: `Plugin.Service` must not replace that direct `Service`. +#[test] +fn namespace_class_does_not_replace_equal_named_direct_import() { + let dir = tempfile::tempdir().expect("tempdir"); + write( + dir.path(), + "plugin.ts", + "export class Service {\n\ + static identify() { return 'Plugin'; }\n\ + }\n\ + export const node = { name: Service.identify() };\n\ + export * as Plugin from './plugin';\n", + ); + write( + dir.path(), + "bootstrap-service.ts", + "export class Service {\n\ + static identify() { return 'InstanceBootstrap'; }\n\ + }\n", + ); + write( + dir.path(), + "bootstrap.ts", + "import { Plugin } from './plugin';\n\ + import { Service } from './bootstrap-service';\n\ + export const node = { name: Service.identify(), dependency: Plugin.node };\n\ + export * as InstanceBootstrap from './bootstrap';\n", + ); + write( + dir.path(), + "main.ts", + "import { Plugin } from './plugin';\n\ + import { InstanceBootstrap } from './bootstrap';\n\ + console.log(InstanceBootstrap.node.name);\n\ + console.log(InstanceBootstrap.node.dependency.name);\n\ + console.log(Plugin.Service.identify());\n", + ); + + assert_eq!( + compile_and_run(dir.path(), "main.ts"), + "InstanceBootstrap\nPlugin\nPlugin\n" + ); +} + +/// OpenCode's main TUI command imports its worker RPC shape with +/// `import { type rpc }`. That spelling must not execute the worker module in +/// the main process (where worker-only globals such as `onmessage` do not +/// exist). +#[test] +fn per_specifier_type_only_import_does_not_initialize_source_module() { + let dir = tempfile::tempdir().expect("tempdir"); + write( + dir.path(), + "worker.ts", + "console.log('worker initialized');\n\ + export interface RpcShape { answer: number; }\n", + ); + write( + dir.path(), + "main.ts", + "import { type RpcShape } from './worker';\n\ + const value: RpcShape = { answer: 42 };\n\ + console.log(value.answer);\n", + ); + + assert_eq!(compile_and_run(dir.path(), "main.ts"), "42\n"); +} + +#[test] +fn mixed_type_and_value_specifier_import_keeps_runtime_edge() { + let dir = tempfile::tempdir().expect("tempdir"); + write( + dir.path(), + "dependency.ts", + "console.log('dependency initialized');\n\ + export interface Shape { answer: number; }\n\ + export const answer = 42;\n", + ); + write( + dir.path(), + "main.ts", + "import { type Shape, answer } from './dependency';\n\ + const value: Shape = { answer };\n\ + console.log(value.answer);\n", + ); + + assert_eq!( + compile_and_run(dir.path(), "main.ts"), + "dependency initialized\n42\n" + ); +} From 8e5643884db1bae1046b106c0b238367abab4b73 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sun, 30 Aug 2026 07:09:52 +0200 Subject: [PATCH 2/6] chore: add changelog fragment for #9133 --- changelog.d/9133-opencode-source-compat.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/9133-opencode-source-compat.md diff --git a/changelog.d/9133-opencode-source-compat.md b/changelog.d/9133-opencode-source-compat.md new file mode 100644 index 0000000000..9ae7bd0bca --- /dev/null +++ b/changelog.d/9133-opencode-source-compat.md @@ -0,0 +1,3 @@ +### Fixed + +- Full-source OpenCode builds now preserve namespace and type-only import semantics, class-expression and closure initialization, iterator/class runtime behavior, and Windows archive linking across the complete TypeScript dependency graph instead of requiring a pre-bundled JavaScript input. From 15887807ed23dd225337eb5a38992c0f976bdcf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 30 Aug 2026 13:01:20 +0200 Subject: [PATCH 3/6] fix(compile): detect archive format from object bytes, not the file name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The well-known-wrapper strip-dedup path decided COFF from the archive's `.lib` extension. It is handed perry's own intermediate, which `strip_duplicate_objects_from_no_shared_deps` names `__nosharedeps.lib` on EVERY host — so macOS and Linux took the Windows branch, `llvm-ar --format=coff` wrote a GNU-style symbol table, and `ld` rejected the archive: ld: archive member '/' not a mach-o file in _libperry_ext_ws.a_nosharedeps.lib Read the container from an extracted member's magic bytes instead (`object_format::object_is_coff`), so `--redefine-sym` / `--format=coff` stay Windows-only. Full OpenCode source-graph build now links on macOS: 2,954 modules, 0 JavaScript fallbacks, 235 MB executable. Also in this commit: - Add the missing `runtime_erased` field to five `Import` literals in perry-transform's tests; the crate's lib tests did not compile, which red-lined cargo-test and fail-fast cancelled check / e2e-scoped / gc-stress-build / gap-suite-build before they ran. - Split five files back under the 2,000-line gate: error.rs, property_get.rs, module_decl.rs, cjs_wrap/tests.rs and strip_dedup.rs. - Guard the new ClassRef `Object.setPrototypeOf` store with `is_above_handle_band` + `try_read_gc_header` before `is_valid_obj_ptr`. `proto` is user-supplied and lands in a GC root table the collector later dereferences; a bare check accepts the fetch/zlib/proxy handle bands on Linux. Clears the addr-class ratchet. - Record the `test_only` verdict for TEST_COLLECT_BEFORE_GLOBAL_THIS_ALLOC in the GC root-holder inventory. Verified: cargo fmt --check, cargo check --all-targets, file-size gate, addr-class audit and gc_runtime_root_holders all clean; perry-transform 121, perry-hir 364, perry-runtime 2,825, perry bin 1,068 tests pass. Claude-Session: https://claude.ai/code/session_01P3bPE5eJQT4vQ6wf8P9JDN --- crates/perry-codegen/src/expr/property_get.rs | 15 +- .../src/expr/property_get/helpers.rs | 17 +++ crates/perry-hir/src/lower/module_decl.rs | 86 +---------- .../module_decl/static_import_bindings.rs | 95 ++++++++++++ crates/perry-runtime/src/error.rs | 134 +---------------- .../perry-runtime/src/error_tostring_tests.rs | 136 ++++++++++++++++++ .../object/object_ops/define_properties.rs | 10 ++ crates/perry-transform/src/inline/mod.rs | 5 + .../src/commands/compile/cjs_wrap/tests.rs | 28 +--- .../compile/cjs_wrap/tests/hoist_scanner.rs | 31 ++++ .../perry/src/commands/compile/strip_dedup.rs | 32 ++--- .../compile/strip_dedup/object_format.rs | 64 +++++++++ scripts/gc_runtime_root_holders.json | 6 + 13 files changed, 388 insertions(+), 271 deletions(-) create mode 100644 crates/perry-hir/src/lower/module_decl/static_import_bindings.rs create mode 100644 crates/perry-runtime/src/error_tostring_tests.rs create mode 100644 crates/perry/src/commands/compile/cjs_wrap/tests/hoist_scanner.rs create mode 100644 crates/perry/src/commands/compile/strip_dedup/object_format.rs diff --git a/crates/perry-codegen/src/expr/property_get.rs b/crates/perry-codegen/src/expr/property_get.rs index 0ee91e88e3..2493d53cdc 100644 --- a/crates/perry-codegen/src/expr/property_get.rs +++ b/crates/perry-codegen/src/expr/property_get.rs @@ -55,6 +55,7 @@ mod tests; pub(crate) use generic_dispatch::lower_generic_property_get; pub(crate) use globalget::lower_globalget_property; +use helpers::guarded_declared_class_get_candidate; pub(crate) use helpers::{ builtin_prototype_method_read, class_has_computed_runtime_members, is_global_builtin_value_expr, lower_class_method_bind, lower_global_builtin_static_value, @@ -69,20 +70,6 @@ use super::{ TypedFeedbackContract, TypedFeedbackKind, }; -/// A declared class may nominate the guarded field/method route, but never a -/// raw load by itself. Every field consumer below checks the live receiver's -/// class id and keys token before dereferencing; method-value/runtime-member -/// helpers retain their dynamic fallback semantics. -fn guarded_declared_class_get_candidate(ctx: &FnCtx<'_>, object: &Expr) -> Option { - let Expr::LocalGet(id) = object else { - return None; - }; - let HirType::Named(name) = ctx.local_type_hint(id)? else { - return None; - }; - ctx.classes.contains_key(name).then(|| name.clone()) -} - pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // #7219: reading `.buffer` on a tracked typed-array view HANDS OUT ITS // STORAGE, so the local's inline-storage proof stops holding from here on. diff --git a/crates/perry-codegen/src/expr/property_get/helpers.rs b/crates/perry-codegen/src/expr/property_get/helpers.rs index 050caf3550..3e3cb82e23 100644 --- a/crates/perry-codegen/src/expr/property_get/helpers.rs +++ b/crates/perry-codegen/src/expr/property_get/helpers.rs @@ -932,3 +932,20 @@ pub(crate) fn lower_raw_f64_class_field_get_for_number_context( ], ))) } + +/// A declared class may nominate the guarded field/method route, but never a +/// raw load by itself. Every field consumer checks the live receiver's class id +/// and keys token before dereferencing; method-value/runtime-member helpers +/// retain their dynamic fallback semantics. +pub(crate) fn guarded_declared_class_get_candidate( + ctx: &FnCtx<'_>, + object: &Expr, +) -> Option { + let Expr::LocalGet(id) = object else { + return None; + }; + let HirType::Named(name) = ctx.local_type_hint(id)? else { + return None; + }; + ctx.classes.contains_key(name).then(|| name.clone()) +} diff --git a/crates/perry-hir/src/lower/module_decl.rs b/crates/perry-hir/src/lower/module_decl.rs index 36d19914c8..fc06854594 100644 --- a/crates/perry-hir/src/lower/module_decl.rs +++ b/crates/perry-hir/src/lower/module_decl.rs @@ -13,6 +13,7 @@ mod namespace; mod native_default_import; pub(super) mod native_profile_import; mod object_literal; +mod static_import_bindings; mod typescript; // Re-export moved items so existing `crate::...` / `super::*` call paths keep @@ -23,70 +24,9 @@ use native_default_import::{ node_submodule_default_export_key, }; use object_literal::is_direct_object_literal; - -/// Register ordinary source-module import bindings before statement lowering. -/// -/// ESM imports are module-scoped and hoisted regardless of where their -/// declarations appear in the source. The main declaration pass still emits -/// the HIR `Import` records in source order; this pre-pass only makes the -/// bindings visible to expressions that precede the declaration. -pub(super) fn pre_register_static_import_bindings( - ctx: &mut LoweringContext, - ast_module: &ast::Module, -) { - for item in &ast_module.body { - let ast::ModuleItem::ModuleDecl(ast::ModuleDecl::Import(import_decl)) = item else { - continue; - }; - let raw_source = import_decl.src.value.as_str().unwrap_or("").to_string(); - let source = canonicalize_native_import_source(&raw_source); - - // Native imports need their module/method-specific registration, which - // the ordinary declaration pass performs. This pass fixes source - // modules, whose value bindings all share the imported-function path. - if is_native_module(&source) - || is_node_builtin_module(&source) - || source == "reflect-metadata" - { - continue; - } - - for specifier in &import_decl.specifiers { - match specifier { - ast::ImportSpecifier::Named(named) => { - if import_decl.type_only || named.is_type_only { - continue; - } - let local = named.local.sym.to_string(); - ctx.register_imported_func(local.clone(), local); - } - ast::ImportSpecifier::Default(default) => { - if import_decl.type_only { - continue; - } - let local = default.local.sym.to_string(); - ctx.register_imported_func(local.clone(), local.clone()); - if source == "react" { - ctx.react_default_import_local = Some(local); - } - } - ast::ImportSpecifier::Namespace(namespace) => { - if import_decl.type_only { - continue; - } - let local = namespace.local.sym.to_string(); - ctx.register_imported_func(local.clone(), local.clone()); - ctx.namespace_import_locals.insert(local.clone()); - ctx.namespace_import_sources - .insert(local.clone(), source.clone()); - if source == "react" { - ctx.react_default_import_local = Some(local); - } - } - } - } - } -} +pub(super) use static_import_bindings::{ + import_is_runtime_erased, pre_register_static_import_bindings, +}; pub(crate) fn lower_module_decl( ctx: &mut LoweringContext, @@ -192,23 +132,7 @@ pub(crate) fn lower_module_decl( return Ok(()); } let whole_decl_type_only = import_decl.type_only; - // TypeScript's per-specifier spelling - // - // import { type Foo, type Bar } from "./types" - // - // is just as runtime-erased as `import type { Foo, Bar }`. Keep - // the named specifiers below so class/interface metadata can still - // reach consumers, but mark the declaration itself type-only when - // every specifier is erased. Mixed imports retain their runtime - // edge because at least one specifier carries a value binding. - let runtime_erased = !whole_decl_type_only - && !import_decl.specifiers.is_empty() - && import_decl.specifiers.iter().all(|specifier| { - matches!( - specifier, - ast::ImportSpecifier::Named(named) if named.is_type_only - ) - }); + let runtime_erased = import_is_runtime_erased(import_decl, whole_decl_type_only); // Parse import specifiers let mut specifiers = Vec::new(); diff --git a/crates/perry-hir/src/lower/module_decl/static_import_bindings.rs b/crates/perry-hir/src/lower/module_decl/static_import_bindings.rs new file mode 100644 index 0000000000..a0ab100da3 --- /dev/null +++ b/crates/perry-hir/src/lower/module_decl/static_import_bindings.rs @@ -0,0 +1,95 @@ +//! Hoisting of ordinary static ESM import bindings — extracted from +//! `module_decl.rs`, which had crossed the 2000-line size gate. + +use super::*; +use swc_ecma_ast as ast; + +/// Register ordinary source-module import bindings before statement lowering. +/// +/// ESM imports are module-scoped and hoisted regardless of where their +/// declarations appear in the source. The main declaration pass still emits +/// the HIR `Import` records in source order; this pre-pass only makes the +/// bindings visible to expressions that precede the declaration. +pub(crate) fn pre_register_static_import_bindings( + ctx: &mut LoweringContext, + ast_module: &ast::Module, +) { + for item in &ast_module.body { + let ast::ModuleItem::ModuleDecl(ast::ModuleDecl::Import(import_decl)) = item else { + continue; + }; + let raw_source = import_decl.src.value.as_str().unwrap_or("").to_string(); + let source = canonicalize_native_import_source(&raw_source); + + // Native imports need their module/method-specific registration, which + // the ordinary declaration pass performs. This pass fixes source + // modules, whose value bindings all share the imported-function path. + if is_native_module(&source) + || is_node_builtin_module(&source) + || source == "reflect-metadata" + { + continue; + } + + for specifier in &import_decl.specifiers { + match specifier { + ast::ImportSpecifier::Named(named) => { + if import_decl.type_only || named.is_type_only { + continue; + } + let local = named.local.sym.to_string(); + ctx.register_imported_func(local.clone(), local); + } + ast::ImportSpecifier::Default(default) => { + if import_decl.type_only { + continue; + } + let local = default.local.sym.to_string(); + ctx.register_imported_func(local.clone(), local.clone()); + if source == "react" { + ctx.react_default_import_local = Some(local); + } + } + ast::ImportSpecifier::Namespace(namespace) => { + if import_decl.type_only { + continue; + } + let local = namespace.local.sym.to_string(); + ctx.register_imported_func(local.clone(), local.clone()); + ctx.namespace_import_locals.insert(local.clone()); + ctx.namespace_import_sources + .insert(local.clone(), source.clone()); + if source == "react" { + ctx.react_default_import_local = Some(local); + } + } + } + } + } +} + +/// True when a syntactically value-shaped import declares ONLY per-specifier +/// type bindings. +/// +/// TypeScript's +/// +/// import { type Foo, type Bar } from "./types" +/// +/// is just as runtime-erased as `import type { Foo, Bar }`. Callers keep the +/// named specifiers so class/interface metadata still reaches consumers, but +/// the declaration itself creates no runtime binding and no module-init edge. +/// A mixed declaration retains its runtime edge because at least one specifier +/// carries a value binding. +pub(crate) fn import_is_runtime_erased( + import_decl: &ast::ImportDecl, + whole_decl_type_only: bool, +) -> bool { + !whole_decl_type_only + && !import_decl.specifiers.is_empty() + && import_decl.specifiers.iter().all(|specifier| { + matches!( + specifier, + ast::ImportSpecifier::Named(named) if named.is_type_only + ) + }) +} diff --git a/crates/perry-runtime/src/error.rs b/crates/perry-runtime/src/error.rs index 40ecd1e51e..f0b1e05d2d 100644 --- a/crates/perry-runtime/src/error.rs +++ b/crates/perry-runtime/src/error.rs @@ -1863,138 +1863,8 @@ static KEEP_AGGREGATEERROR_NEW_FULL: extern "C" fn( static KEEP_ERROR_IS_ERROR: extern "C" fn(f64) -> f64 = js_error_is_error; #[cfg(test)] -mod tostring_tests { - use super::*; - - #[test] - fn not_a_function_throw_bridge_is_unwind_capable() { - let _: extern "C-unwind" fn(*const u8, usize, *const u8, usize) -> ! = - js_throw_type_error_not_a_function; - } - - #[test] - fn unresolved_global_name_survives_collection_during_global_this_init() { - let _copying_nursery = crate::gc::CopyingNurseryTestGuard::new(0); - let _triggers = crate::gc::GcTriggerThresholdTestGuard::suppress_automatic_triggers(); - let _force_evacuation = crate::gc::knob_overrides::ForcedEvacuationTestGuard::on(); - crate::gc::register_runtime_handle_root_scanner_for_tests(); - - // Mirror a generated module string slot: the collector rewrites this - // registered root, but it cannot rewrite the by-value f64 copied into - // `js_global_get_optional`. That callee must establish its own handle - // before lazy global initialization reaches a collection point. - let key_ptr = s(b"navigator"); - let key_before = key_ptr as usize; - let mut key_value = f64::from_bits( - crate::value::STRING_TAG | (key_before as u64 & crate::value::POINTER_MASK), - ); - crate::gc::js_gc_register_global_root((&mut key_value as *mut f64) as i64); - crate::object::collect_before_global_this_alloc_for_test(); - - let navigator = js_global_get_optional(key_value); - let key_after = (key_value.to_bits() & crate::value::POINTER_MASK) as usize; - assert_ne!( - key_after, key_before, - "the forced collection must relocate the caller's rooted key" - ); - assert!( - crate::value::JSValue::from_bits(navigator.to_bits()).is_pointer(), - "the refreshed key must still resolve globalThis.navigator" - ); - } - - fn s(bytes: &[u8]) -> *mut StringHeader { - js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32) - } - - #[test] - fn error_to_string_name_and_message() { - let e = js_error_new_with_message(s(b"boom")); - let out = unsafe { read_string_header_owned(js_error_to_string(e)) }; - assert_eq!(out, "Error: boom"); - } - - #[test] - fn error_to_string_no_message_is_just_name() { - let e = js_error_new_with_message(s(b"")); - let out = unsafe { read_string_header_owned(js_error_to_string(e)) }; - assert_eq!(out, "Error"); - } - - #[test] - fn typed_error_to_string_uses_subclass_name() { - let e = js_error_new_with_name_message(b"TypeError", s(b"bad")); - let out = unsafe { read_string_header_owned(js_error_to_string(e)) }; - assert_eq!(out, "TypeError: bad"); - } - - #[test] - fn get_errors_on_regular_object_reads_real_property_not_fixed_slot() { - // Codegen lowers EVERY `obj.errors` read to `js_error_get_errors` and - // then OR-s POINTER_TAG onto the result. For a *regular* object (not a - // native error), the `ErrorHeader.errors` byte offset (+48) is an - // unrelated slot — historically this returned NaN-boxed garbage that - // the caller's re-tag turned into a handle-band id (e.g. - // `0x7FFD_0000_0000_0001`), crashing `for…of`. The fix resolves the - // `errors` property generically for non-errors. - let arr = crate::array::js_array_alloc(2); - crate::array::js_array_push_f64(arr, 11.0); - crate::array::js_array_push_f64(arr, 22.0); - let arr_boxed = crate::value::js_nanbox_pointer(arr as i64); - - // Plain object with an own `errors` property pointing at `arr`. - let obj = crate::object::js_object_alloc(0, 2); - let key = s(b"errors"); - crate::object::js_object_set_field_by_name(obj, key, arr_boxed); - - // The accessor receives the *cleaned* (untagged) pointer, as codegen - // strips the tag before the call. - let got = js_error_get_errors(obj as *mut ErrorHeader); - assert_eq!( - got as usize, arr as usize, - "regular object's .errors must resolve to its real array property, \ - not the +48 ErrorHeader slot" - ); - - // An object with no `errors` property yields null (→ caller re-tag is a - // null receiver that `for…of` rejects as not iterable, matching the - // generic property read). - let empty = crate::object::js_object_alloc(0, 1); - assert!(js_error_get_errors(empty as *mut ErrorHeader).is_null()); - - // A small-handle-band "pointer" must never be dereferenced. - assert!(js_error_get_errors(1usize as *mut ErrorHeader).is_null()); - } - - #[test] - fn get_errors_on_native_aggregate_error_uses_fixed_slot() { - let arr = crate::array::js_array_alloc(1); - crate::array::js_array_push_f64(arr, 7.0); - let agg = js_aggregateerror_new(arr, s(b"agg")); - let got = js_error_get_errors(agg); - assert_eq!( - got as usize, arr as usize, - "native AggregateError must read its fixed errors slot" - ); - } - - #[test] - fn eval_and_uri_errors_have_distinct_kinds_and_names() { - let eval = js_evalerror_new(s(b"eval")); - assert_eq!(js_error_get_kind(eval), ERROR_KIND_EVAL_ERROR); - assert_eq!( - unsafe { read_string_header_owned(js_error_get_name(eval)) }, - "EvalError" - ); - - let uri = js_urierror_new(s(b"uri")); - assert_eq!(js_error_get_kind(uri), ERROR_KIND_URI_ERROR); - assert_eq!( - unsafe { read_string_header_owned(js_error_get_name(uri)) }, - "URIError" - ); - } -} +#[path = "error_tostring_tests.rs"] +mod tostring_tests; #[cfg(test)] mod header_unification_tests { diff --git a/crates/perry-runtime/src/error_tostring_tests.rs b/crates/perry-runtime/src/error_tostring_tests.rs new file mode 100644 index 0000000000..b7c36c1106 --- /dev/null +++ b/crates/perry-runtime/src/error_tostring_tests.rs @@ -0,0 +1,136 @@ +//! `error.rs` to-string / throw-bridge regressions. +//! +//! Split out of `error.rs` to keep that file under the 2,000-line CI +//! cap (`scripts/check_file_size.sh`). Included from there with +//! `#[cfg(test)] #[path = "error_tostring_tests.rs"] mod tostring_tests;`, +//! so `use super::*` still resolves against `error.rs`. + +use super::*; + +#[test] +fn not_a_function_throw_bridge_is_unwind_capable() { + let _: extern "C-unwind" fn(*const u8, usize, *const u8, usize) -> ! = + js_throw_type_error_not_a_function; +} + +#[test] +fn unresolved_global_name_survives_collection_during_global_this_init() { + let _copying_nursery = crate::gc::CopyingNurseryTestGuard::new(0); + let _triggers = crate::gc::GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _force_evacuation = crate::gc::knob_overrides::ForcedEvacuationTestGuard::on(); + crate::gc::register_runtime_handle_root_scanner_for_tests(); + + // Mirror a generated module string slot: the collector rewrites this + // registered root, but it cannot rewrite the by-value f64 copied into + // `js_global_get_optional`. That callee must establish its own handle + // before lazy global initialization reaches a collection point. + let key_ptr = s(b"navigator"); + let key_before = key_ptr as usize; + let mut key_value = + f64::from_bits(crate::value::STRING_TAG | (key_before as u64 & crate::value::POINTER_MASK)); + crate::gc::js_gc_register_global_root((&mut key_value as *mut f64) as i64); + crate::object::collect_before_global_this_alloc_for_test(); + + let navigator = js_global_get_optional(key_value); + let key_after = (key_value.to_bits() & crate::value::POINTER_MASK) as usize; + assert_ne!( + key_after, key_before, + "the forced collection must relocate the caller's rooted key" + ); + assert!( + crate::value::JSValue::from_bits(navigator.to_bits()).is_pointer(), + "the refreshed key must still resolve globalThis.navigator" + ); +} + +fn s(bytes: &[u8]) -> *mut StringHeader { + js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32) +} + +#[test] +fn error_to_string_name_and_message() { + let e = js_error_new_with_message(s(b"boom")); + let out = unsafe { read_string_header_owned(js_error_to_string(e)) }; + assert_eq!(out, "Error: boom"); +} + +#[test] +fn error_to_string_no_message_is_just_name() { + let e = js_error_new_with_message(s(b"")); + let out = unsafe { read_string_header_owned(js_error_to_string(e)) }; + assert_eq!(out, "Error"); +} + +#[test] +fn typed_error_to_string_uses_subclass_name() { + let e = js_error_new_with_name_message(b"TypeError", s(b"bad")); + let out = unsafe { read_string_header_owned(js_error_to_string(e)) }; + assert_eq!(out, "TypeError: bad"); +} + +#[test] +fn get_errors_on_regular_object_reads_real_property_not_fixed_slot() { + // Codegen lowers EVERY `obj.errors` read to `js_error_get_errors` and + // then OR-s POINTER_TAG onto the result. For a *regular* object (not a + // native error), the `ErrorHeader.errors` byte offset (+48) is an + // unrelated slot — historically this returned NaN-boxed garbage that + // the caller's re-tag turned into a handle-band id (e.g. + // `0x7FFD_0000_0000_0001`), crashing `for…of`. The fix resolves the + // `errors` property generically for non-errors. + let arr = crate::array::js_array_alloc(2); + crate::array::js_array_push_f64(arr, 11.0); + crate::array::js_array_push_f64(arr, 22.0); + let arr_boxed = crate::value::js_nanbox_pointer(arr as i64); + + // Plain object with an own `errors` property pointing at `arr`. + let obj = crate::object::js_object_alloc(0, 2); + let key = s(b"errors"); + crate::object::js_object_set_field_by_name(obj, key, arr_boxed); + + // The accessor receives the *cleaned* (untagged) pointer, as codegen + // strips the tag before the call. + let got = js_error_get_errors(obj as *mut ErrorHeader); + assert_eq!( + got as usize, arr as usize, + "regular object's .errors must resolve to its real array property, \ + not the +48 ErrorHeader slot" + ); + + // An object with no `errors` property yields null (→ caller re-tag is a + // null receiver that `for…of` rejects as not iterable, matching the + // generic property read). + let empty = crate::object::js_object_alloc(0, 1); + assert!(js_error_get_errors(empty as *mut ErrorHeader).is_null()); + + // A small-handle-band "pointer" must never be dereferenced. + assert!(js_error_get_errors(1usize as *mut ErrorHeader).is_null()); +} + +#[test] +fn get_errors_on_native_aggregate_error_uses_fixed_slot() { + let arr = crate::array::js_array_alloc(1); + crate::array::js_array_push_f64(arr, 7.0); + let agg = js_aggregateerror_new(arr, s(b"agg")); + let got = js_error_get_errors(agg); + assert_eq!( + got as usize, arr as usize, + "native AggregateError must read its fixed errors slot" + ); +} + +#[test] +fn eval_and_uri_errors_have_distinct_kinds_and_names() { + let eval = js_evalerror_new(s(b"eval")); + assert_eq!(js_error_get_kind(eval), ERROR_KIND_EVAL_ERROR); + assert_eq!( + unsafe { read_string_header_owned(js_error_get_name(eval)) }, + "EvalError" + ); + + let uri = js_urierror_new(s(b"uri")); + assert_eq!(js_error_get_kind(uri), ERROR_KIND_URI_ERROR); + assert_eq!( + unsafe { read_string_header_owned(js_error_get_name(uri)) }, + "URIError" + ); +} diff --git a/crates/perry-runtime/src/object/object_ops/define_properties.rs b/crates/perry-runtime/src/object/object_ops/define_properties.rs index 3c3568cd02..2d954808b4 100644 --- a/crates/perry-runtime/src/object/object_ops/define_properties.rs +++ b/crates/perry-runtime/src/object/object_ops/define_properties.rs @@ -382,8 +382,18 @@ pub extern "C" fn js_object_set_prototype_of(obj_value: f64, proto: f64) -> f64 } if (proto_bits & 0xFFFF_0000_0000_0000) == POINTER_TAG { let proto_ptr = crate::value::js_nanbox_get_pointer(proto) as *mut ObjectHeader; + // `proto` is user-supplied, so it can carry a fetch/zlib/proxy + // handle rather than a heap object. A bare `is_valid_obj_ptr` + // accepts those bands on Linux, and this pointer is stored into a + // GC root table that the collector later dereferences — a segfault + // there, silently hidden on macOS (#1843/#4004/#4665/#4800/#6271). + // Require a real, readable GC header instead. if !proto_ptr.is_null() && !crate::closure::is_closure_ptr(proto_ptr as usize) + && crate::value::addr_class::is_above_handle_band(proto_ptr as usize) + && unsafe { + crate::value::addr_class::try_read_gc_header(proto_ptr as usize).is_some() + } && is_valid_obj_ptr(proto_ptr as *const u8) { super::super::class_registry::class_prototype_object_root_store( diff --git a/crates/perry-transform/src/inline/mod.rs b/crates/perry-transform/src/inline/mod.rs index af66f341f8..b9c4bf9817 100644 --- a/crates/perry-transform/src/inline/mod.rs +++ b/crates/perry-transform/src/inline/mod.rs @@ -1093,6 +1093,7 @@ mod tests { module_kind: ModuleKind::NativeCompiled, resolved_path: Some("/src/predicate.ts".to_string()), type_only: false, + runtime_erased: false, is_dynamic: false, is_dynamic_target: false, is_deferred_require: false, @@ -1151,6 +1152,7 @@ mod tests { module_kind: ModuleKind::NativeCompiled, resolved_path: Some("/src/ops.ts".to_string()), type_only: false, + runtime_erased: false, is_dynamic: false, is_dynamic_target: false, is_deferred_require: false, @@ -1166,6 +1168,7 @@ mod tests { module_kind: ModuleKind::NativeCompiled, resolved_path: Some("/src/predicate.ts".to_string()), type_only: false, + runtime_erased: false, is_dynamic: false, is_dynamic_target: false, is_deferred_require: false, @@ -1334,6 +1337,7 @@ mod tests { module_kind: ModuleKind::NativeCompiled, resolved_path: Some("/src/ops.ts".to_string()), type_only: false, + runtime_erased: false, is_dynamic: false, is_dynamic_target: false, is_deferred_require: false, @@ -1349,6 +1353,7 @@ mod tests { module_kind: ModuleKind::NativeCompiled, resolved_path: Some("/src/predicate.ts".to_string()), type_only: true, + runtime_erased: false, is_dynamic: false, is_dynamic_target: false, is_deferred_require: false, diff --git a/crates/perry/src/commands/compile/cjs_wrap/tests.rs b/crates/perry/src/commands/compile/cjs_wrap/tests.rs index adb048f851..3317e32298 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/tests.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/tests.rs @@ -14,6 +14,7 @@ use super::wrap::{wrap_commonjs, wrap_commonjs_for_target, wrap_commonjs_with_bo use std::fs; use std::path::PathBuf; +mod hoist_scanner; mod source_graph; // #5247: the wrapped output must report where the ORIGINAL body begins, and @@ -1985,30 +1986,3 @@ fn cjs_wrap_builtin_require_not_hoisted_as_static_import() { "the built-in require case must not reference the dropped import local; got:\n{wrapped}" ); } - -#[test] -fn regex_quote_before_local_superclass_keeps_class_in_cjs_iife() { - // @smithy/core's serde CJS emit contains this sequence. The quote inside - // the regex is not a string delimiter; treating it as one desynchronized - // the top-level-binding scanner, hid `ReadableStreamRef`, and hoisted only - // `ChecksumStream` ahead of the CommonJS IIFE. - let src = r#"const splitHeader = (value) => { - return value.replace(/\\"/g, '"'); -}; -const ReadableStreamRef = typeof ReadableStream === "function" - ? ReadableStream - : function () {}; -class ChecksumStream extends ReadableStreamRef {} -module.exports = { ChecksumStream }; -"#; - - let (blocks, hoisted_names, rest) = extract_top_level_class_decls(src); - assert!( - !hoisted_names.iter().any(|name| name == "ChecksumStream"), - "a class depending on a CJS-local superclass must not hoist; hoisted block:\n{blocks}" - ); - assert!( - rest.contains("class ChecksumStream extends ReadableStreamRef"), - "the class declaration must remain at its source position inside the factory" - ); -} diff --git a/crates/perry/src/commands/compile/cjs_wrap/tests/hoist_scanner.rs b/crates/perry/src/commands/compile/cjs_wrap/tests/hoist_scanner.rs new file mode 100644 index 0000000000..169b82c9bd --- /dev/null +++ b/crates/perry/src/commands/compile/cjs_wrap/tests/hoist_scanner.rs @@ -0,0 +1,31 @@ +//! `hoist_classes` top-level-binding scanner regression — split out of +//! `tests.rs`, which had crossed the 2000-line size gate. + +use super::super::hoist_classes::extract_top_level_class_decls; + +#[test] +fn regex_quote_before_local_superclass_keeps_class_in_cjs_iife() { + // @smithy/core's serde CJS emit contains this sequence. The quote inside + // the regex is not a string delimiter; treating it as one desynchronized + // the top-level-binding scanner, hid `ReadableStreamRef`, and hoisted only + // `ChecksumStream` ahead of the CommonJS IIFE. + let src = r#"const splitHeader = (value) => { + return value.replace(/\\"/g, '"'); +}; +const ReadableStreamRef = typeof ReadableStream === "function" + ? ReadableStream + : function () {}; +class ChecksumStream extends ReadableStreamRef {} +module.exports = { ChecksumStream }; +"#; + + let (blocks, hoisted_names, rest) = extract_top_level_class_decls(src); + assert!( + !hoisted_names.iter().any(|name| name == "ChecksumStream"), + "a class depending on a CJS-local superclass must not hoist; hoisted block:\n{blocks}" + ); + assert!( + rest.contains("class ChecksumStream extends ReadableStreamRef"), + "the class declaration must remain at its source position inside the factory" + ); +} diff --git a/crates/perry/src/commands/compile/strip_dedup.rs b/crates/perry/src/commands/compile/strip_dedup.rs index a30385b6ba..0bac3a7a06 100644 --- a/crates/perry/src/commands/compile/strip_dedup.rs +++ b/crates/perry/src/commands/compile/strip_dedup.rs @@ -151,19 +151,6 @@ fn force_localize_symbol(symbol: &str) -> bool { || is_panic_unwind_symbol(symbol) } -/// True if `path` is an ELF object file (first four bytes `0x7F 'E' 'L' 'F'`). -/// Used to skip panic/unwind-symbol localization on ELF, where localizing -/// `rust_eh_personality` / `DW.ref.rust_eh_personality` breaks PIE relocations -/// (see [`RUST_PANIC_UNWIND_SYMBOL_PARTS`]). -fn object_is_elf(path: &Path) -> bool { - use std::io::Read; - let mut magic = [0u8; 4]; - std::fs::File::open(path) - .and_then(|mut f| f.read_exact(&mut magic)) - .map(|_| magic == [0x7f, b'E', b'L', b'F']) - .unwrap_or(false) -} - pub(super) fn find_path_tool(name: &str) -> Option { let paths = std::env::var_os("PATH")?; std::env::split_paths(&paths) @@ -1072,10 +1059,6 @@ fn rebuild_archive( pub(super) fn strip_duplicate_objects_from_well_known_lib(lib_path: &PathBuf) -> Result { let lib_name = lib_path.file_name().and_then(|f| f.to_str()).unwrap_or("?"); - let is_coff = lib_path - .extension() - .and_then(|extension| extension.to_str()) - .is_some_and(|extension| extension.eq_ignore_ascii_case("lib")); eprintln!( "[strip-dedup] Processing well-known wrapper: {}", lib_path.display() @@ -1166,6 +1149,18 @@ pub(super) fn strip_duplicate_objects_from_well_known_lib(lib_path: &PathBuf) -> return Err(anyhow::anyhow!("failed to extract {lib_name}: {stderr}")); } + // Decide the container format from the extracted members, never from the + // archive's file name. This function is handed the intermediate wrapper + // produced by `strip_duplicate_objects_from_no_shared_deps`, which is named + // `__nosharedeps.lib` on EVERY platform — so an extension check claims + // COFF on macOS and Linux as well. `llvm-ar --format=coff` then writes a + // GNU-style symbol table whose `/` member is not a Mach-O file, and Apple's + // linker rejects the whole archive ("archive member '/' not a mach-o file"). + let is_coff = members + .iter() + .filter_map(|member| extracted_archive_member(&extract_dir, member)) + .any(|member_path| object_is_coff(&member_path)); + let archive_tag: String = lib_name .chars() .map(|character| { @@ -1822,6 +1817,9 @@ fn requires_bundled_native_companion(symbol: &str) -> bool { symbol.trim_start_matches('_').starts_with("ring_core_") } +mod object_format; +use object_format::{object_is_coff, object_is_elf}; + mod stub_symbols; pub(super) use stub_symbols::localize_stdlib_stub_symbols; use stub_symbols::strip_members_present_in_reference; diff --git a/crates/perry/src/commands/compile/strip_dedup/object_format.rs b/crates/perry/src/commands/compile/strip_dedup/object_format.rs new file mode 100644 index 0000000000..2746528600 --- /dev/null +++ b/crates/perry/src/commands/compile/strip_dedup/object_format.rs @@ -0,0 +1,64 @@ +//! Object-container sniffing for archive members — extracted from +//! `strip_dedup.rs`, which had crossed the 2000-line size gate. +//! +//! Both predicates read the object's own magic bytes. The archive file +//! NAME must never be used for this: the well-known wrapper path is handed +//! perry's intermediate `__nosharedeps.lib`, which carries that +//! extension on every host. + +use std::path::Path; + +/// True if `path` is an ELF object file (first four bytes `0x7F 'E' 'L' 'F'`). +/// Used to skip panic/unwind-symbol localization on ELF, where localizing +/// `rust_eh_personality` / `DW.ref.rust_eh_personality` breaks PIE relocations +/// (see [`RUST_PANIC_UNWIND_SYMBOL_PARTS`]). +pub(super) fn object_is_elf(path: &Path) -> bool { + use std::io::Read; + let mut magic = [0u8; 4]; + std::fs::File::open(path) + .and_then(|mut f| f.read_exact(&mut magic)) + .map(|_| magic == [0x7f, b'E', b'L', b'F']) + .unwrap_or(false) +} + +/// True only when `path` is positively identified as a COFF object. +/// +/// Container format must be read from the object's own bytes: the well-known +/// wrapper path receives perry's intermediate archive, which is named +/// `__nosharedeps.lib` regardless of host, so a file-extension check +/// misidentifies Mach-O and ELF archives as COFF. Anything unreadable, ELF, or +/// Mach-O (thin or fat, either endianness) answers `false`, keeping the +/// Windows-only `--redefine-sym` / `--format=coff` handling off every other +/// platform. +pub(super) fn object_is_coff(path: &Path) -> bool { + use std::io::Read; + let mut magic = [0u8; 4]; + if std::fs::File::open(path) + .and_then(|mut f| f.read_exact(&mut magic)) + .is_err() + { + return false; + } + if magic == [0x7f, b'E', b'L', b'F'] { + return false; + } + // Mach-O: 0xfeedface / 0xfeedfacf in either byte order, plus the + // big-endian fat-archive magic 0xcafebabe. + if matches!(u32::from_le_bytes(magic), 0xfeed_face | 0xfeed_facf) + || matches!( + u32::from_be_bytes(magic), + 0xfeed_face | 0xfeed_facf | 0xcafe_babe + ) + { + return false; + } + // COFF anonymous/"bigobj" header, emitted by MSVC for large objects. + if magic == [0x00, 0x00, 0xff, 0xff] { + return true; + } + // Ordinary COFF starts with its little-endian machine word. + matches!( + u16::from_le_bytes([magic[0], magic[1]]), + 0x014c | 0x8664 | 0xaa64 | 0x01c0 | 0x01c4 | 0x0200 | 0x6264 + ) +} diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index eb6cd93a83..7121c47e3a 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -250,6 +250,12 @@ "scanner": "object::scan_class_side_table_roots_mut and its budgeted step twin (class_registry/gc_roots.rs:138 and :256)", "why": "The class side tables are declared in state.rs and scanned from gc_roots.rs. Both twins visit it \u2014 #7239 diffed all eight budgeted (FULL, STEP) pairs and found no drift." }, + { + "file": "crates/perry-runtime/src/object/global_this/fetch_globals.rs", + "name": "TEST_COLLECT_BEFORE_GLOBAL_THIS_ALLOC", + "verdict": "test_only", + "why": "#[cfg(test)] Cell one-shot flag that asks js_get_global_this to collect once before it allocates the realm, so the unresolved-global rooting regression can observe the relocation; stores only true/false and is absent from shipped binaries." + }, { "file": "crates/perry-runtime/src/object/global_this/fetch_globals.rs", "name": "THREAD_GLOBAL_THIS", From 66b1ece1aa936ee7441db90c3ec7c4353d30ddcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 30 Aug 2026 13:32:19 +0200 Subject: [PATCH 4/6] fix(lint): repoint the local-binding allowlist at the moved helper Splitting property_get.rs moved guarded_declared_class_get_candidate into property_get/helpers.rs. The local-binding type-proof allowlist is keyed by (path, function), so the entry went stale and the read became unclassified. Same classification and rationale, new path. Claude-Session: https://claude.ai/code/session_01P3bPE5eJQT4vQ6wf8P9JDN --- scripts/local_binding_type_allowlist.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/local_binding_type_allowlist.json b/scripts/local_binding_type_allowlist.json index 5ef9bb95d1..735c0b07a9 100644 --- a/scripts/local_binding_type_allowlist.json +++ b/scripts/local_binding_type_allowlist.json @@ -170,7 +170,7 @@ "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." }, { - "path": "crates/perry-codegen/src/expr/property_get.rs", + "path": "crates/perry-codegen/src/expr/property_get/helpers.rs", "function": "guarded_declared_class_get_candidate", "access": "local_type_hint", "count": 1, From 60cb4d9561f8f62ec542184411ef4d028518155a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 30 Aug 2026 14:53:45 +0200 Subject: [PATCH 5/6] fix(runtime): keep a constructor's [[Prototype]] off the instance chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Object.setPrototypeOf(Ctor, obj)` on a declared class recorded `obj` in CLASS_PROTOTYPE_OBJECTS. That table means "the object INSTANCES of this class inherit from", and the instance-side field-read and method-dispatch walks read it for exactly that purpose, so a constructor-side link leaked onto instances: const schema = { ast: "STATIC-ONLY", greet() { return "static-only" } } class Opaque {} Object.setPrototypeOf(Opaque, schema) new Opaque().ast // "STATIC-ONLY", Node says undefined new Opaque().greet() // ran, Node throws TypeError Prototype-method mirroring writes through the same table, so `Opaque.prototype.added = fn` also made `added` an own enumerable key of the user's `schema` object. Give the constructor link its own GC-rooted table, CLASS_STATIC_PROTOTYPES, read only by the static-side lookups: the ClassRef arm of js_object_get_field_by_name, the generic `in` presence walk, js_class_static_method_call, and Object.getPrototypeOf. It is visited by both the full and budgeted class-side-table root walks (new ClassSideTableRootSlot::StaticPrototype), and the store fires the root write barrier, matching CLASS_DECL_PROTOTYPE_OBJECTS beside it. This also closes two gaps the original arm left open: - `Ctor.staticMethod()` written directly on the class ref threw "is not a function" (it only worked through an any-typed alias, which takes dynamic dispatch). js_class_static_method_call now walks the recorded constructor prototypes with `this` bound to the receiver. - `Object.getPrototypeOf(Ctor)` ignored the link entirely. It now returns the recorded object, and CLASS_STATIC_PROTOTYPE_NULLED distinguishes "never linked" (default Function.prototype) from an explicit `setPrototypeOf(Ctor, null)` (null), which Node separates. Regression tests assert Node's exact output for both sides — including that instances see nothing and that the user's object is not mutated, which the original test did not cover. Claude-Session: https://claude.ai/code/session_01P3bPE5eJQT4vQ6wf8P9JDN --- .../src/object/class_registry.rs | 10 +- .../src/object/class_registry/gc_roots.rs | 37 +++++++ .../object/class_registry/parent_static.rs | 33 +++++++ .../src/object/class_registry/state.rs | 96 ++++++++++++++++++- .../object/field_get_set/get_field_by_name.rs | 13 +++ .../src/object/field_get_set/has_property.rs | 9 ++ .../object/object_ops/define_properties.rs | 17 ++-- .../src/object/object_ops/prototype.rs | 13 +++ .../issue_5763_setprototypeof_chain_end.rs | 90 +++++++++++++++++ scripts/gc_runtime_root_holders.json | 13 +++ 10 files changed, 319 insertions(+), 12 deletions(-) diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index 8288d234b6..33483dbd29 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -67,8 +67,9 @@ pub(crate) use state::{ class_object_value_root_store, class_own_enumerable_field_names, class_own_static_field_value, class_parent_closure, class_parent_closure_root_store, class_prototype_method_is_enumerable, class_prototype_method_set_enumerable, class_prototype_method_value_cache_root_store, - class_prototype_object_root_clear, class_prototype_object_root_store, - class_static_defined_attrs, class_static_set_defined_attrs, class_unmark_key_deleted, + class_prototype_object_root_store, class_static_defined_attrs, class_static_prototype, + class_static_prototype_is_nulled, class_static_prototype_root_clear, + class_static_prototype_root_store, class_static_set_defined_attrs, class_unmark_key_deleted, global_object_prototype_bits, is_bound_native_constructor_closure_value, is_non_constructable_builtin_function_value, parent_closure_in_chain, throw_non_constructable_builtin_function, @@ -77,8 +78,9 @@ pub use state::{ ClassVTable, VTableMethodEntry, CLASS_DECL_PROTOTYPE_OBJECTS, CLASS_DYNAMIC_PARENT_VALUE, CLASS_METHOD_BIND_LENGTHS, CLASS_OBJECT_VALUES, CLASS_PARENT_CLOSURES, CLASS_PROTOTYPE_METHOD_NONENUM, CLASS_PROTOTYPE_OBJECTS, CLASS_STATIC_ACCESSORS, - CLASS_STATIC_METHODS, CLASS_STATIC_METHOD_BIND_LENGTHS, CLASS_SYMBOL_ACCESSORS, - CLASS_SYMBOL_METHODS, CLASS_VTABLE_REGISTRY, FUNCTION_CLASS_IDS, REGISTERED_CLASS_IDS, + CLASS_STATIC_METHODS, CLASS_STATIC_METHOD_BIND_LENGTHS, CLASS_STATIC_PROTOTYPES, + CLASS_SYMBOL_ACCESSORS, CLASS_SYMBOL_METHODS, CLASS_VTABLE_REGISTRY, FUNCTION_CLASS_IDS, + REGISTERED_CLASS_IDS, }; // ── prototype_objects.rs ──────────────────────────────────────────────────── diff --git a/crates/perry-runtime/src/object/class_registry/gc_roots.rs b/crates/perry-runtime/src/object/class_registry/gc_roots.rs index 4a4bea8362..733562220c 100644 --- a/crates/perry-runtime/src/object/class_registry/gc_roots.rs +++ b/crates/perry-runtime/src/object/class_registry/gc_roots.rs @@ -20,6 +20,9 @@ enum ClassSideTableRootSlot { DeclPrototypeObject { class_id: u32, }, + StaticPrototype { + class_id: u32, + }, ParentClosure { class_id: u32, }, @@ -126,6 +129,16 @@ pub fn scan_class_side_table_roots_mut(visitor: &mut crate::gc::RuntimeRootVisit } }); + CLASS_STATIC_PROTOTYPES.with(|table| { + if let Ok(mut guard) = table.write() { + if let Some(map) = guard.as_mut() { + for proto_addr in map.values_mut() { + visitor.visit_usize_slot(proto_addr); + } + } + } + }); + CLASS_PARENT_CLOSURES.with(|table| { if let Ok(mut guard) = table.write() { if let Some(map) = guard.as_mut() { @@ -271,6 +284,16 @@ fn class_side_table_root_snapshot() -> Vec { } }); + CLASS_STATIC_PROTOTYPES.with(|table| { + if let Ok(guard) = table.read() { + if let Some(map) = guard.as_ref() { + for &class_id in map.keys() { + slots.push(ClassSideTableRootSlot::StaticPrototype { class_id }); + } + } + } + }); + CLASS_PARENT_CLOSURES.with(|table| { if let Ok(guard) = table.read() { if let Some(map) = guard.as_ref() { @@ -403,6 +426,15 @@ fn scan_class_side_table_root_slot( } }); } + ClassSideTableRootSlot::StaticPrototype { class_id } => { + CLASS_STATIC_PROTOTYPES.with(|table| { + if let Ok(mut guard) = table.write() { + if let Some(proto_addr) = guard.as_mut().and_then(|map| map.get_mut(class_id)) { + visitor.visit_usize_slot(proto_addr); + } + } + }); + } ClassSideTableRootSlot::ParentClosure { class_id } => { CLASS_PARENT_CLOSURES.with(|table| { if let Ok(mut guard) = table.write() { @@ -641,6 +673,11 @@ pub(crate) fn test_clear_class_side_table_roots() { *guard = None; } }); + CLASS_STATIC_PROTOTYPES.with(|table| { + if let Ok(mut guard) = table.write() { + *guard = None; + } + }); CLASS_PARENT_CLOSURES.with(|table| { if let Ok(mut guard) = table.write() { *guard = None; diff --git a/crates/perry-runtime/src/object/class_registry/parent_static.rs b/crates/perry-runtime/src/object/class_registry/parent_static.rs index f48d3aa48c..bef0c764df 100644 --- a/crates/perry-runtime/src/object/class_registry/parent_static.rs +++ b/crates/perry-runtime/src/object/class_registry/parent_static.rs @@ -1583,6 +1583,39 @@ pub unsafe extern "C" fn js_class_static_method_call( { return result; } + // `Object.setPrototypeOf(Ctor, obj)` put a plain object on the + // CONSTRUCTOR's prototype chain, so `Ctor.method(...)` resolves through it + // (Effect's `Schema.Opaque`). Walk the registered parent chain the same way + // the static FIELD lookup above does, reading each level's recorded + // constructor prototype, and invoke the first callable found with `this` + // bound to the original receiver. + { + let mut cid = class_id; + let mut depth = 0u32; + while cid != 0 && depth < 32 { + let static_proto = super::class_static_prototype(cid); + if !static_proto.is_null() { + let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + let member = super::super::field_get_set::js_object_get_field_by_name( + static_proto as *const ObjectHeader, + key, + ); + let member = f64::from_bits(member.bits()); + let mv = crate::value::JSValue::from_bits(member.to_bits()); + if !mv.is_undefined() && !mv.is_null() { + let prev_this = crate::object::js_implicit_this_set(receiver); + let result = crate::closure::js_native_call_value(member, args_ptr, args_len); + crate::object::js_implicit_this_set(prev_this); + return result; + } + } + match get_parent_class_id(cid) { + Some(parent) if parent != 0 && parent != cid => cid = parent, + _ => break, + } + depth += 1; + } + } // `class X extends Promise` — inherited builtin static (`X.all(...)`, // `X.resolve(...)`, …). Dispatch the spec static with `this` = the subclass // receiver so `NewPromiseCapability(X)` constructs the subclass. Resolves the diff --git a/crates/perry-runtime/src/object/class_registry/state.rs b/crates/perry-runtime/src/object/class_registry/state.rs index 8c89d2f7cc..493a04943f 100644 --- a/crates/perry-runtime/src/object/class_registry/state.rs +++ b/crates/perry-runtime/src/object/class_registry/state.rs @@ -318,6 +318,38 @@ crate::perry_thread_local! { pub static CLASS_PROTOTYPE_OBJECTS: RwLock>> = RwLock::new(None); } +crate::perry_thread_local! { + /// The CONSTRUCTOR's `[[Prototype]]`, set by `Object.setPrototypeOf(Ctor, obj)` + /// on a declared class (perry represents those as INT32 ClassRefs, not heap + /// Function objects, so they have no closure prototype slot to write). + /// + /// Deliberately its own table. `CLASS_PROTOTYPE_OBJECTS` means "the object + /// INSTANCES of this class inherit from", and the method-dispatch and + /// field-read walks read it for exactly that purpose — parking a + /// constructor-side link there makes `new Ctor()` inherit the constructor's + /// statics and makes prototype-method mirroring write into the user's + /// object. Only the static-side lookups consult this table: + /// `js_object_get_field_by_name`'s ClassRef arm, the generic `in` presence + /// walk, static method dispatch, and `Object.getPrototypeOf`. + /// + /// Effect's `Schema.Opaque` is the motivating shape (`Schema.ts:1887`, + /// `:5874`): `class Opaque {}; Object.setPrototypeOf(Opaque, schema)`, then + /// `class Partial extends Opaque {}` reads `Partial.ast` through the chain. + /// + /// Stored as `usize` for the same Send + Sync reason as the tables above. + pub static CLASS_STATIC_PROTOTYPES: RwLock>> = RwLock::new(None); +} + +crate::perry_thread_local! { + /// Class ids whose constructor `[[Prototype]]` was explicitly set to `null` + /// (`Object.setPrototypeOf(Ctor, null)`). Absence from CLASS_STATIC_PROTOTYPES + /// alone cannot express this: "never linked" must still report the default + /// `Function.prototype`, while an explicit null must report `null`. Holds + /// class ids only, so the collector has nothing to trace here. + pub static CLASS_STATIC_PROTOTYPE_NULLED: RwLock>> = + RwLock::new(None); +} + crate::perry_thread_local! { /// Lazily materialized `Class.prototype` objects for declared ES classes. /// These are separate from `CLASS_PROTOTYPE_OBJECTS`: that older table is @@ -468,17 +500,77 @@ pub(crate) fn class_prototype_object_root_store(class_id: u32, proto_ptr: *mut O crate::gc::runtime_write_barrier_root_raw_ptr(proto_ptr); } -pub(crate) fn class_prototype_object_root_clear(class_id: u32) { +pub(crate) fn class_static_prototype_root_store(class_id: u32, proto_ptr: *mut ObjectHeader) { + if class_id == 0 || proto_ptr.is_null() { + return; + } + CLASS_STATIC_PROTOTYPES.with(|table| { + let mut guard = table.write().unwrap(); + if guard.is_none() { + *guard = Some(HashMap::new()); + } + guard.as_mut().unwrap().insert(class_id, proto_ptr as usize); + }); + CLASS_STATIC_PROTOTYPE_NULLED.with(|table| { + if let Ok(mut guard) = table.write() { + if let Some(set) = guard.as_mut() { + set.remove(&class_id); + } + } + }); + crate::gc::runtime_write_barrier_root_raw_ptr(proto_ptr); +} + +pub(crate) fn class_static_prototype_root_clear(class_id: u32) { if class_id == 0 { return; } - CLASS_PROTOTYPE_OBJECTS.with(|table| { + CLASS_STATIC_PROTOTYPES.with(|table| { if let Ok(mut guard) = table.write() { if let Some(map) = guard.as_mut() { map.remove(&class_id); } } }); + CLASS_STATIC_PROTOTYPE_NULLED.with(|table| { + let mut guard = table.write().unwrap(); + if guard.is_none() { + *guard = Some(std::collections::HashSet::new()); + } + guard.as_mut().unwrap().insert(class_id); + }); +} + +/// True when `Object.setPrototypeOf(Ctor, null)` explicitly severed the +/// constructor's prototype chain, as opposed to never having linked one. +pub(crate) fn class_static_prototype_is_nulled(class_id: u32) -> bool { + if class_id == 0 { + return false; + } + let class_id = crate::object::class_generic_origin(class_id).unwrap_or(class_id); + CLASS_STATIC_PROTOTYPE_NULLED.with(|table| { + table + .read() + .ok() + .and_then(|guard| guard.as_ref().map(|set| set.contains(&class_id))) + .unwrap_or(false) + }) +} + +/// The constructor-side `[[Prototype]]` recorded for `class_id`, or null. +pub(crate) fn class_static_prototype(class_id: u32) -> *mut ObjectHeader { + if class_id == 0 { + return std::ptr::null_mut(); + } + let class_id = crate::object::class_generic_origin(class_id).unwrap_or(class_id); + CLASS_STATIC_PROTOTYPES.with(|table| { + if let Ok(read) = table.read() { + if let Some(map) = read.as_ref() { + return map.get(&class_id).copied().unwrap_or(0) as *mut ObjectHeader; + } + } + std::ptr::null_mut() + }) } pub(crate) fn class_decl_prototype_object_root_store(class_id: u32, proto_ptr: *mut ObjectHeader) { diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs index 2996423e4a..b13d3ae8fd 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs @@ -1349,6 +1349,19 @@ pub extern "C" fn js_object_get_field_by_name( let mut child = class_id; let mut depth = 0usize; while depth < 32 { + // The constructor's own `[[Prototype]]`, set by + // `Object.setPrototypeOf(Ctor, obj)`. Checked first: + // it is the nearest static-side link, and unlike + // `class_prototype_object` it is never on an + // instance's chain. + let static_proto = + super::super::class_registry::class_static_prototype(child); + if !static_proto.is_null() { + let v = js_object_get_field_by_name(static_proto as *const _, key); + if !v.is_undefined() { + return v; + } + } let proto = super::super::class_registry::class_prototype_object(child); if !proto.is_null() { let v = js_object_get_field_by_name(proto as *const _, key); diff --git a/crates/perry-runtime/src/object/field_get_set/has_property.rs b/crates/perry-runtime/src/object/field_get_set/has_property.rs index cce718d288..72a323c553 100644 --- a/crates/perry-runtime/src/object/field_get_set/has_property.rs +++ b/crates/perry-runtime/src/object/field_get_set/has_property.rs @@ -181,6 +181,15 @@ unsafe fn class_ref_has_inherited_static_data( let mut child = class_id; let mut depth = 0usize; while depth < 32 { + // The constructor's own `[[Prototype]]` (`Object.setPrototypeOf(Ctor, + // obj)`) provides statics for presence purposes exactly like a pinned + // class-expression parent does. + let static_proto = super::super::class_registry::class_static_prototype(child); + if !static_proto.is_null() + && ordinary_has_property(static_proto as *const ObjectHeader, key) + { + return true; + } let proto = super::super::class_registry::class_prototype_object(child); if !proto.is_null() && ordinary_has_property(proto as *const ObjectHeader, key) { return true; diff --git a/crates/perry-runtime/src/object/object_ops/define_properties.rs b/crates/perry-runtime/src/object/object_ops/define_properties.rs index 2d954808b4..035e9cf118 100644 --- a/crates/perry-runtime/src/object/object_ops/define_properties.rs +++ b/crates/perry-runtime/src/object/object_ops/define_properties.rs @@ -371,13 +371,18 @@ pub extern "C" fn js_object_set_prototype_of(obj_value: f64, proto: f64) -> f64 // Partial.ast // // Ordinary object and closure targets already have prototype side tables, - // but the ClassRef previously fell through as a no-op. The class-static - // inheritance walk already consults CLASS_PROTOTYPE_OBJECTS, so record an - // ordinary object prototype there. A null prototype clears an earlier - // link. Other valid prototype kinds retain their existing behavior. + // but the ClassRef previously fell through as a no-op. Record it in + // CLASS_STATIC_PROTOTYPES — the CONSTRUCTOR-side table. + // + // It must not go in CLASS_PROTOTYPE_OBJECTS: that table means "what + // INSTANCES of this class inherit from", so parking a constructor link + // there makes `new Opaque().ast` resolve the static (Node: undefined) and + // makes prototype-method mirroring write into `schema` itself. A null + // prototype clears an earlier link. Other valid prototype kinds retain + // their existing behavior. if let Some(class_id) = super::super::class_ref_id(obj_value) { if proto_is_null { - super::super::class_registry::class_prototype_object_root_clear(class_id); + super::super::class_registry::class_static_prototype_root_clear(class_id); return obj_value; } if (proto_bits & 0xFFFF_0000_0000_0000) == POINTER_TAG { @@ -396,7 +401,7 @@ pub extern "C" fn js_object_set_prototype_of(obj_value: f64, proto: f64) -> f64 } && is_valid_obj_ptr(proto_ptr as *const u8) { - super::super::class_registry::class_prototype_object_root_store( + super::super::class_registry::class_static_prototype_root_store( class_id, proto_ptr, ); return obj_value; diff --git a/crates/perry-runtime/src/object/object_ops/prototype.rs b/crates/perry-runtime/src/object/object_ops/prototype.rs index 3ed6d5fdee..b9b56c2c8b 100644 --- a/crates/perry-runtime/src/object/object_ops/prototype.rs +++ b/crates/perry-runtime/src/object/object_ops/prototype.rs @@ -361,6 +361,19 @@ pub extern "C" fn js_object_get_prototype_of(obj_value: f64) -> f64 { }; if top16 == 0x7FFE { let class_id = (bits & 0xFFFF_FFFF) as u32; + // An explicit `Object.setPrototypeOf(Ctor, obj)` wins over every + // derived answer below — it IS the constructor's [[Prototype]]. + if super::super::class_prototype_ref_id(obj_value).is_none() { + let static_proto = super::super::class_registry::class_static_prototype(class_id); + if !static_proto.is_null() { + return f64::from_bits( + crate::value::js_nanbox_pointer(static_proto as i64).to_bits(), + ); + } + if super::super::class_registry::class_static_prototype_is_nulled(class_id) { + return f64::from_bits(TAG_NULL); + } + } if super::super::class_prototype_ref_id(obj_value).is_none() { // A class whose heritage is a runtime function value has no Perry // parent class id. Its constructor's [[Prototype]] is that exact diff --git a/crates/perry/tests/issue_5763_setprototypeof_chain_end.rs b/crates/perry/tests/issue_5763_setprototypeof_chain_end.rs index 63a84c9286..2bd8be7cf6 100644 --- a/crates/perry/tests/issue_5763_setprototypeof_chain_end.rs +++ b/crates/perry/tests/issue_5763_setprototypeof_chain_end.rs @@ -211,3 +211,93 @@ console.log("cycle detected:", threw); "a genuine prototype cycle must still be rejected with a TypeError" ); } + +/// `Object.setPrototypeOf(Ctor, obj)` sets the CONSTRUCTOR's [[Prototype]]. +/// It must not put `obj` on the chain instances inherit from, and it must not +/// mutate `obj` itself. Perry records the link in CLASS_STATIC_PROTOTYPES for +/// exactly that reason: `CLASS_PROTOTYPE_OBJECTS` means "what instances inherit +/// from", so storing it there made `new Opaque().ast` resolve the static and +/// made prototype-method mirroring write into the user's object. +#[test] +fn set_prototype_of_class_ref_is_invisible_to_instances() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +const schema = { ast: "STATIC-ONLY", greet() { return "static-only"; } }; +class Opaque {} +Object.setPrototypeOf(Opaque, schema); + +const inst = new Opaque(); +console.log(inst.ast); +console.log(typeof inst.greet); +try { inst.greet(); console.log("NO THROW"); } catch (e) { console.log("throws " + e.constructor.name); } +console.log("ast" in inst, "greet" in inst); +console.log(JSON.stringify(inst)); + +// The constructor side still sees them. +console.log(Opaque.ast, typeof Opaque.greet, Opaque.greet()); + +// Installing a prototype method must not write into `schema`. +Opaque.prototype.added = function () { return "proto-method"; }; +console.log(JSON.stringify(Object.keys(schema))); +console.log(Object.prototype.hasOwnProperty.call(schema, "added")); +"#, + ); + assert_eq!( + stdout, + concat!( + "undefined\n", + "undefined\n", + "throws TypeError\n", + "false false\n", + "{}\n", + "STATIC-ONLY function static-only\n", + "[\"ast\",\"greet\"]\n", + "false\n", + ), + "the constructor's [[Prototype]] must stay off the instance chain and \ + out of the user's object" + ); +} + +/// The static side of the same link: an inherited static DATA read, a direct +/// static METHOD call on the class ref (which used to throw "is not a +/// function"), and `Object.getPrototypeOf` all resolve through it. +#[test] +fn set_prototype_of_class_ref_serves_the_whole_static_side() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +const schema = { ast: 1, make(x) { return x + 1; } }; +class Opaque {} +Object.setPrototypeOf(Opaque, schema); +class Derived extends Opaque {} + +console.log(Object.getPrototypeOf(Opaque) === schema); +console.log(Opaque.ast, Derived.ast); +console.log(Opaque.make(1), Derived.make(2)); +console.log("ast" in Opaque, "make" in Derived, "nope" in Derived); + +const alias = Opaque; +console.log(alias.make(10)); + +Object.setPrototypeOf(Opaque, null); +console.log(Opaque.ast, Derived.ast, Object.getPrototypeOf(Opaque)); +"#, + ); + assert_eq!( + stdout, + concat!( + "true\n", + "1 1\n", + "2 3\n", + "true true false\n", + "11\n", + "undefined undefined null\n", + ), + "static reads, static calls, `in`, and getPrototypeOf must all route \ + through the recorded constructor prototype, and clear with it" + ); +} diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 7121c47e3a..329aa447ff 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -1790,6 +1790,19 @@ "name": "WINDOW_ROOTS", "verdict": "not_a_gc_pointer", "why": "Window-root registry maps numeric window handles to numeric root-widget handles; neither value is a JavaScript heap pointer." + }, + { + "file": "crates/perry-runtime/src/object/class_registry/state.rs", + "name": "CLASS_STATIC_PROTOTYPES", + "verdict": "covered_elsewhere", + "scanner": "object::class_registry::gc_roots::scan_class_side_table_roots_mut and its budgeted step twin (class_side_table_root_snapshot enumerates ClassSideTableRootSlot::StaticPrototype; scan_class_side_table_root_slot visits that slot)", + "why": "Constructor-side [[Prototype]] recorded by Object.setPrototypeOf(Ctor, obj) on a declared class. Holds a real heap ObjectHeader address as usize, so it is visited with visit_usize_slot in BOTH the full and budgeted class-side-table walks, exactly like the CLASS_DECL_PROTOTYPE_OBJECTS entries beside it, and class_static_prototype_root_store fires runtime_write_barrier_root_raw_ptr on the stored pointer." + }, + { + "file": "crates/perry-runtime/src/object/class_registry/state.rs", + "name": "CLASS_STATIC_PROTOTYPE_NULLED", + "verdict": "not_a_gc_pointer", + "why": "Set of class ids whose constructor [[Prototype]] was explicitly set to null, so Object.getPrototypeOf answers null rather than the default Function.prototype. Stores u32 class ids only \u2014 no heap address, nothing to trace or forward." } ], "_FRONTIER_README": "Identity-pinned debt ratchet over new perry-ui* candidates and otherwise-unclassified core perry_thread_local! declarations (see the census docstring, \u201cThe identity-pinned frontier\u201d). A new uncovered holder fails until it is scanned, receives a researched holders verdict, or is deliberately pinned as debt. Moving a researched false positive to holders graduates it from this list. A fixed or classified holder makes its old frontier pin stale, so the receipt must be deleted.", From 749491ff874d5c505fa5e27ae051b40ba3700fe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 31 Aug 2026 01:14:30 +0200 Subject: [PATCH 6/6] fix(runtime): a colliding anon-shape id must not steal a class's prototype MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Object.getPrototypeOf(instance)` returned `Object.prototype` for instances of a real declared class, so the standard prototype-preserving clone Object.create(Object.getPrototypeOf(ast), descriptors) produced objects carrying every own field but none of the class's methods. OpenCode died at startup on exactly that: Effect's `SchemaAST` `modifyOwnPropertyDescriptors` clones AST nodes that way, and the cloned `Union` lost `recur` — "TypeError: recur is not a function" while initializing `SchemaRepresentation.ts` (`Schema.toCodecJson($Document)`). Cause: class ids are handed out per module, so one module's anon-shape id can be the same number as another module's DECLARED class. Effect's monomorphized `Union$AST` collided with one; once that module's init ran `js_register_anon_shape_class_id`, `is_anon_shape_class_id` started answering true for the declared class and `js_object_get_prototype_of` skipped its declared-class fast path. The same class id was observed reporting both states in one run: [gp] cid=1121 anon_shape=false name=Some("Union$AST") methods=[.., "recur"] [gp] cid=1121 anon_shape=true name=Some("Union$AST") methods=[.., "recur"] That is also why it only appeared at scale: a 102-module graph never collides, 245+ does. Instance method dispatch kept working throughout (it resolves through the vtable), so only reflection saw the damage. A registered class name plus a non-empty prototype vtable is positive evidence of a real declared class — an anon shape has neither — so `declared_class_outranks_anon_shape` lets the declared class keep its reflective prototype through the collision. This does not fix the underlying per-module id collision, which can still affect other id-keyed lookups and deserves its own issue. Verified: the full OpenCode source graph (2,954 modules, 0 JavaScript fallbacks, 235 MB) now runs — `--version` prints `local` and `--help` renders its command list, both exit 0, matching this PR's acceptance output for the first time on macOS. perry-runtime 2,827 (incl. 2 new), perry-codegen 1,349, perry bin 1,068, perry-hir 364, perry-transform 121, all lint gates clean. Claude-Session: https://claude.ai/code/session_01P3bPE5eJQT4vQ6wf8P9JDN --- .../src/object/class_registry.rs | 10 +- .../src/object/class_registry/class_meta.rs | 95 +++++++++++++++++++ .../src/object/object_ops/prototype.rs | 20 +++- 3 files changed, 118 insertions(+), 7 deletions(-) diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index 33483dbd29..4f625d8125 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -97,11 +97,11 @@ pub(crate) use class_meta::test_text_encoding_stream_new_with_constructor; #[cfg(feature = "global-text")] pub(crate) use class_meta::text_decoder_bool_option; pub use class_meta::{ - class_length_for_id, class_name_for_id, is_anon_shape_class_id, js_compression_stream_new, - js_decompression_stream_new, js_register_anon_shape_class_id, js_register_class_id, - js_register_class_length, js_register_class_name, js_text_decoder_stream_new, - js_text_encoder_stream_new, js_text_encoding_stream_new, ANON_SHAPE_CLASS_IDS, CLASS_LENGTHS, - CLASS_NAMES, + class_length_for_id, class_name_for_id, declared_class_outranks_anon_shape, + is_anon_shape_class_id, js_compression_stream_new, js_decompression_stream_new, + js_register_anon_shape_class_id, js_register_class_id, js_register_class_length, + js_register_class_name, js_text_decoder_stream_new, js_text_encoder_stream_new, + js_text_encoding_stream_new, ANON_SHAPE_CLASS_IDS, CLASS_LENGTHS, CLASS_NAMES, }; pub(crate) use class_meta::{ identify_global_builtin_constructor, report_dispatch_miss, diff --git a/crates/perry-runtime/src/object/class_registry/class_meta.rs b/crates/perry-runtime/src/object/class_registry/class_meta.rs index 6531914c6a..1329d2603d 100644 --- a/crates/perry-runtime/src/object/class_registry/class_meta.rs +++ b/crates/perry-runtime/src/object/class_registry/class_meta.rs @@ -518,6 +518,28 @@ pub unsafe extern "C" fn js_register_anon_shape_class_id(class_id: u32) { guard.as_mut().unwrap().insert(class_id); } +/// True when `class_id` is marked as an anon shape but is really a DECLARED +/// class, so reflective lookups must prefer the declared class. +/// +/// Class ids are handed out per module, so one module's anon-shape id can be +/// the same number as another module's declared class. When that module's init +/// runs `js_register_anon_shape_class_id`, every instance of the unrelated +/// declared class starts reporting `Object.prototype` from +/// `Object.getPrototypeOf` — and the standard prototype-preserving clone +/// `Object.create(Object.getPrototypeOf(x), descriptors)` then yields an object +/// with the fields but none of the methods. Effect's `SchemaAST` hit this: its +/// monomorphized `Union$AST` collided with an anon-shape id, so +/// `modifyOwnPropertyDescriptors` produced Unions whose `recur` was gone +/// ("recur is not a function" at OpenCode startup). +/// +/// A registered class NAME plus a non-empty prototype vtable is positive +/// evidence of a real declared class; an anon shape has neither. +pub fn declared_class_outranks_anon_shape(class_id: u32) -> bool { + is_anon_shape_class_id(class_id) + && class_name_for_id(class_id).is_some() + && !super::class_decl_prototype_method_names(class_id).is_empty() +} + /// True if `class_id` was registered via `js_register_anon_shape_class_id`. pub fn is_anon_shape_class_id(class_id: u32) -> bool { if class_id == 0 { @@ -530,3 +552,76 @@ pub fn is_anon_shape_class_id(class_id: u32) -> bool { } false } + +#[cfg(test)] +mod anon_shape_collision_tests { + use super::*; + use crate::object::class_registry::state::{ + ClassVTable, VTableMethodEntry, CLASS_VTABLE_REGISTRY, + }; + use std::collections::HashMap; + + fn seed_declared_class(class_id: u32, name: &str, method: &str) { + unsafe { js_register_class_name(class_id, name.as_ptr(), name.len() as u32) }; + let mut methods = HashMap::new(); + methods.insert( + method.to_string(), + VTableMethodEntry { + func_ptr: 0x1000, + param_count: 1, + has_synthetic_arguments: false, + has_rest: false, + }, + ); + let mut guard = CLASS_VTABLE_REGISTRY.write().unwrap(); + if guard.is_none() { + *guard = Some(HashMap::new()); + } + guard.as_mut().unwrap().insert( + class_id, + ClassVTable { + methods, + getters: HashMap::new(), + setters: HashMap::new(), + }, + ); + } + + /// Class ids are allocated per module, so one module's anon-shape id can + /// collide with another module's declared class. The declared class must + /// keep its reflective prototype, or `Object.getPrototypeOf(instance)` + /// starts answering `Object.prototype` and the standard clone + /// `Object.create(Object.getPrototypeOf(x), descriptors)` silently drops + /// every method (Effect's `Union$AST` → "recur is not a function"). + #[test] + fn declared_class_outranks_a_colliding_anon_shape_id() { + let class_id = 0x4321_0001; + seed_declared_class(class_id, "Union$AST", "recur"); + + assert!( + !declared_class_outranks_anon_shape(class_id), + "a class that was never marked an anon shape needs no override" + ); + + unsafe { js_register_anon_shape_class_id(class_id) }; + assert!(is_anon_shape_class_id(class_id)); + assert!( + declared_class_outranks_anon_shape(class_id), + "a named class with a prototype vtable must outrank the colliding \ + anon-shape marking" + ); + } + + /// A real anon shape has neither a registered name nor a vtable, so it must + /// keep reporting the ordinary object prototype. + #[test] + fn a_genuine_anon_shape_does_not_outrank_itself() { + let class_id = 0x4321_0002; + unsafe { js_register_anon_shape_class_id(class_id) }; + assert!(is_anon_shape_class_id(class_id)); + assert!( + !declared_class_outranks_anon_shape(class_id), + "an unnamed, vtable-less anon shape must not be treated as declared" + ); + } +} diff --git a/crates/perry-runtime/src/object/object_ops/prototype.rs b/crates/perry-runtime/src/object/object_ops/prototype.rs index b9b56c2c8b..ea5d825b19 100644 --- a/crates/perry-runtime/src/object/object_ops/prototype.rs +++ b/crates/perry-runtime/src/object/object_ops/prototype.rs @@ -529,9 +529,25 @@ pub extern "C" fn js_object_get_prototype_of(obj_value: f64) -> f64 { // class_id 0 / anonymous-shape / unregistered ids), so synthetic // function-ctor instances and plain objects keep the existing // `constructor`-based resolution unchanged. + // A declared class keeps its reflective prototype even when its + // id is ALSO marked as an anon shape. Class ids are allocated + // per module, so one module's anon-shape id can collide with + // another's declared class (observed: Effect's monomorphized + // `Union$AST`, whose instances started reporting + // `Object.prototype` once an unrelated module's init registered + // the same number). `Object.create(Object.getPrototypeOf(ast), + // descriptors)` — the standard prototype-preserving clone, used + // by SchemaAST's `modifyOwnPropertyDescriptors` — then produced + // objects with none of the class's methods. A registered class + // name plus a non-empty prototype vtable is positive evidence of + // a real declared class, so prefer it over the collision. + let instance_class_id = (*obj).class_id; if (*gc).obj_type == crate::gc::GC_TYPE_OBJECT - && (*obj).class_id != 0 - && !is_anon_shape_class_id((*obj).class_id) + && instance_class_id != 0 + && (!is_anon_shape_class_id(instance_class_id) + || super::super::class_registry::declared_class_outranks_anon_shape( + instance_class_id, + )) { if let Some(proto) = super::super::class_registry::class_decl_prototype_value_for_instance_class(