diff --git a/changelog.d/8730-aliased-native-class-new-resolution.md b/changelog.d/8730-aliased-native-class-new-resolution.md new file mode 100644 index 0000000000..b5898a30d1 --- /dev/null +++ b/changelog.d/8730-aliased-native-class-new-resolution.md @@ -0,0 +1 @@ +Fix an aliased ESM named import of a Node built-in class (`import { BlockList as Wj4 } from "net"`, `{ AsyncLocalStorage as J_z } from "async_hooks"`, `{ PassThrough as Lrz } from "stream"`) throwing `ReferenceError: identifier is not defined` when constructed. The `new`-lowering already rewrites the alias to the class's export name so construction matches the un-aliased form, but the unresolved-`new` guard added in #8688 re-checked the native-module registry under that rewritten export name — which is keyed on the local import name — and, since these classes are not reified global builtins, fired the nameless throw at module init. The guard now also consults the registry under the original imported identifier, so aliased native-class imports resolve and construct exactly like their un-aliased form. This unblocked the natively compiled Claude Code cli.js 2.1.112 bundle, which crashed at module init on nearly every command. diff --git a/crates/perry-hir/src/lower/expr_new.rs b/crates/perry-hir/src/lower/expr_new.rs index 3bb744b362..65c5014dc5 100644 --- a/crates/perry-hir/src/lower/expr_new.rs +++ b/crates/perry-hir/src/lower/expr_new.rs @@ -1571,12 +1571,26 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R // evaluating the constructor reference. That is a ReferenceError // (`new Missing()`), distinct from the TypeError produced when a // present binding's value is non-constructable. + // + // Consult the native-module registry under BOTH the (possibly + // rewritten) `class_name` AND the original `source_class_name`. + // The alias-rewrite block just above replaces `class_name` with a + // native class's EXPORT name (`Wj4` → `BlockList`) so the + // construction path below matches the un-aliased form, but the + // registry is keyed on the LOCAL import name (`Wj4`), so + // `lookup_native_module(&class_name)` misses under the export name. + // Checking `source_class_name` recognizes the aliased native import + // as resolved; without it, an aliased `import { BlockList as Wj4 }` + // / `{ AsyncLocalStorage as J_z }` / `{ PassThrough as Lrz }` (none + // of which are reified global builtins) fell through to this throw + // at module init even though the binding is perfectly resolvable. if ctx.lookup_class(&class_name).is_none() && ctx.resolve_class_alias(&class_name).is_none() && ctx.lookup_local(&class_name).is_none() && ctx.lookup_func(&class_name).is_none() && ctx.lookup_imported_func(&class_name).is_none() && ctx.lookup_native_module(&class_name).is_none() + && ctx.lookup_native_module(source_class_name).is_none() && !ctx.forward_class_names.contains(source_class_name) && !is_reified_global_builtin_constructor(&class_name) { diff --git a/crates/perry-hir/tests/aliased_native_new_resolution.rs b/crates/perry-hir/tests/aliased_native_new_resolution.rs new file mode 100644 index 0000000000..9795ddbf21 --- /dev/null +++ b/crates/perry-hir/tests/aliased_native_new_resolution.rs @@ -0,0 +1,101 @@ +//! Regression test for #8730: an ALIASED ESM named import of a Node built-in +//! class (`import { BlockList as Wj4 } from "net"; new Wj4()`) must not lower +//! `new ()` to the nameless `js_throw_reference_error_unresolved_get` +//! throw. +//! +//! Root cause: the alias-rewrite block in `lower_new` replaces the callee's +//! `class_name` with the native class's EXPORT name (`Wj4` -> `BlockList`) so +//! the construction path matches the un-aliased form, but the freshly-added +//! (#8688) unresolved-`new` guard then consulted `lookup_native_module` under +//! that rewritten export name — which is not in the registry (it is keyed on +//! the LOCAL import name). None of these classes are reified global builtins, +//! so the guard fired and every command threw `ReferenceError: identifier is +//! not defined` at module init. + +use perry_diagnostics::SourceCache; +use perry_hir::lower_module; +use perry_parser::parse_typescript_with_cache; + +const THROW_HELPER: &str = "js_throw_reference_error_unresolved_get"; + +fn lower_debug(src: &str) -> String { + let src = src.to_string(); + std::thread::Builder::new() + .stack_size(32 * 1024 * 1024) + .spawn(move || { + let mut cache = SourceCache::new(); + let parsed = + parse_typescript_with_cache(&src, "aliased_native_new_resolution.ts", &mut cache) + .expect("parse should succeed"); + let module = lower_module(&parsed.module, "test", "aliased_native_new_resolution.ts") + .expect("lowering should succeed"); + format!("{module:#?}") + }) + .expect("spawn lower thread") + .join() + .expect("lower thread panicked") +} + +#[test] +fn aliased_native_class_import_does_not_lower_to_nameless_throw() { + // Each mirrors a real cli.js 2.1.112 shape from #8730 (BlockList/Wj4 built + // and `.addSubnet`-ed at module init; AsyncLocalStorage/J_z; PassThrough). + let cases = [ + ( + "BlockList", + r#"import { BlockList as Wj4 } from "net"; + const b = new Wj4(); + b.addSubnet("10.0.0.0", 8); + console.log(b.check("10.1.2.3"));"#, + ), + ( + "AsyncLocalStorage", + r#"import { AsyncLocalStorage as J_z } from "async_hooks"; + const s = new J_z(); + console.log(typeof s.run);"#, + ), + ( + "PassThrough", + r#"import { PassThrough as Lrz } from "stream"; + const p = new Lrz(); + console.log(typeof p.pipe);"#, + ), + ]; + + for (label, src) in cases { + let debug = lower_debug(src); + assert!( + !debug.contains(THROW_HELPER), + "aliased native import `{label}` must construct, not throw the nameless \ + ReferenceError at module init:\n{debug}" + ); + } +} + +#[test] +fn unaliased_native_class_import_still_constructs() { + // Control: the un-aliased form was never broken; keep it green so the fix + // is symmetric across aliased/un-aliased native imports. + let debug = lower_debug( + r#"import { BlockList } from "net"; + const b = new BlockList(); + b.addSubnet("10.0.0.0", 8); + console.log(b.check("10.1.2.3"));"#, + ); + assert!( + !debug.contains(THROW_HELPER), + "un-aliased native import must construct, not throw:\n{debug}" + ); +} + +#[test] +fn genuinely_unresolved_new_still_throws() { + // Positive control: the guard must still fire for a `new` on an identifier + // that resolves to no binding at all — the fix must not blanket-suppress it. + let debug = lower_debug(r#"const x = new Totally_Undefined_Constructor_Xyz();"#); + assert!( + debug.contains(THROW_HELPER), + "a genuinely unresolved `new` must still lower to the nameless \ + ReferenceError throw:\n{debug}" + ); +}