From f2a0f3e777af7d2a09832bace252667f0607a649 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 6 Sep 2026 18:55:27 +0200 Subject: [PATCH] fix(hir): scope native assignment instances --- .../9904-native-instance-assignment-scope.md | 8 ++ crates/perry-hir/src/lower/context.rs | 6 +- crates/perry-hir/src/lower/expr_assign.rs | 65 +++++++++------- .../tests/native_instance_binding_scope.rs | 76 +++++++++++++++++++ 4 files changed, 123 insertions(+), 32 deletions(-) create mode 100644 changelog.d/9904-native-instance-assignment-scope.md diff --git a/changelog.d/9904-native-instance-assignment-scope.md b/changelog.d/9904-native-instance-assignment-scope.md new file mode 100644 index 0000000000..42d6a7cdf2 --- /dev/null +++ b/changelog.d/9904-native-instance-assignment-scope.md @@ -0,0 +1,8 @@ +### Fixed + +- Native instances assigned with `target = new NativeClass(...)` or propagated + with `target = source` are now tracked by the resolved binding rather than by + identifier text across the whole module. A native handle named `O` can no + longer make unrelated bindings named `O` dispatch ordinary methods through + that native class, while module-level handles and unresolved global fallbacks + retain their existing cross-function behavior. diff --git a/crates/perry-hir/src/lower/context.rs b/crates/perry-hir/src/lower/context.rs index 0a5826a26d..1305c29fa1 100644 --- a/crates/perry-hir/src/lower/context.rs +++ b/crates/perry-hir/src/lower/context.rs @@ -1641,7 +1641,7 @@ impl LoweringContext { .filter(|(_, module, class)| !exposes_plain_object_fields(module, class)) .map(|(_, module, class)| (module.as_str(), class.as_str())) .or_else(|| { - // #9847: a bare assignment (`O = cp.spawn(...)`) tags the + // #9847/#9858: assignment-derived native tags use the // RESOLVED binding, not the spelling. Consulted before the // name-keyed module-wide table below, so a same-named binding // in another function is simply a different binding and cannot @@ -1721,8 +1721,8 @@ impl LoweringContext { /// #9847: tag the RESOLVED binding `id` as holding a native instance. /// - /// Used by the bare-assignment path (`O = cp.spawn(...)`) in place of - /// `push_module_native_instance`, whose name key was module-wide: in a + /// Used by native-instance assignment paths in place of name-keyed + /// module-wide registration: in a /// minified single-module bundle a single native handle poisoned every /// homonym in the program. Keyed on the `LocalId` the target resolves to, /// this keeps the cross-function reach the module-wide table was there to diff --git a/crates/perry-hir/src/lower/expr_assign.rs b/crates/perry-hir/src/lower/expr_assign.rs index 123f562e05..a947b6cdf9 100644 --- a/crates/perry-hir/src/lower/expr_assign.rs +++ b/crates/perry-hir/src/lower/expr_assign.rs @@ -165,6 +165,26 @@ fn lower_logical_assignment( Ok(Expr::Logical { op, left, right }) } +fn register_assignment_native_instance( + ctx: &mut LoweringContext, + var_name: String, + module_name: String, + class_name: String, + register_scoped_fallback: bool, +) { + if let Some(local_id) = ctx.lookup_local(&var_name) { + ctx.register_local_id_native_instance(local_id, module_name, class_name); + return; + } + + // An unresolvable assignment target has no LocalId to key on. Preserve + // the former name-keyed registrations for that global fallback path. + if register_scoped_fallback { + ctx.register_native_instance(var_name.clone(), module_name.clone(), class_name.clone()); + } + ctx.push_module_native_instance((var_name, module_name, class_name)); +} + pub(super) fn lower_assign(ctx: &mut LoweringContext, assign: &ast::AssignExpr) -> Result { // Detect assignments from native module calls and register for cross-function tracking. // e.g., `mongoClient = await MongoClient.connect(uri)` registers mongoClient as a mongodb instance. @@ -221,22 +241,13 @@ pub(super) fn lower_assign(ctx: &mut LoweringContext, assign: &ast::AssignExpr) // key on and keeps the old name-keyed // registration; see the matching arm in // `lookup_native_instance`. - match ctx.lookup_local(&var_name) { - Some(local_id) => { - ctx.register_local_id_native_instance( - local_id, - module_name.to_string(), - class_name.to_string(), - ); - } - None => { - ctx.push_module_native_instance(( - var_name.clone(), - module_name.to_string(), - class_name.to_string(), - )); - } - } + register_assignment_native_instance( + ctx, + var_name.clone(), + module_name.to_string(), + class_name.to_string(), + false, + ); } } } @@ -252,16 +263,13 @@ pub(super) fn lower_assign(ctx: &mut LoweringContext, assign: &ast::AssignExpr) .lookup_native_module(class_name_str) .map(|(m, _)| m.to_string()); if let Some(module_name) = native_info { - ctx.register_native_instance( - var_name.clone(), - module_name.clone(), - class_name_str.to_string(), - ); - ctx.push_module_native_instance(( + register_assignment_native_instance( + ctx, var_name.clone(), module_name, class_name_str.to_string(), - )); + true, + ); } } } @@ -269,12 +277,11 @@ pub(super) fn lower_assign(ctx: &mut LoweringContext, assign: &ast::AssignExpr) // e.g., `mongoClient = client` where client was tracked from MongoClient.connect(). if let ast::Expr::Ident(rhs_ident) = inner_rhs { let rhs_name = rhs_ident.sym.as_ref(); - if let Some((module, class)) = ctx.lookup_native_instance(rhs_name) { - ctx.push_module_native_instance(( - var_name, - module.to_string(), - class.to_string(), - )); + let native_info = ctx + .lookup_native_instance(rhs_name) + .map(|(module, class)| (module.to_string(), class.to_string())); + if let Some((module, class)) = native_info { + register_assignment_native_instance(ctx, var_name, module, class, false); } } } diff --git a/crates/perry-hir/tests/native_instance_binding_scope.rs b/crates/perry-hir/tests/native_instance_binding_scope.rs index 1d9fe3731d..b756ddc609 100644 --- a/crates/perry-hir/tests/native_instance_binding_scope.rs +++ b/crates/perry-hir/tests/native_instance_binding_scope.rs @@ -208,3 +208,79 @@ export function widthLike(q: any): number { {arm_b}" ); } + +const NEW_ASSIGNMENT_FIXTURE: &str = r#" +import { BlockList } from "net"; + +export function maker(): any { + let O: any; + O = new BlockList(); + O.addSubnet("10.0.0.0", 8); + return O; +} + +export function widthLike(q: any): number { + let Y = 0; + for (let { segment: O } of q) { + Y += O.codePointAt(0) >= 4352 ? 2 : 1; + } + return Y; +} +"#; + +const PROPAGATED_ASSIGNMENT_FIXTURE: &str = r#" +import * as cp from "child_process"; + +export function copier(): any { + let source: any; + source = cp.spawn("true", []); + let O: any; + O = source; + O.kill(); + return O; +} + +export function widthLike(q: any): number { + let Y = 0; + for (let { segment: O } of q) { + Y += O.codePointAt(0) >= 4352 ? 2 : 1; + } + return Y; +} +"#; + +fn assert_unrelated_width_binding_is_ordinary(module: &perry_hir::Module) { + let width_like = body_of(module, "widthLike"); + assert!( + !width_like.contains("method: \"codePointAt\""), + "the unrelated for-of binding must not inherit a native-instance tag: \ + {width_like}" + ); + assert!( + width_like.contains("property: \"codePointAt\""), + "the string binding's method should remain an ordinary property call: \ + {width_like}" + ); +} + +#[test] +fn a_native_constructor_assignment_does_not_tag_an_unrelated_homonym() { + let module = lower(NEW_ASSIGNMENT_FIXTURE); + let maker = body_of(&module, "maker"); + assert!( + maker.contains("module: \"net\"") && maker.contains("method: \"addSubnet\""), + "the assigned BlockList binding must retain native dispatch: {maker}" + ); + assert_unrelated_width_binding_is_ordinary(&module); +} + +#[test] +fn a_propagated_native_assignment_does_not_tag_an_unrelated_homonym() { + let module = lower(PROPAGATED_ASSIGNMENT_FIXTURE); + let copier = body_of(&module, "copier"); + assert!( + copier.contains("module: \"child_process\"") && copier.contains("method: \"kill\""), + "the propagated handle binding must retain native dispatch: {copier}" + ); + assert_unrelated_width_binding_is_ordinary(&module); +}