diff --git a/changelog.d/10636-implicit-ctor-native-super-forward.md b/changelog.d/10636-implicit-ctor-native-super-forward.md new file mode 100644 index 0000000000..26f7109546 --- /dev/null +++ b/changelog.d/10636-implicit-ctor-native-super-forward.md @@ -0,0 +1,16 @@ +Fixed a constructor-less subclass of a native base (`AsyncResource`, +`AsyncLocalStorage`, `EventEmitter`, `EventEmitterAsyncResource`, `LRUCache`, +`WebSocketServer`, the genuine `node:stream` classes) losing its `super()` +argument forwarding and native-surface install inside a CommonJS-wrapped +module — the shape real npm packages use. `const { AsyncResource } = +require("node:async_hooks")` is a genuine local there (the whole module body +runs inside the CJS wrap's IIFE), which class-heritage resolution could not +tell apart from a real user shadow of the same name, so it fell back to a +generic dynamic-value dispatch. For a base whose runtime value is a real ES +`class` (`AsyncResource`, `AsyncLocalStorage`), that dispatch called the value +without `new` and threw; for an old-style-function base (`EventEmitter`, the +stream classes) it happened to work, through a much slower indirect path +(measured ~5.5x more instructions per construction than the direct native +path). Class-heritage resolution now tracks a `require()`-destructured +binding's provenance and only treats it as shadowing when it did NOT come +from the real native module. diff --git a/crates/perry-hir/src/destructuring/var_decl_sources.rs b/crates/perry-hir/src/destructuring/var_decl_sources.rs index 9897b1719a..2bb91915ac 100644 --- a/crates/perry-hir/src/destructuring/var_decl_sources.rs +++ b/crates/perry-hir/src/destructuring/var_decl_sources.rs @@ -218,6 +218,46 @@ pub(super) fn register_destructured_stream_ctors( return Vec::new(); }; + // #10623: record the destructuring's PROVENANCE (local binding -> the + // export key it was destructured from) whenever the RHS resolves to a + // real native/Node-builtin module — regardless of the #8342 CJS-wrapper + // gate immediately below. Inside a CJS-wrapped module that gate skips the + // FULL native-module-alias registration (member reads/calls must fall + // through to the wrapper's real runtime `require(...)` there), but the + // destructured identifier is still genuinely bound FROM that native + // module at runtime. Class-heritage resolution (`class_decl.rs`) needs + // exactly that narrower fact to avoid treating `class X extends + // AsyncResource {}` as user-shadowed just because the CJS wrapper makes + // every top-level `const` a real local — without it, `super()` (explicit + // or the implicit default derived ctor) fell back to a generic + // call-the-value dispatch that neither installs the native base's surface + // nor tolerates bases whose runtime value enforces real ES `class` + // `[[Call]]` semantics (`AsyncResource` throws "cannot be invoked without + // 'new'"). + if require_resolvable_native_specifier(init).is_some() { + for prop in &obj_pat.props { + let (key, binding) = match prop { + ast::ObjectPatProp::Assign(assign) => { + let name = assign.key.sym.to_string(); + (name.clone(), name) + } + ast::ObjectPatProp::KeyValue(kv) => { + let key = match &kv.key { + ast::PropName::Ident(i) => i.sym.to_string(), + ast::PropName::Str(s) => s.value.as_str().unwrap_or("").to_string(), + _ => continue, + }; + let ast::Pat::Ident(binding) = kv.value.as_ref() else { + continue; + }; + (key, binding.id.sym.to_string()) + } + ast::ObjectPatProp::Rest(_) => continue, + }; + ctx.require_destructured_native_locals.insert(binding, key); + } + } + // #8342: inside a CJS-wrapped module the wrap's synthetic // `function require(...)` shadows the bare global `require`, and its // built-in arm resolves `require("process")` etc. via `createRequire` at diff --git a/crates/perry-hir/src/lower/context.rs b/crates/perry-hir/src/lower/context.rs index 53f7cadf01..5b27e57f3e 100644 --- a/crates/perry-hir/src/lower/context.rs +++ b/crates/perry-hir/src/lower/context.rs @@ -26,7 +26,7 @@ use crate::ir::*; /// `b/util.ts` are `a_util_ts` and `b_util_ts` — distinct salts, and /// cross-module capture chains stay isolated exactly as before. Same module ⇒ /// same salt ⇒ same-module inheritance keeps sharing parent stashes. -fn stable_module_salt(module_identity: &str) -> u64 { +pub(crate) fn stable_module_salt(module_identity: &str) -> u64 { let mut h: u64 = 0xcbf29ce484222325; for b in module_identity.as_bytes() { h ^= u64::from(*b); @@ -36,213 +36,6 @@ fn stable_module_salt(module_identity: &str) -> u64 { } impl LoweringContext { - // #854: single-arg constructor (delegates to `with_class_id_start`). - // Currently only exercised from the `#[cfg(test)]` lowering tests, so it - // reads as dead in a non-test build. Kept as the canonical entry point. - #[allow(dead_code)] - pub fn new(source_file_path: impl Into) -> Self { - Self::with_class_id_start(source_file_path, 1) - } - - pub fn with_class_id_start( - source_file_path: impl Into, - start_class_id: ClassId, - ) -> Self { - // No module name available (the `#[cfg(test)]` lowering entry points). - // Salting on the path preserves the pre-#7177 behaviour for those; the - // production path below passes the module name. - let source_file_path = source_file_path.into(); - let identity = source_file_path.clone(); - Self::with_class_id_start_salted(source_file_path, identity, start_class_id) - } - - /// #7177: as [`Self::with_class_id_start`], but salts the module's - /// `__perry_cap_*` names on `salt_identity` — the module NAME — instead of - /// its absolute source path, so the emitted symbols do not change with the - /// checkout location. - pub fn with_class_id_start_salted( - source_file_path: impl Into, - salt_identity: impl Into, - start_class_id: ClassId, - ) -> Self { - let source_file_path = source_file_path.into(); - let module_identity = salt_identity.into(); - let tagged_template_site_salt = stable_module_salt(&module_identity); - Self { - next_local_id: 0, - local_source_spans: HashMap::new(), - classic_for_lexical_bindings: HashSet::new(), - next_global_id: 0, - next_func_id: 0, - next_class_id: start_class_id, // Start from the provided ID to avoid collisions across modules - next_enum_id: 0, - next_interface_id: 0, - next_type_alias_id: 0, - tagged_template_site_salt, - next_tagged_template_site_id: 0, - locals: crate::lower::Locals::new(), - globals: Vec::new(), - functions: Vec::new(), - func_defaults: Vec::new(), - classes: Vec::new(), - class_statics: Vec::new(), - class_field_names: HashMap::new(), - class_accessor_names: HashMap::new(), - class_method_names: HashMap::new(), - class_native_extends: Vec::new(), - class_field_types: HashMap::new(), - enums: Vec::new(), - pending_body_enums: Vec::new(), - interfaces: Vec::new(), - type_aliases: Vec::new(), - native_profile_type_aliases: HashMap::new(), - immutable_locals: HashSet::new(), - interface_source_keys: std::collections::HashMap::new(), - interface_object_types: std::collections::HashMap::new(), - imported_functions: Vec::new(), - builtin_named_imports: Vec::new(), - native_modules: Vec::new(), - builtin_module_aliases: Vec::new(), - subns_path_aliases: HashMap::new(), - type_param_scopes: Vec::new(), - type_param_constraints: Vec::new(), - native_instances: Vec::new(), - param_native_hints: HashMap::new(), - current_strict: false, - ui_widget_type_aliases: HashMap::new(), - deferred_unknown_native_imports: HashMap::new(), - current_class: None, - current_class_scope_depth: None, - current_class_inner_name: None, - pending_class_inner_name: None, - class_expr_self_bindings: Vec::new(), - current_class_member_is_static: false, - private_scopes: Vec::new(), - object_super_home_stack: Vec::new(), - extern_func_types: Vec::new(), - source_file_path, - empty_site_width_hints: std::collections::HashMap::new(), - exportable_object_vars: HashSet::new(), - pending_functions: Vec::new(), - closure_display_names: HashMap::new(), - class_display_names: HashMap::new(), - gen_param_prologue_len: HashMap::new(), - assignment_inferred_name: None, - inferred_class_bindings: Default::default(), - closure_source_text: HashMap::new(), - class_source_text: HashMap::new(), - func_return_native_instances: Vec::new(), - pending_classes: Vec::new(), - func_return_types: Vec::new(), - resolved_types: None, - pre_registered_module_vars: HashSet::new(), - pre_registered_module_var_decls: HashSet::new(), - script_var_decl_names: HashSet::new(), - module_level_ids: HashSet::new(), - sloppy_implicit_globals: Vec::new(), - sloppy_implicit_global_ids: HashSet::new(), - with_sloppy_implicit_ids: std::collections::HashMap::new(), - pending_with_implicit_inits: Vec::new(), - scope_depth: 0, - scope_local_marks: Vec::new(), - scope_module_shadow_marks: Vec::new(), - inside_block_scope: 0, - for_of_force_lazy: false, - namespace_vars: Vec::new(), - current_namespace: None, - module_native_instances: Vec::new(), - local_id_native_instances: HashMap::new(), - uses_fetch: false, - uses_webassembly: false, - react_default_import_local: None, - suppress_stdlib_dispatch_guard_once: false, - lowering_call_callee: false, - unresolved_ident_as_global: false, - global_intrinsic_new_once: false, - with_env_stack: Vec::new(), - var_hoisted_ids: HashSet::new(), - tdz_forward_ids: HashSet::new(), - forward_lexical_names: HashSet::new(), - forward_lexical_saves: Vec::new(), - catch_param_scopes: Vec::new(), - annexb_block_fn_var_ids: HashMap::new(), - annexb_block_fn_names_all: HashSet::new(), - block_fn_decl_bindings: HashMap::new(), - lexical_forward_decls: HashMap::new(), - nested_forward_scope_ids: HashSet::new(), - functions_index: HashMap::new(), - classes_index: HashMap::new(), - imported_functions_index: HashMap::new(), - builtin_module_aliases_index: HashMap::new(), - native_instances_index: HashMap::new(), - module_native_instances_index: HashMap::new(), - func_return_native_instances_index: HashMap::new(), - prescan_protected_native_params: std::collections::HashMap::new(), - native_modules_index: HashMap::new(), - module_shadow_stack: Vec::new(), - class_statics_index: HashMap::new(), - weakref_locals: HashSet::new(), - finreg_locals: HashSet::new(), - weakmap_locals: HashSet::new(), - weakset_locals: HashSet::new(), - namespace_import_locals: HashSet::new(), - fetch_call_response_locals: HashSet::new(), - namespace_import_sources: std::collections::HashMap::new(), - generator_func_names: HashSet::new(), - async_generator_func_names: HashSet::new(), - nested_generator_forward_referenced: HashSet::new(), - iterator_func_for_class: std::collections::HashMap::new(), - proxy_locals: HashSet::new(), - proxy_local_ids: HashSet::new(), - builtin_proto_method_locals: HashMap::new(), - plain_object_locals: HashSet::new(), - proxy_revoke_locals: HashMap::new(), - class_expr_aliases: HashMap::new(), - in_constructor_class: None, - current_class_is_derived: false, - in_class_field_init: false, - current_class_super_ident: None, - mixin_funcs: HashMap::new(), - anon_shape_classes: HashMap::new(), - anon_shape_fields: HashMap::new(), - closed_shape_literal_locals: HashMap::new(), - prefer_exported_method_shape_seed: false, - forward_class_names: std::collections::HashSet::new(), - forward_class_decl_depth: std::collections::HashMap::new(), - class_renames: std::collections::HashMap::new(), - next_class_rename_id: 0, - module_class_decl_names: std::collections::HashSet::new(), - class_decl_names_any_depth: std::collections::HashSet::new(), - next_anon_shape_id: 0, - class_method_return_types: Vec::new(), - class_captures: Vec::new(), - body_class_expr_captures: Vec::new(), - let_class_aliases: Vec::new(), - global_this_aliases: HashSet::new(), - prototype_aliases: HashMap::new(), - prototype_function_aliases: HashMap::new(), - function_valued_locals: HashSet::new(), - prototype_function_locals: HashMap::new(), - object_static_method_aliases: HashMap::new(), - array_static_method_aliases: HashMap::new(), - is_entry_module: false, - platform_globals: HashSet::new(), - saw_global_this_expr: false, - reassigned_top_level_identifiers: HashSet::new(), - module_strict: false, - strict_mode_stack: Vec::new(), - is_external_module: false, - optional_require_try_depth: 0, - require_local_is_create_require: false, - import_meta_require_local: None, - fn_ctor_env: super::fn_ctor_env::FnCtorEnv::default(), - dynamic_function_subclasses: HashMap::new(), - expr_lower_depth: 0, - prelowered_member_receiver: None, - in_nonarrow_fn: false, - } - } - pub(crate) fn fresh_tagged_template_site_id(&mut self) -> u64 { let local_id = self.next_tagged_template_site_id; self.next_tagged_template_site_id = self.next_tagged_template_site_id.wrapping_add(1); diff --git a/crates/perry-hir/src/lower/context_new.rs b/crates/perry-hir/src/lower/context_new.rs new file mode 100644 index 0000000000..7bf9399924 --- /dev/null +++ b/crates/perry-hir/src/lower/context_new.rs @@ -0,0 +1,220 @@ +//! `LoweringContext::new()` / `with_class_id_start[_salted]()` — extracted +//! from `context.rs` for the 2000-line cap (#10623's `require_destructured_ +//! native_locals` field pushed it to 2001). Pure relocation: no logic +//! changes, and no visibility narrowing — `stable_module_salt` widened from +//! module-private to `pub(crate)` so this sibling module can still call it. + +use std::collections::{HashMap, HashSet}; + +use super::*; +use crate::ir::*; + +impl LoweringContext { + // #854: single-arg constructor (delegates to `with_class_id_start`). + // Currently only exercised from the `#[cfg(test)]` lowering tests, so it + // reads as dead in a non-test build. Kept as the canonical entry point. + #[allow(dead_code)] + pub fn new(source_file_path: impl Into) -> Self { + Self::with_class_id_start(source_file_path, 1) + } + + pub fn with_class_id_start( + source_file_path: impl Into, + start_class_id: ClassId, + ) -> Self { + // No module name available (the `#[cfg(test)]` lowering entry points). + // Salting on the path preserves the pre-#7177 behaviour for those; the + // production path below passes the module name. + let source_file_path = source_file_path.into(); + let identity = source_file_path.clone(); + Self::with_class_id_start_salted(source_file_path, identity, start_class_id) + } + + /// #7177: as [`Self::with_class_id_start`], but salts the module's + /// `__perry_cap_*` names on `salt_identity` — the module NAME — instead of + /// its absolute source path, so the emitted symbols do not change with the + /// checkout location. + pub fn with_class_id_start_salted( + source_file_path: impl Into, + salt_identity: impl Into, + start_class_id: ClassId, + ) -> Self { + let source_file_path = source_file_path.into(); + let module_identity = salt_identity.into(); + let tagged_template_site_salt = super::context::stable_module_salt(&module_identity); + Self { + next_local_id: 0, + local_source_spans: HashMap::new(), + classic_for_lexical_bindings: HashSet::new(), + next_global_id: 0, + next_func_id: 0, + next_class_id: start_class_id, // Start from the provided ID to avoid collisions across modules + next_enum_id: 0, + next_interface_id: 0, + next_type_alias_id: 0, + tagged_template_site_salt, + next_tagged_template_site_id: 0, + locals: crate::lower::Locals::new(), + globals: Vec::new(), + functions: Vec::new(), + func_defaults: Vec::new(), + classes: Vec::new(), + class_statics: Vec::new(), + class_field_names: HashMap::new(), + class_accessor_names: HashMap::new(), + class_method_names: HashMap::new(), + class_native_extends: Vec::new(), + class_field_types: HashMap::new(), + enums: Vec::new(), + pending_body_enums: Vec::new(), + interfaces: Vec::new(), + type_aliases: Vec::new(), + native_profile_type_aliases: HashMap::new(), + immutable_locals: HashSet::new(), + interface_source_keys: std::collections::HashMap::new(), + interface_object_types: std::collections::HashMap::new(), + imported_functions: Vec::new(), + builtin_named_imports: Vec::new(), + native_modules: Vec::new(), + require_destructured_native_locals: HashMap::new(), + builtin_module_aliases: Vec::new(), + subns_path_aliases: HashMap::new(), + type_param_scopes: Vec::new(), + type_param_constraints: Vec::new(), + native_instances: Vec::new(), + param_native_hints: HashMap::new(), + current_strict: false, + ui_widget_type_aliases: HashMap::new(), + deferred_unknown_native_imports: HashMap::new(), + current_class: None, + current_class_scope_depth: None, + current_class_inner_name: None, + pending_class_inner_name: None, + class_expr_self_bindings: Vec::new(), + current_class_member_is_static: false, + private_scopes: Vec::new(), + object_super_home_stack: Vec::new(), + extern_func_types: Vec::new(), + source_file_path, + empty_site_width_hints: std::collections::HashMap::new(), + exportable_object_vars: HashSet::new(), + pending_functions: Vec::new(), + closure_display_names: HashMap::new(), + class_display_names: HashMap::new(), + gen_param_prologue_len: HashMap::new(), + assignment_inferred_name: None, + inferred_class_bindings: Default::default(), + closure_source_text: HashMap::new(), + class_source_text: HashMap::new(), + func_return_native_instances: Vec::new(), + pending_classes: Vec::new(), + func_return_types: Vec::new(), + resolved_types: None, + pre_registered_module_vars: HashSet::new(), + pre_registered_module_var_decls: HashSet::new(), + script_var_decl_names: HashSet::new(), + module_level_ids: HashSet::new(), + sloppy_implicit_globals: Vec::new(), + sloppy_implicit_global_ids: HashSet::new(), + with_sloppy_implicit_ids: std::collections::HashMap::new(), + pending_with_implicit_inits: Vec::new(), + scope_depth: 0, + scope_local_marks: Vec::new(), + scope_module_shadow_marks: Vec::new(), + inside_block_scope: 0, + for_of_force_lazy: false, + namespace_vars: Vec::new(), + current_namespace: None, + module_native_instances: Vec::new(), + local_id_native_instances: HashMap::new(), + uses_fetch: false, + uses_webassembly: false, + react_default_import_local: None, + suppress_stdlib_dispatch_guard_once: false, + lowering_call_callee: false, + unresolved_ident_as_global: false, + global_intrinsic_new_once: false, + with_env_stack: Vec::new(), + var_hoisted_ids: HashSet::new(), + tdz_forward_ids: HashSet::new(), + forward_lexical_names: HashSet::new(), + forward_lexical_saves: Vec::new(), + catch_param_scopes: Vec::new(), + annexb_block_fn_var_ids: HashMap::new(), + annexb_block_fn_names_all: HashSet::new(), + block_fn_decl_bindings: HashMap::new(), + lexical_forward_decls: HashMap::new(), + nested_forward_scope_ids: HashSet::new(), + functions_index: HashMap::new(), + classes_index: HashMap::new(), + imported_functions_index: HashMap::new(), + builtin_module_aliases_index: HashMap::new(), + native_instances_index: HashMap::new(), + module_native_instances_index: HashMap::new(), + func_return_native_instances_index: HashMap::new(), + prescan_protected_native_params: std::collections::HashMap::new(), + native_modules_index: HashMap::new(), + module_shadow_stack: Vec::new(), + class_statics_index: HashMap::new(), + weakref_locals: HashSet::new(), + finreg_locals: HashSet::new(), + weakmap_locals: HashSet::new(), + weakset_locals: HashSet::new(), + namespace_import_locals: HashSet::new(), + fetch_call_response_locals: HashSet::new(), + namespace_import_sources: std::collections::HashMap::new(), + generator_func_names: HashSet::new(), + async_generator_func_names: HashSet::new(), + nested_generator_forward_referenced: HashSet::new(), + iterator_func_for_class: std::collections::HashMap::new(), + proxy_locals: HashSet::new(), + proxy_local_ids: HashSet::new(), + builtin_proto_method_locals: HashMap::new(), + plain_object_locals: HashSet::new(), + proxy_revoke_locals: HashMap::new(), + class_expr_aliases: HashMap::new(), + in_constructor_class: None, + current_class_is_derived: false, + in_class_field_init: false, + current_class_super_ident: None, + mixin_funcs: HashMap::new(), + anon_shape_classes: HashMap::new(), + anon_shape_fields: HashMap::new(), + closed_shape_literal_locals: HashMap::new(), + prefer_exported_method_shape_seed: false, + forward_class_names: std::collections::HashSet::new(), + forward_class_decl_depth: std::collections::HashMap::new(), + class_renames: std::collections::HashMap::new(), + next_class_rename_id: 0, + module_class_decl_names: std::collections::HashSet::new(), + class_decl_names_any_depth: std::collections::HashSet::new(), + next_anon_shape_id: 0, + class_method_return_types: Vec::new(), + class_captures: Vec::new(), + body_class_expr_captures: Vec::new(), + let_class_aliases: Vec::new(), + global_this_aliases: HashSet::new(), + prototype_aliases: HashMap::new(), + prototype_function_aliases: HashMap::new(), + function_valued_locals: HashSet::new(), + prototype_function_locals: HashMap::new(), + object_static_method_aliases: HashMap::new(), + array_static_method_aliases: HashMap::new(), + is_entry_module: false, + platform_globals: HashSet::new(), + saw_global_this_expr: false, + reassigned_top_level_identifiers: HashSet::new(), + module_strict: false, + strict_mode_stack: Vec::new(), + is_external_module: false, + optional_require_try_depth: 0, + require_local_is_create_require: false, + import_meta_require_local: None, + fn_ctor_env: super::fn_ctor_env::FnCtorEnv::default(), + dynamic_function_subclasses: HashMap::new(), + expr_lower_depth: 0, + prelowered_member_receiver: None, + in_nonarrow_fn: false, + } + } +} diff --git a/crates/perry-hir/src/lower/lowering_context.rs b/crates/perry-hir/src/lower/lowering_context.rs index 1eddfaed43..3c44999141 100644 --- a/crates/perry-hir/src/lower/lowering_context.rs +++ b/crates/perry-hir/src/lower/lowering_context.rs @@ -275,6 +275,22 @@ pub struct LoweringContext { /// For namespace imports (import * as x), method_name is None /// For named imports (import { v4 as uuid }), method_name is Some("v4") pub(crate) native_modules: Vec<(String, String, Option)>, + /// #10623: `const { Key } = require("")` + /// destructured bindings, keyed by the LOCAL binding name -> the + /// destructured export KEY (identity for the common unaliased case). + /// Recorded unconditionally, even inside a CJS-wrapped module where + /// `register_destructured_stream_ctors` deliberately skips the full + /// `native_modules` alias registration (#8342: the wrapper's synthetic + /// `require(...)` returns a real runtime value there, so the static + /// native-namespace fast path is not safe to use for ordinary property + /// reads/calls). Class-heritage resolution (`class_decl.rs`) is a + /// narrower consumer: it only needs "was this identifier bound FROM a + /// require() of a real native module", to avoid treating `class X + /// extends AsyncResource {}` as user-shadowed merely because the CJS + /// wrapper makes every top-level `const` a genuine local. Not itself a + /// module/value resolution table — do not use it for anything requiring + /// runtime-accurate native-module semantics. + pub(crate) require_destructured_native_locals: HashMap, /// Built-in module aliases from require(): local_name -> module_name (e.g., "myFs" -> "fs") pub(crate) builtin_module_aliases: Vec<(String, String)>, /// Stack of type parameter scopes (for nested generics) diff --git a/crates/perry-hir/src/lower/mod.rs b/crates/perry-hir/src/lower/mod.rs index 1831d4b68f..d6ae8d5f0d 100644 --- a/crates/perry-hir/src/lower/mod.rs +++ b/crates/perry-hir/src/lower/mod.rs @@ -37,6 +37,7 @@ pub(crate) mod ambient; pub(crate) mod builder_fold; mod context; +mod context_new; pub(crate) use context::perry_ui_factory_returns_handle; pub(crate) mod expr_assign; mod expr_call; diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index bc4c1f498f..2f3660611a 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -1934,55 +1934,6 @@ fn aliased_native_imports_canonicalize_class_heritage() { assert!(watcher.extends_expr.is_none()); } -/// #8882: a module-level class constructing a sibling class that is declared -/// inside a function body lowered LATER. This is the shape the CJS wrap -/// produces for Next's `server/lib/lru-cache.js`: `LRUCache` is hoisted out of -/// the module IIFE while `SentinelNode` (whose doc comment closes on the -/// `class` line, so the textual hoister never sees it) stays inside the -/// `__perry_cjs_factory` closure. JS binds the constructor reference when the -/// `new` executes; the #8643 guard instead lowered it to an unconditional, -/// nameless `ReferenceError` that killed the application at init. -#[test] -fn hoisted_class_constructs_sibling_declared_inside_a_later_closure() { - let source = r#" - class LRUCache { - constructor() { - this.head = new SentinelNode(); - this.tail = new SentinelNode(); - } - } - const _cjs = (function () { - class SentinelNode { - constructor() { - this.prev = null; - this.next = null; - } - } - return { SentinelNode }; - })(); - "#; - let module = perry_parser::parse_typescript(source, "lru-cache.js").expect("source parses"); - let hir = super::lower_module(&module, "lru-cache", "lru-cache.js").expect("source lowers"); - let lru_cache = hir - .classes - .iter() - .find(|class| class.name == "LRUCache") - .expect("LRUCache class is lowered"); - let debug = format!("{lru_cache:?}"); - - assert!( - !debug.contains("js_throw_reference_error_unresolved_get") - && !debug.contains("js_global_get_or_throw_unresolved"), - "a sibling class declared later in the module must not lower to a \ - compile-time ReferenceError:\n{debug}" - ); - assert_eq!( - debug.matches(r#"New { class_name: "SentinelNode""#).count(), - 2, - "both `new SentinelNode()` sites must stay late-bound by-name constructs:\n{debug}" - ); -} - mod ambient_declare; mod unresolved_new_global; @@ -1998,3 +1949,7 @@ mod class_expr_subclass_captures; mod nullish_over_optional_chain; mod subclass_ctor_inherited_method; mod ui_widget_add_child; + +mod issue_10623_require_destructured_native_super; + +mod hoisted_sibling_in_later_closure; diff --git a/crates/perry-hir/src/lower/tests/hoisted_sibling_in_later_closure.rs b/crates/perry-hir/src/lower/tests/hoisted_sibling_in_later_closure.rs new file mode 100644 index 0000000000..c53b6028b5 --- /dev/null +++ b/crates/perry-hir/src/lower/tests/hoisted_sibling_in_later_closure.rs @@ -0,0 +1,49 @@ +//! #8882: a module-level class constructing a sibling class that is declared +//! inside a function body lowered LATER. This is the shape the CJS wrap +//! produces for Next's `server/lib/lru-cache.js`: `LRUCache` is hoisted out of +//! the module IIFE while `SentinelNode` (whose doc comment closes on the +//! `class` line, so the textual hoister never sees it) stays inside the +//! `__perry_cjs_factory` closure. JS binds the constructor reference when the +//! `new` executes; the #8643 guard instead lowered it to an unconditional, +//! nameless `ReferenceError` that killed the application at init. + +#[test] +fn hoisted_class_constructs_sibling_declared_inside_a_later_closure() { + let source = r#" + class LRUCache { + constructor() { + this.head = new SentinelNode(); + this.tail = new SentinelNode(); + } + } + const _cjs = (function () { + class SentinelNode { + constructor() { + this.prev = null; + this.next = null; + } + } + return { SentinelNode }; + })(); + "#; + let module = perry_parser::parse_typescript(source, "lru-cache.js").expect("source parses"); + let hir = super::lower_module(&module, "lru-cache", "lru-cache.js").expect("source lowers"); + let lru_cache = hir + .classes + .iter() + .find(|class| class.name == "LRUCache") + .expect("LRUCache class is lowered"); + let debug = format!("{lru_cache:?}"); + + assert!( + !debug.contains("js_throw_reference_error_unresolved_get") + && !debug.contains("js_global_get_or_throw_unresolved"), + "a sibling class declared later in the module must not lower to a \ + compile-time ReferenceError:\n{debug}" + ); + assert_eq!( + debug.matches(r#"New { class_name: "SentinelNode""#).count(), + 2, + "both `new SentinelNode()` sites must stay late-bound by-name constructs:\n{debug}" + ); +} diff --git a/crates/perry-hir/src/lower/tests/issue_10623_require_destructured_native_super.rs b/crates/perry-hir/src/lower/tests/issue_10623_require_destructured_native_super.rs new file mode 100644 index 0000000000..cf831859e4 --- /dev/null +++ b/crates/perry-hir/src/lower/tests/issue_10623_require_destructured_native_super.rs @@ -0,0 +1,148 @@ +//! #10623: `class NoCtor extends AsyncResource {}` — a constructor-less +//! subclass of a native base obtained via `const { AsyncResource } = +//! require("node:async_hooks")` — must still resolve `AsyncResource` as the +//! NATIVE parent (`native_extends`), not as a dynamically-shadowed local +//! (`extends_expr`). Split from `tests.rs` for the 2000-line cap. +//! +//! A CJS-wrapped module runs its whole body inside the wrap's synthetic +//! `require(...)` IIFE (see `test_cjs_wrapper_lru_cache_destructure_uses_ +//! static_constructor` above for the same simulated-wrapper shape), so every +//! top-level `const` there — including `const { AsyncResource } = +//! require(...)` — is a genuine local. Before the fix, `class_decl.rs`'s +//! `locally_shadowed` check could not tell that apart from a real user +//! shadow (`const AsyncResource = MyOwnClass`), so it always took the dynamic +//! `extends_expr` path and lost the native install + argument forwarding. + +fn cjs_wrapper_source(body: &str) -> String { + format!( + 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; + }} + {body} + "# + ) +} + +/// The issue's exact shape: no own constructor. Must resolve as the native +/// parent, forwarding the `new`-site args to `super()` implicitly. +#[test] +fn cjs_destructured_async_resource_implicit_ctor_uses_native_parent() { + let source = cjs_wrapper_source( + r#" + const { AsyncResource } = require("node:async_hooks"); + class NoCtor extends AsyncResource {} + const a = new NoCtor("MyResource"); + "#, + ); + 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 class = hir + .classes + .iter() + .find(|c| c.name == "NoCtor") + .expect("NoCtor is lowered"); + assert_eq!( + class.native_extends, + Some(("async_hooks".to_string(), "AsyncResource".to_string())), + "a require()-destructured AsyncResource must resolve as the native \ + parent, not a dynamically-shadowed local: {class:#?}" + ); + assert!( + class.extends_expr.is_none(), + "the native parent must not ALSO be captured as a dynamic \ + extends_expr (that is the pre-fix shadowed-local path): {class:#?}" + ); +} + +/// The explicit-`super()` control: this form must keep resolving natively +/// too — before the fix it took the SAME broken dynamic path (the issue's +/// claim that the explicit form "already works" held only for an ESM import, +/// not for this CJS shape). +#[test] +fn cjs_destructured_async_resource_explicit_ctor_uses_native_parent() { + let source = cjs_wrapper_source( + r#" + const { AsyncResource } = require("node:async_hooks"); + class WithCtor extends AsyncResource { + constructor(type: string) { super(type); } + } + const b = new WithCtor("MyResource2"); + "#, + ); + 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 class = hir + .classes + .iter() + .find(|c| c.name == "WithCtor") + .expect("WithCtor is lowered"); + assert_eq!( + class.native_extends, + Some(("async_hooks".to_string(), "AsyncResource".to_string())), + "the explicit-ctor form must ALSO resolve as the native parent: {class:#?}" + ); +} + +/// A class EXPRESSION reaches a separate lowering arm +/// (`lower_class_from_ast`) with its own copy of the shadow check — pin it +/// too so the fix is not name-keyed to only the declaration form. +#[test] +fn cjs_destructured_async_resource_class_expr_uses_native_parent() { + let source = cjs_wrapper_source( + r#" + const { AsyncResource } = require("node:async_hooks"); + const Anon = class extends AsyncResource {}; + const inst = new Anon("AnonResource"); + "#, + ); + 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 class = hir + .classes + .iter() + .find(|c| c.name == "Anon") + .expect("the class expression is lowered"); + assert_eq!( + class.native_extends, + Some(("async_hooks".to_string(), "AsyncResource".to_string())), + "a class EXPRESSION extending a require()-destructured native base \ + must ALSO resolve natively: {class:#?}" + ); +} + +/// Guards the other side: GENUINE shadowing (the user's own value, not a +/// require() re-export) must still take the dynamic `extends_expr` path — +/// the fix narrows the false positive, it does not remove the real check. +#[test] +fn cjs_local_shadowing_a_native_name_still_goes_dynamic() { + let source = cjs_wrapper_source( + r#" + class MyOwnAsyncResource { tag = "mine"; } + const AsyncResource = MyOwnAsyncResource; + class NoCtor extends AsyncResource {} + const a = new NoCtor(); + "#, + ); + 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 class = hir + .classes + .iter() + .find(|c| c.name == "NoCtor") + .expect("NoCtor is lowered"); + assert!( + class.native_extends.is_none(), + "a genuine user shadow of the native name must NOT resolve natively: {class:#?}" + ); + assert!( + class.extends_expr.is_some(), + "a genuine user shadow must still route through the dynamic parent: {class:#?}" + ); +} diff --git a/crates/perry-hir/src/lower_decl/class_decl.rs b/crates/perry-hir/src/lower_decl/class_decl.rs index 862d5abdbb..ae640e409b 100644 --- a/crates/perry-hir/src/lower_decl/class_decl.rs +++ b/crates/perry-hir/src/lower_decl/class_decl.rs @@ -39,9 +39,11 @@ fn is_genuine_node_stream_parent(ctx: &LoweringContext, name: &str) -> bool { } mod class_heritage; +mod from_ast; mod member_helpers; mod member_registration; use class_heritage::*; +pub(crate) use from_ast::lower_class_from_ast; pub(crate) use member_helpers::capture_class_source; use member_helpers::{ generic_computed_member_key, lower_generic_computed_class_member, @@ -253,8 +255,31 @@ pub fn lower_class_decl( // path, not the native `events` parent. ESM imports are not in // `ctx.locals`, so genuine native subclassing is unchanged. Mirrors // the class-expression arm below. + // + // #10623: a CJS-wrapped module is the odd one out — EVERY + // top-level `const` there is a genuine local (the whole module + // body runs inside the wrap's IIFE), so `const { AsyncResource } = + // require("node:async_hooks")` looks identical to true user + // shadowing under the check above. Distinguish them by + // PROVENANCE, not by re-deriving the name: `parent_name` shadows + // only if it was NOT also destructured from a require() of a real + // native module with this same export key + // (`require_destructured_native_locals`, populated unconditionally + // in `var_decl_sources.rs` regardless of the #8342 CJS-wrapper + // gate that skips the FULL native-module-alias registration for + // the same binding). A class expression / indirect subclass never + // reaches this check with anything but the immediate `extends` + // identifier, so this does not change the "keyed on the literal + // extends name" failure mode described in CLAUDE.md — it only + // widens what counts as "not actually shadowed" for that one + // identifier. + let require_native_reexport = ctx + .require_destructured_native_locals + .get(&parent_name) + .is_some_and(|key| *key == canonical_parent_name); let locally_shadowed = !ctx.class_renames.contains_key(&parent_name) - && ctx.locals.lookup(&parent_name).is_some(); + && ctx.locals.lookup(&parent_name).is_some() + && !require_native_reexport; if native_parent.is_some() && !locally_shadowed { // Keep `extends_name` populated alongside `native_extends` // so SuperCall codegen + downstream chain walks still @@ -1262,715 +1287,3 @@ pub fn lower_class_decl( specialized_from: None, }) } - -/// Lower a class expression (ast::Class) to HIR. -/// Used for anonymous class expressions like `new (class extends Command { ... })()`. -pub fn lower_class_from_ast( - ctx: &mut LoweringContext, - class: &ast::Class, - name: &str, - is_exported: bool, -) -> Result { - validate_legacy_decorator_surface(class, name)?; - validate_class_element_early_errors(class, name)?; - let class_id = match ctx.lookup_class(name) { - Some(id) => id, - None => { - let id = ctx.fresh_class(); - ctx.register_class(name.to_string(), id); - id - } - }; - capture_class_source(ctx, class_id, class); - - let old_class = ctx.current_class.take(); - ctx.current_class = Some(name.to_string()); - let old_class_scope_depth = ctx.current_class_scope_depth.replace(ctx.scope_depth); - let old_inner_name = ctx.current_class_inner_name.take(); - // A class-expression caller stashes the source ident here; fall back - // to the (possibly synthetic) registration name when absent. - let explicit_inner_name = ctx.pending_class_inner_name.take(); - ctx.current_class_inner_name = explicit_inner_name - .clone() - .or_else(|| Some(name.to_string())); - let old_is_derived = ctx.current_class_is_derived; - ctx.current_class_is_derived = class.super_class.is_some(); - - // Private-name scope for this class-expression body (see lower_class_decl). - ctx.push_private_scope(super::build_private_scope(class, name, class_id)); - - // Issue #562: same as the parallel `lower_class_decl` arm — track the - // parent class identifier so super({...}) controller-param pre-scan - // fires for stream subclasses. - let old_super_ident = ctx.current_class_super_ident.take(); - ctx.current_class_super_ident = match class.super_class.as_deref() { - Some(ast::Expr::Ident(ident)) => Some(ident.sym.to_string()), - _ => None, - }; - - let type_params = class - .type_params - .as_ref() - .map(|tp| extract_type_params(tp)) - .unwrap_or_default(); - - ctx.enter_type_param_scope(&type_params); - - // #5437: parent Ident shadowed by an in-scope lexical local? (See the - // matching computation in `lower_class_decl`.) Lets codegen prefer the - // dynamic local over a NAME-keyed built-in special case. - let heritage_lexically_shadowed = match class.super_class.as_deref() { - Some(ast::Expr::Ident(ident)) => { - let n = ident.sym.to_string(); - !ctx.class_renames.contains_key(&n) && ctx.locals.lookup(&n).is_some() - } - _ => false, - }; - - let (extends, extends_name, native_extends, extends_expr) = if let Some(ref super_class) = - class.super_class - { - if explicit_inner_name - .as_deref() - .is_some_and(|inner| is_class_self_heritage(super_class, inner)) - { - ( - None, - None, - None, - Some(Box::new(crate::lower::throw_reference_error_expr( - "js_throw_reference_error_this_before_super", - ))), - ) - } else if let ast::Expr::Ident(ident) = super_class.as_ref() { - let parent_name = ident.sym.to_string(); - let canonical_parent_name = canonical_native_parent_name(ctx, &parent_name) - .unwrap_or(&parent_name) - .to_string(); - let native_parent = match canonical_parent_name.as_str() { - "EventEmitter" => Some(("events".to_string(), "EventEmitter".to_string())), - "EventEmitterAsyncResource" => Some(( - "events".to_string(), - "EventEmitterAsyncResource".to_string(), - )), - "AsyncLocalStorage" => { - Some(("async_hooks".to_string(), "AsyncLocalStorage".to_string())) - } - "AsyncResource" => Some(("async_hooks".to_string(), "AsyncResource".to_string())), - "WebSocketServer" => Some(("ws".to_string(), "WebSocketServer".to_string())), - // #10293: lru-cache's LRUCache is a compile-time lowering with - // no runtime value; recognising it here routes `extends` to the - // subclass-init path instead of the dynamic parent registration - // that throws "Class extends value is not a constructor". - "LRUCache" => Some(("lru-cache".to_string(), "LRUCache".to_string())), - // Issue #562: keep in lockstep with the parallel arm in - // `lower_class_decl` above. - "ReadableStream" => { - Some(("readable_stream".to_string(), "ReadableStream".to_string())) - } - "WritableStream" => { - Some(("writable_stream".to_string(), "WritableStream".to_string())) - } - "TransformStream" => Some(( - "transform_stream".to_string(), - "TransformStream".to_string(), - )), - // #1545: classic node:stream base classes — keep in lockstep - // with the parallel arm in `lower_class_decl` above. Gated on - // `is_genuine_node_stream_parent` so a userland stream-shim - // binding (readable-stream's `Transform`) falls through to the - // dynamic `extends_expr` parent path. - "Readable" | "Writable" | "Duplex" | "Transform" - if is_genuine_node_stream_parent(ctx, &parent_name) => - { - Some(("node_stream".to_string(), canonical_parent_name.clone())) - } - _ => None, - }; - // A lexical local binding shadowing the parent name must win over the - // native/static parent — the in-scope local IS the real parent value. - // Check it BEFORE `native_parent` so e.g. `const EventEmitter = …; - // const C = class extends EventEmitter {}` routes through the dynamic - // `extends_expr` path (the local) instead of recording the native - // `events` parent. ESM imports are NOT in `ctx.locals`, so genuine - // `extends EventEmitter` (imported) still takes the native path. - let locally_shadowed = !ctx.class_renames.contains_key(&parent_name) - && ctx.locals.lookup(&parent_name).is_some(); - if native_parent.is_some() && !locally_shadowed { - (None, Some(canonical_parent_name), native_parent, None) - } else if locally_shadowed { - // #5437 (Next.js p-queue `PQueue` inside a minified bundle): a - // class EXPRESSION whose parent Ident is an IN-SCOPE LOCAL - // (`const t = require("events"); … class extends t {…}`) must - // bind to that LEXICAL local — not to an unrelated module-global - // class that happens to share the (minified, single-letter) - // name. The static `lookup_class(parent_name)` path keys - // codegen's `super()` on a module-wide `HashMap`; - // in a turbopack chunk dozens of distinct webpack-factory - // classes are all named `t`/`u`/`i`, so that map keeps ONE `t` - // (whichever registered last) and `super()` inlines the WRONG - // class's constructor. The bundle's p-queue `PQueue extends t` - // (eventemitter3) resolved `t` to superstruct's `StructError` - // base, so `new PQueue()` ran StructError's destructuring ctor - // on the (undefined) options arg → "Cannot convert undefined or - // null to object" → HTTP 500 on the dynamic page routes. - // - // When the parent name is bound by a local in THIS body's scope, - // route through the dynamic `extends_expr` path: lower the Ident - // as a runtime value (the lexically-correct local), register the - // parent edge dynamically, and let `super()` invoke the real - // parent value via `js_fetch_or_value_super` (which already - // tolerates native / closure / class-ref / builtin parents). - // Gated on `!class_renames.contains_key` so the #5437 - // sibling-rename path above still wins when a scope-local class - // rename exists (that disambiguation is exact). Pure-Ident - // module-global heritage (no shadowing local) is unaffected — - // `ctx.locals.lookup` returns `None` for a class name. - // Do NOT set a static `extends` (parent_cid) OR `extends_name` - // here: the only candidate is `lookup_class(parent_name)`, the - // wrong same-named module-global class we deliberately avoid — and - // a retained `extends_name` is re-resolved back to it by the - // static parent-chain walks (layout / parent-edge / inherited- - // method / vtable / type-facts), corrupting the subclass. The - // dynamic `extends_expr` path registers the correct parent edge at - // runtime via `RegisterClassParentDynamic` + `function_class_id`. - match lower_class_heritage_expr(ctx, super_class) { - Ok(expr) => (None, None, None, Some(Box::new(expr))), - Err(_) => (None, None, None, None), - } - } else { - // #5437: resolve the parent through active scope-local class - // renames so a class EXPRESSION extending a disambiguated - // same-named sibling (`f` -> `f$0`) binds to the right class. - // See the matching fix in `lower_class_decl` above. - let parent_name = ctx.resolve_class_name(&parent_name); - let parent_cid = ctx.lookup_class(&parent_name); - if parent_cid.is_none() { - // Issue #711 part 2: see the parallel arm in - // `lower_class_decl` above. Unknown Ident super-class - // falls through to extends_expr capture so a - // function-with-prototype value can be resolved at - // runtime via `function_class_id`. - match lower_class_heritage_expr(ctx, super_class) { - Ok(expr) => (None, Some(parent_name), None, Some(Box::new(expr))), - Err(_) => (None, Some(parent_name), None, None), - } - } else { - (parent_cid, Some(parent_name), None, None) - } - } - } else if let ast::Expr::Member(member) = super_class.as_ref() { - // Refs #488 drizzle-sqlite: try cross-module class lookup. See - // the matching arm in `lower_class_decl` (above) for the full - // rationale — without this, the parent link is lost and - // inherited methods don't reach instances. - let parent_name = extract_member_class_name(member); - // Issue #4908: avoid a self-referential parent edge when the - // member's trailing property equals the subclass's own name - // (`class Agent extends http.Agent`). See the matching guard in - // `lower_class_decl` above — a self-link loops codegen's - // parent-chain walk forever. Leave the class parentless, matching - // the non-colliding native-member-base behavior. - if parent_name == name { - (None, None, None, None) - } else if parent_name == "default" { - // `class X extends _mod.default` — the interop ESM - // default-export-class pattern. Keep in lockstep with the - // matching `.default` arm in `lower_class_decl` above: route - // through `extends_expr` so `super()` re-evaluates the alias - // at construction time and the parent edge is registered. - match lower_class_heritage_expr(ctx, super_class) { - Ok(expr) => (None, Some(parent_name), None, Some(Box::new(expr))), - Err(_) => (None, Some(parent_name), None, None), - } - } else { - // Named cross-module member-extends — route through `extends_expr` - // UNCONDITIONALLY so `super()` runs the parent ctor at runtime even - // when the parent isn't in codegen's class table / not yet lowered. - // Keep in lockstep with the matching arm in `lower_class_decl` - // (wall 48: NodeNextRequest extends _index.BaseNextRequest). - let resolved = ctx.lookup_class(&parent_name); - match lower_class_heritage_expr(ctx, super_class) { - Ok(expr) => (resolved, Some(parent_name), None, Some(Box::new(expr))), - Err(_) => (resolved, Some(parent_name), None, None), - } - } - } else { - // Issue #711: see the matching arm in `lower_class_decl` above - // for the full rationale. Capture the lowered extends - // expression so codegen can evaluate it at the class - // declaration site and call - // `js_register_class_parent_dynamic` at runtime. - match lower_class_heritage_expr(ctx, super_class) { - Ok(expr) => (None, None, None, Some(Box::new(expr))), - Err(_) => (None, None, None, None), - } - } - } else { - (None, None, None, None) - }; - - // Issue #10486: mirrors the capture-forwarding fallback in - // `lower_class_decl` above (see its comment for the full rationale) — - // a class EXPRESSION extending a lexically-local capture-bearing class - // EXPRESSION (`const Base = class {…}; const Sub = class extends Base - // {…}`) needs the alias-resolved heritage identifier for capture - // lookup even when `extends_name` was deliberately left None for - // class-registry resolution. - // See the matching guard in `lower_class_decl` above: skip the - // fallback when this class expression has its own explicit - // constructor (its `super(...)` already forwards correctly). - let has_own_constructor = class - .body - .iter() - .any(|m| matches!(m, ast::ClassMember::Constructor(_))); - let capture_parent_name: Option = extends_name.clone().or_else(|| { - if has_own_constructor { - return None; - } - class.super_class.as_deref().and_then(|sc| match sc { - ast::Expr::Ident(ident) => { - let raw = ident.sym.to_string(); - Some(ctx.resolve_class_alias(&raw).unwrap_or(raw)) - } - _ => None, - }) - }); - - let mut static_field_names = Vec::new(); - let mut static_method_names = Vec::new(); - for member in &class.body { - match member { - // See note above: static getters/setters are not callable methods. - ast::ClassMember::Method(method) - if method.is_static && matches!(method.kind, ast::MethodKind::Method) => - { - if let ast::PropName::Ident(ident) = &method.key { - static_method_names.push(ident.sym.to_string()); - } - } - ast::ClassMember::PrivateMethod(method) - if method.is_static && matches!(method.kind, ast::MethodKind::Method) => - { - static_method_names.push(format!("#{}", method.key.name)); - } - ast::ClassMember::ClassProp(prop) if prop.is_static && !prop.declare => { - if let ast::PropName::Ident(ident) = &prop.key { - static_field_names.push(ident.sym.to_string()); - } - } - ast::ClassMember::PrivateProp(prop) if prop.is_static => { - static_field_names.push(format!("#{}", prop.key.name)); - } - _ => {} - } - } - ctx.register_class_statics(name.to_string(), static_field_names, static_method_names); - - let mut fields = Vec::new(); - let mut static_fields = Vec::new(); - let mut constructor = None; - let mut methods = Vec::new(); - let mut static_methods = Vec::new(); - let mut getters = Vec::new(); - let mut setters = Vec::new(); - // Parallel staticness, so `record_class_accessor` can tell a static - // accessor from an instance one with the same name. - let mut getter_statics: Vec = Vec::new(); - let mut setter_statics: Vec = Vec::new(); - let mut static_accessor_names: Vec = Vec::new(); - let mut static_accessor_fn_ids: Vec = Vec::new(); - let mut computed_members = Vec::new(); - let mut seen_generic_computed_member = false; - - for (member_index, member) in class.body.iter().enumerate() { - match member { - ast::ClassMember::Constructor(ctor) => { - constructor = Some(lower_constructor(ctx, name, ctor)?); - } - ast::ClassMember::Method(method) => { - // Skip TypeScript overload declarations (no body) - if method.function.body.is_none() { - continue; - } - if let Some(computed) = generic_computed_member_key(ctx, method) { - computed_members.push(lower_generic_computed_class_member( - ctx, - method, - computed, - member_index, - )?); - seen_generic_computed_member = true; - continue; - } - let (prop_name, can_source_order_register) = match &method.key { - ast::PropName::Ident(ident) => (ident.sym.to_string(), true), - ast::PropName::Str(s) => (s.value.as_str().unwrap_or("").to_string(), true), - // Numeric-literal member names — see the parallel arm in - // `lower_class_decl`. Canonical ToString of the value. - ast::PropName::Num(n) => (crate::lower::number_to_js_key(n.value), true), - // `[Symbol.iterator]() {}` / `*[Symbol.iterator]() {}` on a - // class *expression* — mirror the declaration path so - // `new (class { *[Symbol.iterator]() {…} })()` is iterable - // for spread, `Array.from`, destructuring, and manual - // `obj[Symbol.iterator]()` calls (#5128). The generator lift - // happens in the `Method` arm below. - ast::PropName::Computed(computed) if is_symbol_iterator_key(&computed.expr) => { - ("@@iterator".to_string(), false) - } - ast::PropName::Computed(computed) - if is_inspect_custom_key(ctx, &computed.expr) - && !method.is_static - && matches!(method.kind, ast::MethodKind::Method) => - { - // Refs #1248: see class_decl.rs Method handling above. - ("__perry_inspect_custom__".to_string(), false) - } - // Other well-known-symbol keys (`[Symbol.asyncIterator]`, - // `[Symbol.toPrimitive]`, `[Symbol.dispose]` / - // `[Symbol.asyncDispose]`, `static [Symbol.hasInstance]`, - // `get [Symbol.toStringTag]`) on a class *expression* — - // same handling as the declaration path, via the shared - // helper. Pre-fix these fell through `_ => continue` and - // were silently dropped, so e.g. `for await (… of new (C = - // class { [Symbol.asyncIterator]() {…} })())` threw - // `TypeError: value is not iterable`. - ast::PropName::Computed(_) => { - match lower_well_known_computed_method(ctx, method, name)? { - Some(WellKnownComputedMethod::Rename(renamed)) => (renamed, false), - Some( - WellKnownComputedMethod::Lifted - | WellKnownComputedMethod::Unsupported, - ) - | None => continue, - } - } - _ => continue, - }; - match method.kind { - ast::MethodKind::Getter => { - let func = with_static_member_context(ctx, method.is_static, |ctx| { - lower_getter_method(ctx, method) - })?; - if seen_generic_computed_member && can_source_order_register { - computed_members.push(lower_noncomputed_class_member_registration( - ctx, - method, - &prop_name, - member_index, - )?); - } - if method.is_static { - static_accessor_names.push(prop_name.clone()); - static_accessor_fn_ids.push(func.id); - } - record_class_accessor( - &mut getters, - &mut getter_statics, - prop_name, - func, - method.is_static, - ); - } - ast::MethodKind::Setter => { - let func = with_static_member_context(ctx, method.is_static, |ctx| { - lower_setter_method(ctx, method) - })?; - if seen_generic_computed_member && can_source_order_register { - computed_members.push(lower_noncomputed_class_member_registration( - ctx, - method, - &prop_name, - member_index, - )?); - } - if method.is_static { - static_accessor_names.push(prop_name.clone()); - static_accessor_fn_ids.push(func.id); - } - record_class_accessor( - &mut setters, - &mut setter_statics, - prop_name, - func, - method.is_static, - ); - } - ast::MethodKind::Method => { - let mut func = with_static_member_context(ctx, method.is_static, |ctx| { - lower_class_method(ctx, method) - })?; - // `*[Symbol.iterator]()` — lift to a top-level generator - // and register a synthetic `@@iterator` wrapper (#5128), - // exactly as the class-declaration path does above. - if prop_name == "@@iterator" && func.is_generator && !method.is_static { - let wrapper = synthesize_symbol_iterator_wrapper(ctx, name, &mut func); - let ast::PropName::Computed(computed) = &method.key else { - unreachable!("@@iterator generator key must be computed"); - }; - // The computed-symbol registration installs the - // runtime dispatch alias too. Registering the wrapper - // as a string method also exposed an own "@@iterator" - // property that the source never declared (#9788). - computed_members.push(ClassComputedMember { - key_expr: lower_expr(ctx, &computed.expr)?, - function: wrapper, - is_static: false, - kind: ClassComputedMemberKind::Method, - source_order: member_index, - }); - continue; - } - if seen_generic_computed_member && can_source_order_register { - computed_members.push(lower_noncomputed_class_member_registration( - ctx, - method, - &prop_name, - member_index, - )?); - } - if method.is_static { - static_methods.push(func); - } else { - methods.push(func); - } - } - } - } - ast::ClassMember::ClassProp(prop) => { - // `declare` and `abstract` fields are type-only: TypeScript - // erases them entirely (`node --experimental-strip-types` - // emits no runtime slot). Materializing an abstract base-class - // field creates a phantom slot that shadows the concrete - // subclass initializer of the same name — a base/union-typed - // read then resolves to the (undefined) base slot. Skip both. - if prop.declare || prop.is_abstract { - continue; - } - // Computed-key fields (`[Symbol.for("k")] = init`) flow through - // here for both instance AND static positions. - // `lower_class_prop` captures the key expression in - // `ClassField.key_expr` for runtime evaluation. Refs #420 — - // drizzle's `static [entityKind] = "Table"` is the canonical - // static-computed-key pattern; codegen's `init_static_fields` - // detects `key_expr.is_some()` and emits a runtime - // registration into the class-static-symbol side table. - let field = lower_class_prop(ctx, prop)?; - if prop.is_static { - static_fields.push(field); - } else { - fields.push(field); - } - } - ast::ClassMember::PrivateProp(prop) => { - let field = lower_private_prop(ctx, prop)?; - if prop.is_static { - static_fields.push(field); - } else { - fields.push(field); - } - } - ast::ClassMember::PrivateMethod(method) => { - if method.function.body.is_none() { - continue; - } - match method.kind { - ast::MethodKind::Method => { - let func = lower_private_method(ctx, method)?; - if method.is_static { - static_methods.push(func); - } else { - methods.push(func); - } - } - ast::MethodKind::Getter => { - let prop_name = format!("#{}", method.key.name); - let func = lower_private_getter(ctx, method)?; - // Static private accessor — register on the static - // side (see the matching arm in `lower_class_decl`). - if method.is_static { - static_accessor_names.push(prop_name.clone()); - static_accessor_fn_ids.push(func.id); - } - record_class_accessor( - &mut getters, - &mut getter_statics, - prop_name, - func, - method.is_static, - ); - } - ast::MethodKind::Setter => { - let prop_name = format!("#{}", method.key.name); - let func = lower_private_setter(ctx, method)?; - if method.is_static { - static_accessor_names.push(prop_name.clone()); - static_accessor_fn_ids.push(func.id); - } - record_class_accessor( - &mut setters, - &mut setter_statics, - prop_name, - func, - method.is_static, - ); - } - } - } - ast::ClassMember::StaticBlock(block) => { - let scope_mark = ctx.enter_scope(); - let saved_in_nonarrow_fn = ctx.in_nonarrow_fn; - ctx.in_nonarrow_fn = true; - // A static block is its own var-scope (OrdinaryFunctionCreate - // per ClassStaticBlockDefinitionEvaluation): `lower_block_stmt` - // only lowers nested statements without hoisting `var`s to this - // boundary, so a `var` declared in one block leaked into the - // next block/module scope instead of staying local (test262 - // static-init-scope-var-close.js). - let body = lower_fn_body_block_stmt(ctx, &block.body)?; - ctx.exit_scope(scope_mark); - ctx.in_nonarrow_fn = saved_in_nonarrow_fn; - - let block_idx = static_methods - .iter() - .filter(|m| m.name.starts_with("__perry_static_init_")) - .count(); - let synthetic_name = format!("__perry_static_init_{}", block_idx); - static_methods.push(Function { - id: ctx.fresh_func(), - name: synthetic_name, - type_params: Vec::new(), - params: Vec::new(), - return_type: Type::Void, - body, - is_async: false, - is_generator: false, - is_strict: true, - was_plain_async: false, - was_unrolled: false, - is_exported: false, - captures: Vec::new(), - decorators: Vec::new(), - }); - } - _ => {} - } - } - - // `this` in static field initializers — see the matching substitution in - // `lower_class_decl` above. - for sf in &mut static_fields { - if let Some(init) = &mut sf.init { - crate::analysis::substitute_lexical_this_in_expr( - init, - &Expr::ClassRef(name.to_string()), - ); - } - } - - ctx.exit_type_param_scope(); - // Issue #562: see the parallel site in `lower_class_decl` — register - // native_extends so subclass instances of the three Web Stream base - // classes route through the parent stream module's dispatch table. - if let Some((module, class)) = native_extends.as_ref() { - ctx.register_class_native_extends(name.to_string(), module.clone(), class.clone()); - } - ctx.current_class = old_class; - ctx.current_class_scope_depth = old_class_scope_depth; - ctx.current_class_inner_name = old_inner_name; - ctx.current_class_is_derived = old_is_derived; - ctx.pop_private_scope(); - // Issue #562: restore prior super-ident slot. - ctx.current_class_super_ident = old_super_ident; - - // Phase 4.1: register method + getter return types — see the parallel - // site in lower_class_decl. - for m in &methods { - if !matches!(m.return_type, Type::Any) { - ctx.register_class_method_return_type( - name.to_string(), - m.name.clone(), - m.return_type.clone(), - ); - } - } - for (prop_name, g) in &getters { - if !matches!(g.return_type, Type::Any) { - ctx.register_class_method_return_type( - name.to_string(), - prop_name.clone(), - g.return_type.clone(), - ); - } - } - - // Mirror `lower_class_decl`: register the union of this class's accessor - // names (own get/set, including private and the parent chain) so the - // assignment recogniser in `expr_assign.rs` treats `C.prototype. - // = v` as a setter INVOCATION instead of a prototype-method monkey-patch. - // `lower_class_decl` registers these for class declarations; without the - // parallel call here, a class EXPRESSION's instance setters (e.g. - // `var C = class { set ''(p){…} }; C.prototype[''] = v`) were silently - // dropped to `RegisterPrototypeMethod`. Test262 accessor-name-inst setters. - { - let mut accessor_names = runtime_instance_accessor_names(&class.body); - if let Some(ref parent_name) = extends_name { - if let Some(parent_accessors) = ctx.lookup_class_accessor_names(parent_name) { - accessor_names.extend_from(parent_accessors); - } - } - ctx.register_class_accessor_names(name.to_string(), accessor_names); - } - - // Issue #740: synthesize __perry_cap_* capture machinery for class - // expressions that reference enclosing-fn locals (e.g. `const Inner = - // class { _tag = tag }` inside `function makeFactory(tag)`). Without - // this, anon class expressions silently dropped captures while named - // class declarations had the machinery via `lower_class_decl`. See - // the helper's doc comment for the full description. - synthesize_class_captures( - ctx, - name, - capture_parent_name.as_deref(), - extends.is_some() - || extends_name.is_some() - || native_extends.is_some() - || extends_expr.is_some(), - &mut fields, - &mut methods, - &mut getters, - &mut setters, - &mut computed_members, - &mut constructor, - &mut static_methods, - ); - - Ok(Class { - id: class_id, - name: name.to_string(), - type_params, - extends, - extends_name, - native_extends, - extends_expr, - heritage_lexically_shadowed, - fields, - constructor, - methods, - getters, - setters, - static_accessor_names, - static_accessor_fn_ids, - static_fields, - static_methods, - computed_members, - decorators: lower_decorators(ctx, &class.decorators), - is_exported, - aliases: Vec::new(), - // Declared inside a function body / non-module block → its static-field - // initializers must run on class evaluation, not at module init. - is_nested: ctx.scope_depth > 0 || ctx.inside_block_scope > 0, - alloc_width_hint: 0, - specialized_from: None, - }) -} diff --git a/crates/perry-hir/src/lower_decl/class_decl/from_ast.rs b/crates/perry-hir/src/lower_decl/class_decl/from_ast.rs new file mode 100644 index 0000000000..33092d56a4 --- /dev/null +++ b/crates/perry-hir/src/lower_decl/class_decl/from_ast.rs @@ -0,0 +1,726 @@ +//! `lower_class_from_ast` — lowering a class EXPRESSION (as opposed to a +//! class declaration statement, handled in `class_decl.rs` proper) to HIR. +//! Split out of `class_decl.rs` to keep it under the 2000-line file gate. +//! Behaviour is unchanged; `use super::*` reaches the shared imports. + +use super::*; + +/// Lower a class expression (ast::Class) to HIR. +/// Used for anonymous class expressions like `new (class extends Command { ... })()`. +pub(crate) fn lower_class_from_ast( + ctx: &mut LoweringContext, + class: &ast::Class, + name: &str, + is_exported: bool, +) -> Result { + validate_legacy_decorator_surface(class, name)?; + validate_class_element_early_errors(class, name)?; + let class_id = match ctx.lookup_class(name) { + Some(id) => id, + None => { + let id = ctx.fresh_class(); + ctx.register_class(name.to_string(), id); + id + } + }; + capture_class_source(ctx, class_id, class); + + let old_class = ctx.current_class.take(); + ctx.current_class = Some(name.to_string()); + let old_class_scope_depth = ctx.current_class_scope_depth.replace(ctx.scope_depth); + let old_inner_name = ctx.current_class_inner_name.take(); + // A class-expression caller stashes the source ident here; fall back + // to the (possibly synthetic) registration name when absent. + let explicit_inner_name = ctx.pending_class_inner_name.take(); + ctx.current_class_inner_name = explicit_inner_name + .clone() + .or_else(|| Some(name.to_string())); + let old_is_derived = ctx.current_class_is_derived; + ctx.current_class_is_derived = class.super_class.is_some(); + + // Private-name scope for this class-expression body (see lower_class_decl). + ctx.push_private_scope(super::build_private_scope(class, name, class_id)); + + // Issue #562: same as the parallel `lower_class_decl` arm — track the + // parent class identifier so super({...}) controller-param pre-scan + // fires for stream subclasses. + let old_super_ident = ctx.current_class_super_ident.take(); + ctx.current_class_super_ident = match class.super_class.as_deref() { + Some(ast::Expr::Ident(ident)) => Some(ident.sym.to_string()), + _ => None, + }; + + let type_params = class + .type_params + .as_ref() + .map(|tp| extract_type_params(tp)) + .unwrap_or_default(); + + ctx.enter_type_param_scope(&type_params); + + // #5437: parent Ident shadowed by an in-scope lexical local? (See the + // matching computation in `lower_class_decl`.) Lets codegen prefer the + // dynamic local over a NAME-keyed built-in special case. + let heritage_lexically_shadowed = match class.super_class.as_deref() { + Some(ast::Expr::Ident(ident)) => { + let n = ident.sym.to_string(); + !ctx.class_renames.contains_key(&n) && ctx.locals.lookup(&n).is_some() + } + _ => false, + }; + + let (extends, extends_name, native_extends, extends_expr) = if let Some(ref super_class) = + class.super_class + { + if explicit_inner_name + .as_deref() + .is_some_and(|inner| is_class_self_heritage(super_class, inner)) + { + ( + None, + None, + None, + Some(Box::new(crate::lower::throw_reference_error_expr( + "js_throw_reference_error_this_before_super", + ))), + ) + } else if let ast::Expr::Ident(ident) = super_class.as_ref() { + let parent_name = ident.sym.to_string(); + let canonical_parent_name = canonical_native_parent_name(ctx, &parent_name) + .unwrap_or(&parent_name) + .to_string(); + let native_parent = match canonical_parent_name.as_str() { + "EventEmitter" => Some(("events".to_string(), "EventEmitter".to_string())), + "EventEmitterAsyncResource" => Some(( + "events".to_string(), + "EventEmitterAsyncResource".to_string(), + )), + "AsyncLocalStorage" => { + Some(("async_hooks".to_string(), "AsyncLocalStorage".to_string())) + } + "AsyncResource" => Some(("async_hooks".to_string(), "AsyncResource".to_string())), + "WebSocketServer" => Some(("ws".to_string(), "WebSocketServer".to_string())), + // #10293: lru-cache's LRUCache is a compile-time lowering with + // no runtime value; recognising it here routes `extends` to the + // subclass-init path instead of the dynamic parent registration + // that throws "Class extends value is not a constructor". + "LRUCache" => Some(("lru-cache".to_string(), "LRUCache".to_string())), + // Issue #562: keep in lockstep with the parallel arm in + // `lower_class_decl` above. + "ReadableStream" => { + Some(("readable_stream".to_string(), "ReadableStream".to_string())) + } + "WritableStream" => { + Some(("writable_stream".to_string(), "WritableStream".to_string())) + } + "TransformStream" => Some(( + "transform_stream".to_string(), + "TransformStream".to_string(), + )), + // #1545: classic node:stream base classes — keep in lockstep + // with the parallel arm in `lower_class_decl` above. Gated on + // `is_genuine_node_stream_parent` so a userland stream-shim + // binding (readable-stream's `Transform`) falls through to the + // dynamic `extends_expr` parent path. + "Readable" | "Writable" | "Duplex" | "Transform" + if is_genuine_node_stream_parent(ctx, &parent_name) => + { + Some(("node_stream".to_string(), canonical_parent_name.clone())) + } + _ => None, + }; + // A lexical local binding shadowing the parent name must win over the + // native/static parent — the in-scope local IS the real parent value. + // Check it BEFORE `native_parent` so e.g. `const EventEmitter = …; + // const C = class extends EventEmitter {}` routes through the dynamic + // `extends_expr` path (the local) instead of recording the native + // `events` parent. ESM imports are NOT in `ctx.locals`, so genuine + // `extends EventEmitter` (imported) still takes the native path. + // + // #10623: same CJS-wrapper carve-out as the class-declaration arm + // above — see its comment for the full rationale. + let require_native_reexport = ctx + .require_destructured_native_locals + .get(&parent_name) + .is_some_and(|key| *key == canonical_parent_name); + let locally_shadowed = !ctx.class_renames.contains_key(&parent_name) + && ctx.locals.lookup(&parent_name).is_some() + && !require_native_reexport; + if native_parent.is_some() && !locally_shadowed { + (None, Some(canonical_parent_name), native_parent, None) + } else if locally_shadowed { + // #5437 (Next.js p-queue `PQueue` inside a minified bundle): a + // class EXPRESSION whose parent Ident is an IN-SCOPE LOCAL + // (`const t = require("events"); … class extends t {…}`) must + // bind to that LEXICAL local — not to an unrelated module-global + // class that happens to share the (minified, single-letter) + // name. The static `lookup_class(parent_name)` path keys + // codegen's `super()` on a module-wide `HashMap`; + // in a turbopack chunk dozens of distinct webpack-factory + // classes are all named `t`/`u`/`i`, so that map keeps ONE `t` + // (whichever registered last) and `super()` inlines the WRONG + // class's constructor. The bundle's p-queue `PQueue extends t` + // (eventemitter3) resolved `t` to superstruct's `StructError` + // base, so `new PQueue()` ran StructError's destructuring ctor + // on the (undefined) options arg → "Cannot convert undefined or + // null to object" → HTTP 500 on the dynamic page routes. + // + // When the parent name is bound by a local in THIS body's scope, + // route through the dynamic `extends_expr` path: lower the Ident + // as a runtime value (the lexically-correct local), register the + // parent edge dynamically, and let `super()` invoke the real + // parent value via `js_fetch_or_value_super` (which already + // tolerates native / closure / class-ref / builtin parents). + // Gated on `!class_renames.contains_key` so the #5437 + // sibling-rename path above still wins when a scope-local class + // rename exists (that disambiguation is exact). Pure-Ident + // module-global heritage (no shadowing local) is unaffected — + // `ctx.locals.lookup` returns `None` for a class name. + // Do NOT set a static `extends` (parent_cid) OR `extends_name` + // here: the only candidate is `lookup_class(parent_name)`, the + // wrong same-named module-global class we deliberately avoid — and + // a retained `extends_name` is re-resolved back to it by the + // static parent-chain walks (layout / parent-edge / inherited- + // method / vtable / type-facts), corrupting the subclass. The + // dynamic `extends_expr` path registers the correct parent edge at + // runtime via `RegisterClassParentDynamic` + `function_class_id`. + match lower_class_heritage_expr(ctx, super_class) { + Ok(expr) => (None, None, None, Some(Box::new(expr))), + Err(_) => (None, None, None, None), + } + } else { + // #5437: resolve the parent through active scope-local class + // renames so a class EXPRESSION extending a disambiguated + // same-named sibling (`f` -> `f$0`) binds to the right class. + // See the matching fix in `lower_class_decl` above. + let parent_name = ctx.resolve_class_name(&parent_name); + let parent_cid = ctx.lookup_class(&parent_name); + if parent_cid.is_none() { + // Issue #711 part 2: see the parallel arm in + // `lower_class_decl` above. Unknown Ident super-class + // falls through to extends_expr capture so a + // function-with-prototype value can be resolved at + // runtime via `function_class_id`. + match lower_class_heritage_expr(ctx, super_class) { + Ok(expr) => (None, Some(parent_name), None, Some(Box::new(expr))), + Err(_) => (None, Some(parent_name), None, None), + } + } else { + (parent_cid, Some(parent_name), None, None) + } + } + } else if let ast::Expr::Member(member) = super_class.as_ref() { + // Refs #488 drizzle-sqlite: try cross-module class lookup. See + // the matching arm in `lower_class_decl` (above) for the full + // rationale — without this, the parent link is lost and + // inherited methods don't reach instances. + let parent_name = extract_member_class_name(member); + // Issue #4908: avoid a self-referential parent edge when the + // member's trailing property equals the subclass's own name + // (`class Agent extends http.Agent`). See the matching guard in + // `lower_class_decl` above — a self-link loops codegen's + // parent-chain walk forever. Leave the class parentless, matching + // the non-colliding native-member-base behavior. + if parent_name == name { + (None, None, None, None) + } else if parent_name == "default" { + // `class X extends _mod.default` — the interop ESM + // default-export-class pattern. Keep in lockstep with the + // matching `.default` arm in `lower_class_decl` above: route + // through `extends_expr` so `super()` re-evaluates the alias + // at construction time and the parent edge is registered. + match lower_class_heritage_expr(ctx, super_class) { + Ok(expr) => (None, Some(parent_name), None, Some(Box::new(expr))), + Err(_) => (None, Some(parent_name), None, None), + } + } else { + // Named cross-module member-extends — route through `extends_expr` + // UNCONDITIONALLY so `super()` runs the parent ctor at runtime even + // when the parent isn't in codegen's class table / not yet lowered. + // Keep in lockstep with the matching arm in `lower_class_decl` + // (wall 48: NodeNextRequest extends _index.BaseNextRequest). + let resolved = ctx.lookup_class(&parent_name); + match lower_class_heritage_expr(ctx, super_class) { + Ok(expr) => (resolved, Some(parent_name), None, Some(Box::new(expr))), + Err(_) => (resolved, Some(parent_name), None, None), + } + } + } else { + // Issue #711: see the matching arm in `lower_class_decl` above + // for the full rationale. Capture the lowered extends + // expression so codegen can evaluate it at the class + // declaration site and call + // `js_register_class_parent_dynamic` at runtime. + match lower_class_heritage_expr(ctx, super_class) { + Ok(expr) => (None, None, None, Some(Box::new(expr))), + Err(_) => (None, None, None, None), + } + } + } else { + (None, None, None, None) + }; + + // Issue #10486: mirrors the capture-forwarding fallback in + // `lower_class_decl` above (see its comment for the full rationale) — + // a class EXPRESSION extending a lexically-local capture-bearing class + // EXPRESSION (`const Base = class {…}; const Sub = class extends Base + // {…}`) needs the alias-resolved heritage identifier for capture + // lookup even when `extends_name` was deliberately left None for + // class-registry resolution. + // See the matching guard in `lower_class_decl` above: skip the + // fallback when this class expression has its own explicit + // constructor (its `super(...)` already forwards correctly). + let has_own_constructor = class + .body + .iter() + .any(|m| matches!(m, ast::ClassMember::Constructor(_))); + let capture_parent_name: Option = extends_name.clone().or_else(|| { + if has_own_constructor { + return None; + } + class.super_class.as_deref().and_then(|sc| match sc { + ast::Expr::Ident(ident) => { + let raw = ident.sym.to_string(); + Some(ctx.resolve_class_alias(&raw).unwrap_or(raw)) + } + _ => None, + }) + }); + + let mut static_field_names = Vec::new(); + let mut static_method_names = Vec::new(); + for member in &class.body { + match member { + // See note above: static getters/setters are not callable methods. + ast::ClassMember::Method(method) + if method.is_static && matches!(method.kind, ast::MethodKind::Method) => + { + if let ast::PropName::Ident(ident) = &method.key { + static_method_names.push(ident.sym.to_string()); + } + } + ast::ClassMember::PrivateMethod(method) + if method.is_static && matches!(method.kind, ast::MethodKind::Method) => + { + static_method_names.push(format!("#{}", method.key.name)); + } + ast::ClassMember::ClassProp(prop) if prop.is_static && !prop.declare => { + if let ast::PropName::Ident(ident) = &prop.key { + static_field_names.push(ident.sym.to_string()); + } + } + ast::ClassMember::PrivateProp(prop) if prop.is_static => { + static_field_names.push(format!("#{}", prop.key.name)); + } + _ => {} + } + } + ctx.register_class_statics(name.to_string(), static_field_names, static_method_names); + + let mut fields = Vec::new(); + let mut static_fields = Vec::new(); + let mut constructor = None; + let mut methods = Vec::new(); + let mut static_methods = Vec::new(); + let mut getters = Vec::new(); + let mut setters = Vec::new(); + // Parallel staticness, so `record_class_accessor` can tell a static + // accessor from an instance one with the same name. + let mut getter_statics: Vec = Vec::new(); + let mut setter_statics: Vec = Vec::new(); + let mut static_accessor_names: Vec = Vec::new(); + let mut static_accessor_fn_ids: Vec = Vec::new(); + let mut computed_members = Vec::new(); + let mut seen_generic_computed_member = false; + + for (member_index, member) in class.body.iter().enumerate() { + match member { + ast::ClassMember::Constructor(ctor) => { + constructor = Some(lower_constructor(ctx, name, ctor)?); + } + ast::ClassMember::Method(method) => { + // Skip TypeScript overload declarations (no body) + if method.function.body.is_none() { + continue; + } + if let Some(computed) = generic_computed_member_key(ctx, method) { + computed_members.push(lower_generic_computed_class_member( + ctx, + method, + computed, + member_index, + )?); + seen_generic_computed_member = true; + continue; + } + let (prop_name, can_source_order_register) = match &method.key { + ast::PropName::Ident(ident) => (ident.sym.to_string(), true), + ast::PropName::Str(s) => (s.value.as_str().unwrap_or("").to_string(), true), + // Numeric-literal member names — see the parallel arm in + // `lower_class_decl`. Canonical ToString of the value. + ast::PropName::Num(n) => (crate::lower::number_to_js_key(n.value), true), + // `[Symbol.iterator]() {}` / `*[Symbol.iterator]() {}` on a + // class *expression* — mirror the declaration path so + // `new (class { *[Symbol.iterator]() {…} })()` is iterable + // for spread, `Array.from`, destructuring, and manual + // `obj[Symbol.iterator]()` calls (#5128). The generator lift + // happens in the `Method` arm below. + ast::PropName::Computed(computed) if is_symbol_iterator_key(&computed.expr) => { + ("@@iterator".to_string(), false) + } + ast::PropName::Computed(computed) + if is_inspect_custom_key(ctx, &computed.expr) + && !method.is_static + && matches!(method.kind, ast::MethodKind::Method) => + { + // Refs #1248: see class_decl.rs Method handling above. + ("__perry_inspect_custom__".to_string(), false) + } + // Other well-known-symbol keys (`[Symbol.asyncIterator]`, + // `[Symbol.toPrimitive]`, `[Symbol.dispose]` / + // `[Symbol.asyncDispose]`, `static [Symbol.hasInstance]`, + // `get [Symbol.toStringTag]`) on a class *expression* — + // same handling as the declaration path, via the shared + // helper. Pre-fix these fell through `_ => continue` and + // were silently dropped, so e.g. `for await (… of new (C = + // class { [Symbol.asyncIterator]() {…} })())` threw + // `TypeError: value is not iterable`. + ast::PropName::Computed(_) => { + match lower_well_known_computed_method(ctx, method, name)? { + Some(WellKnownComputedMethod::Rename(renamed)) => (renamed, false), + Some( + WellKnownComputedMethod::Lifted + | WellKnownComputedMethod::Unsupported, + ) + | None => continue, + } + } + _ => continue, + }; + match method.kind { + ast::MethodKind::Getter => { + let func = with_static_member_context(ctx, method.is_static, |ctx| { + lower_getter_method(ctx, method) + })?; + if seen_generic_computed_member && can_source_order_register { + computed_members.push(lower_noncomputed_class_member_registration( + ctx, + method, + &prop_name, + member_index, + )?); + } + if method.is_static { + static_accessor_names.push(prop_name.clone()); + static_accessor_fn_ids.push(func.id); + } + record_class_accessor( + &mut getters, + &mut getter_statics, + prop_name, + func, + method.is_static, + ); + } + ast::MethodKind::Setter => { + let func = with_static_member_context(ctx, method.is_static, |ctx| { + lower_setter_method(ctx, method) + })?; + if seen_generic_computed_member && can_source_order_register { + computed_members.push(lower_noncomputed_class_member_registration( + ctx, + method, + &prop_name, + member_index, + )?); + } + if method.is_static { + static_accessor_names.push(prop_name.clone()); + static_accessor_fn_ids.push(func.id); + } + record_class_accessor( + &mut setters, + &mut setter_statics, + prop_name, + func, + method.is_static, + ); + } + ast::MethodKind::Method => { + let mut func = with_static_member_context(ctx, method.is_static, |ctx| { + lower_class_method(ctx, method) + })?; + // `*[Symbol.iterator]()` — lift to a top-level generator + // and register a synthetic `@@iterator` wrapper (#5128), + // exactly as the class-declaration path does above. + if prop_name == "@@iterator" && func.is_generator && !method.is_static { + let wrapper = synthesize_symbol_iterator_wrapper(ctx, name, &mut func); + let ast::PropName::Computed(computed) = &method.key else { + unreachable!("@@iterator generator key must be computed"); + }; + // The computed-symbol registration installs the + // runtime dispatch alias too. Registering the wrapper + // as a string method also exposed an own "@@iterator" + // property that the source never declared (#9788). + computed_members.push(ClassComputedMember { + key_expr: lower_expr(ctx, &computed.expr)?, + function: wrapper, + is_static: false, + kind: ClassComputedMemberKind::Method, + source_order: member_index, + }); + continue; + } + if seen_generic_computed_member && can_source_order_register { + computed_members.push(lower_noncomputed_class_member_registration( + ctx, + method, + &prop_name, + member_index, + )?); + } + if method.is_static { + static_methods.push(func); + } else { + methods.push(func); + } + } + } + } + ast::ClassMember::ClassProp(prop) => { + // `declare` and `abstract` fields are type-only: TypeScript + // erases them entirely (`node --experimental-strip-types` + // emits no runtime slot). Materializing an abstract base-class + // field creates a phantom slot that shadows the concrete + // subclass initializer of the same name — a base/union-typed + // read then resolves to the (undefined) base slot. Skip both. + if prop.declare || prop.is_abstract { + continue; + } + // Computed-key fields (`[Symbol.for("k")] = init`) flow through + // here for both instance AND static positions. + // `lower_class_prop` captures the key expression in + // `ClassField.key_expr` for runtime evaluation. Refs #420 — + // drizzle's `static [entityKind] = "Table"` is the canonical + // static-computed-key pattern; codegen's `init_static_fields` + // detects `key_expr.is_some()` and emits a runtime + // registration into the class-static-symbol side table. + let field = lower_class_prop(ctx, prop)?; + if prop.is_static { + static_fields.push(field); + } else { + fields.push(field); + } + } + ast::ClassMember::PrivateProp(prop) => { + let field = lower_private_prop(ctx, prop)?; + if prop.is_static { + static_fields.push(field); + } else { + fields.push(field); + } + } + ast::ClassMember::PrivateMethod(method) => { + if method.function.body.is_none() { + continue; + } + match method.kind { + ast::MethodKind::Method => { + let func = lower_private_method(ctx, method)?; + if method.is_static { + static_methods.push(func); + } else { + methods.push(func); + } + } + ast::MethodKind::Getter => { + let prop_name = format!("#{}", method.key.name); + let func = lower_private_getter(ctx, method)?; + // Static private accessor — register on the static + // side (see the matching arm in `lower_class_decl`). + if method.is_static { + static_accessor_names.push(prop_name.clone()); + static_accessor_fn_ids.push(func.id); + } + record_class_accessor( + &mut getters, + &mut getter_statics, + prop_name, + func, + method.is_static, + ); + } + ast::MethodKind::Setter => { + let prop_name = format!("#{}", method.key.name); + let func = lower_private_setter(ctx, method)?; + if method.is_static { + static_accessor_names.push(prop_name.clone()); + static_accessor_fn_ids.push(func.id); + } + record_class_accessor( + &mut setters, + &mut setter_statics, + prop_name, + func, + method.is_static, + ); + } + } + } + ast::ClassMember::StaticBlock(block) => { + let scope_mark = ctx.enter_scope(); + let saved_in_nonarrow_fn = ctx.in_nonarrow_fn; + ctx.in_nonarrow_fn = true; + // A static block is its own var-scope (OrdinaryFunctionCreate + // per ClassStaticBlockDefinitionEvaluation): `lower_block_stmt` + // only lowers nested statements without hoisting `var`s to this + // boundary, so a `var` declared in one block leaked into the + // next block/module scope instead of staying local (test262 + // static-init-scope-var-close.js). + let body = lower_fn_body_block_stmt(ctx, &block.body)?; + ctx.exit_scope(scope_mark); + ctx.in_nonarrow_fn = saved_in_nonarrow_fn; + + let block_idx = static_methods + .iter() + .filter(|m| m.name.starts_with("__perry_static_init_")) + .count(); + let synthetic_name = format!("__perry_static_init_{}", block_idx); + static_methods.push(Function { + id: ctx.fresh_func(), + name: synthetic_name, + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Void, + body, + is_async: false, + is_generator: false, + is_strict: true, + was_plain_async: false, + was_unrolled: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + }); + } + _ => {} + } + } + + // `this` in static field initializers — see the matching substitution in + // `lower_class_decl` above. + for sf in &mut static_fields { + if let Some(init) = &mut sf.init { + crate::analysis::substitute_lexical_this_in_expr( + init, + &Expr::ClassRef(name.to_string()), + ); + } + } + + ctx.exit_type_param_scope(); + // Issue #562: see the parallel site in `lower_class_decl` — register + // native_extends so subclass instances of the three Web Stream base + // classes route through the parent stream module's dispatch table. + if let Some((module, class)) = native_extends.as_ref() { + ctx.register_class_native_extends(name.to_string(), module.clone(), class.clone()); + } + ctx.current_class = old_class; + ctx.current_class_scope_depth = old_class_scope_depth; + ctx.current_class_inner_name = old_inner_name; + ctx.current_class_is_derived = old_is_derived; + ctx.pop_private_scope(); + // Issue #562: restore prior super-ident slot. + ctx.current_class_super_ident = old_super_ident; + + // Phase 4.1: register method + getter return types — see the parallel + // site in lower_class_decl. + for m in &methods { + if !matches!(m.return_type, Type::Any) { + ctx.register_class_method_return_type( + name.to_string(), + m.name.clone(), + m.return_type.clone(), + ); + } + } + for (prop_name, g) in &getters { + if !matches!(g.return_type, Type::Any) { + ctx.register_class_method_return_type( + name.to_string(), + prop_name.clone(), + g.return_type.clone(), + ); + } + } + + // Mirror `lower_class_decl`: register the union of this class's accessor + // names (own get/set, including private and the parent chain) so the + // assignment recogniser in `expr_assign.rs` treats `C.prototype. + // = v` as a setter INVOCATION instead of a prototype-method monkey-patch. + // `lower_class_decl` registers these for class declarations; without the + // parallel call here, a class EXPRESSION's instance setters (e.g. + // `var C = class { set ''(p){…} }; C.prototype[''] = v`) were silently + // dropped to `RegisterPrototypeMethod`. Test262 accessor-name-inst setters. + { + let mut accessor_names = runtime_instance_accessor_names(&class.body); + if let Some(ref parent_name) = extends_name { + if let Some(parent_accessors) = ctx.lookup_class_accessor_names(parent_name) { + accessor_names.extend_from(parent_accessors); + } + } + ctx.register_class_accessor_names(name.to_string(), accessor_names); + } + + // Issue #740: synthesize __perry_cap_* capture machinery for class + // expressions that reference enclosing-fn locals (e.g. `const Inner = + // class { _tag = tag }` inside `function makeFactory(tag)`). Without + // this, anon class expressions silently dropped captures while named + // class declarations had the machinery via `lower_class_decl`. See + // the helper's doc comment for the full description. + synthesize_class_captures( + ctx, + name, + capture_parent_name.as_deref(), + extends.is_some() + || extends_name.is_some() + || native_extends.is_some() + || extends_expr.is_some(), + &mut fields, + &mut methods, + &mut getters, + &mut setters, + &mut computed_members, + &mut constructor, + &mut static_methods, + ); + + Ok(Class { + id: class_id, + name: name.to_string(), + type_params, + extends, + extends_name, + native_extends, + extends_expr, + heritage_lexically_shadowed, + fields, + constructor, + methods, + getters, + setters, + static_accessor_names, + static_accessor_fn_ids, + static_fields, + static_methods, + computed_members, + decorators: lower_decorators(ctx, &class.decorators), + is_exported, + aliases: Vec::new(), + // Declared inside a function body / non-module block → its static-field + // initializers must run on class evaluation, not at module init. + is_nested: ctx.scope_depth > 0 || ctx.inside_block_scope > 0, + alloc_width_hint: 0, + specialized_from: None, + }) +} diff --git a/test-files/test_gap_10623_implicit_ctor_native_super.cts b/test-files/test_gap_10623_implicit_ctor_native_super.cts new file mode 100644 index 0000000000..1d53f0976f --- /dev/null +++ b/test-files/test_gap_10623_implicit_ctor_native_super.cts @@ -0,0 +1,144 @@ +// #10623: a derived class with NO explicit constructor does not forward its +// `new`-site arguments to a native base's `super(...)`. +// +// const { AsyncResource } = require("node:async_hooks"); +// class NoCtor extends AsyncResource {} +// new NoCtor("MyResource"); // threw: "type" argument must be of type string +// +// Root cause: class-heritage resolution treats ANY in-scope local binding +// with the same name as the parent as "the user shadowed the native base with +// their own value" (`locally_shadowed` in `perry-hir/src/lower_decl/ +// class_decl.rs`), which routes `super()` through a generic call-the-value +// dispatch instead of the native base's real init (`js_async_resource_ +// subclass_init` and friends). That heuristic is right for a GENUINE shadow +// (`const EventEmitter = MyOwnClass; class X extends EventEmitter {}`), but a +// CJS-wrapped module (this file) runs its ENTIRE body inside the wrap's IIFE, +// so `const { AsyncResource } = require("node:async_hooks")` is *also* a +// genuine local — indistinguishable from real shadowing under the old check. +// AsyncResource's runtime value is a real ES `class`, so the fallback dispatch +// (calling it without `new`) threw "Class constructor AsyncResource cannot be +// invoked without 'new'" — for BOTH the implicit AND the explicit `super(type)` +// form (this file's CJS/CommonJS shape is what real npm packages use; the +// issue's "explicit works" observation held only for an ESM-module variant of +// the same source, not for this one). +// +// The fix distinguishes the two by PROVENANCE instead of by re-deriving the +// name: a local is not "shadowing" if it was ALSO destructured from a +// `require()` of the real native module with a matching export key. + +const { AsyncResource, AsyncLocalStorage } = require("node:async_hooks"); +const { EventEmitter, EventEmitterAsyncResource } = require("node:events"); +const { Readable } = require("node:stream"); + +function run(label: string, fn: () => void) { + try { + fn(); + console.log(label, "ok"); + } catch (e: any) { + console.log(label, "threw:", e.constructor.name + ":", e.message); + } +} + +// ── the issue's exact repro: no ctor, no override ── +run("AsyncResource implicit", () => { + class NoCtor extends AsyncResource {} + const a = new NoCtor("MyResource"); + console.log(" ", a.constructor.name, typeof a.triggerAsyncId, a instanceof AsyncResource); +}); + +// ── explicit-ctor control: this form must keep working ── +run("AsyncResource explicit", () => { + class WithCtor extends AsyncResource { + constructor(type: string) { + super(type); + } + } + const b = new WithCtor("MyResource2"); + console.log(" ", b.constructor.name, typeof b.triggerAsyncId, b instanceof AsyncResource); +}); + +// ── two-level (indirect) subclass, no constructor anywhere ── +run("AsyncResource two-level", () => { + class Mid extends AsyncResource {} + class Leaf extends Mid {} + const l = new Leaf("LeafResource"); + console.log(" ", l.constructor.name, typeof l.triggerAsyncId, l instanceof AsyncResource); +}); + +// ── class EXPRESSION, constructor-less ── +run("AsyncResource class-expr", () => { + const Anon = class extends AsyncResource {}; + const inst = new Anon("AnonResource"); + console.log(" ", inst.constructor.name, typeof inst.triggerAsyncId, inst instanceof AsyncResource); +}); + +// ── other native bases reached the SAME way (require() destructure), same +// class-of-defect coverage: constructor-less + explicit-ctor control ── +// Note: this checks the construction surface only, not `instanceof +// AsyncLocalStorage` — that comparison has its own pre-existing gap +// (unrelated to #10623: it reproduces identically whether or not this class +// forwards constructor arguments, and #10623's fix does not touch +// `instanceof` resolution) filed separately. +run("AsyncLocalStorage implicit", () => { + class NoCtorALS extends AsyncLocalStorage {} + const s = new NoCtorALS(); + console.log(" ", typeof s.run, typeof s.getStore); +}); + +// Same `instanceof`-only carve-out as the AsyncLocalStorage case above. +run("EventEmitterAsyncResource implicit", () => { + class NoCtorEEAR extends EventEmitterAsyncResource {} + const e = new NoCtorEEAR(); + console.log(" ", typeof e.on, typeof e.triggerAsyncId); +}); + +run("EventEmitter implicit", () => { + class NoCtorEE extends EventEmitter {} + const ee = new NoCtorEE(); + let got = 0; + ee.on("ping", (v: number) => (got = v)); + ee.emit("ping", 7); + console.log(" ", typeof ee.on, got); +}); + +run("EventEmitter explicit", () => { + class WithCtorEE extends EventEmitter { + tag: string; + constructor(tag: string) { + super(); + this.tag = tag; + } + } + const ee = new WithCtorEE("t1"); + console.log(" ", typeof ee.on, ee.tag); +}); + +run("Readable implicit", () => { + class NoCtorR extends Readable {} + const r = new NoCtorR({ read() {} }); + console.log(" ", typeof r.push, typeof r.pipe); +}); + +run("Readable explicit", () => { + class WithCtorR extends Readable { + constructor(opts: any) { + super(opts); + } + } + const r = new WithCtorR({ read() {} }); + console.log(" ", typeof r.push); +}); + +// ── Error family: a DIFFERENT (already-correct) mechanism; kept as a +// same-file control so a future regression here shows up next to #10623 ── +run("Error implicit", () => { + class NoCtorErr extends Error {} + const e = new NoCtorErr("boom"); + console.log(" ", e.message, e instanceof Error); +}); + +run("TypeError implicit", () => { + class NoCtorTErr extends TypeError {} + const e = new NoCtorTErr("bad type"); + console.log(" ", e.message, e instanceof TypeError); +});