From c086f273b3e31b3a754a4e685d7d2732194d6466 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 24 Aug 2026 14:38:57 +0200 Subject: [PATCH] fix(hir): aliased native-class import `new` no longer throws ReferenceError An aliased ESM named import of a Node built-in class (`import { BlockList as Wj4 } from "net"; new Wj4()`, `{ AsyncLocalStorage as J_z } from "async_hooks"`, `{ PassThrough as Lrz } from "stream"`) threw `ReferenceError: identifier is not defined` when constructed at module init, crashing the natively-compiled Claude Code cli.js 2.1.112 bundle on nearly every command. The alias-rewrite block in `lower_new` (#5472) already rewrites the callee `class_name` from the local import name (`Wj4`) to the class's EXPORT name (`BlockList`) so construction matches the un-aliased form. The unresolved-`new` guard added in #8688 then re-probed `lookup_native_module(&class_name)` under that rewritten export name, but the registry is keyed on the LOCAL import name, so the lookup missed; none of these classes are reified global builtins, so the guard fired and threw even though the binding is perfectly resolvable. 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. Un-aliased imports and genuinely-undefined `new` targets are unchanged. Fixes #8730 Claude-Session: https://claude.ai/code/session_01TwxRkALrR9HKSF1zKLSTAF --- ...730-aliased-native-class-new-resolution.md | 1 + crates/perry-hir/src/lower/expr_new.rs | 14 +++ .../tests/aliased_native_new_resolution.rs | 101 ++++++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 changelog.d/8730-aliased-native-class-new-resolution.md create mode 100644 crates/perry-hir/tests/aliased_native_new_resolution.rs 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}" + ); +}