Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions changelog.d/8077-proxy-locals-binding-keyed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
### Fixed

- **Proxy lowering now follows the resolved binding instead of an identifier's
spelling (#7775).** A proxy local in one function no longer causes same-named
plain objects, parameters, loop bindings, or destructured values elsewhere
in the module to use proxy operations and silently read as `undefined`.
Proxies declared in class methods, arrow bodies, `Proxy.revocable`
destructuring, and forward-referenced module bindings continue to take the
proxy path.
10 changes: 10 additions & 0 deletions crates/perry-hir/src/destructuring/var_decl/alias_tracking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,16 @@ pub(crate) fn track_decl_aliases(
if let Some(method_name) = array_method_alias {
ctx.array_static_method_aliases.insert(id, method_name);
}
// #7775: bind "is a proxy" to THIS resolved binding rather than to the
// name. Keyed off the lowered init, so it inherits `lower_new_expr`'s
// `shadowed_by_user_binding` check for free — a user `class Proxy {}` never
// lowers to `Expr::ProxyNew`, so it never registers here. This is also the
// only registration that reaches a proxy declared in a class method or an
// arrow body: the pre-scan's `walk_stmt` does not descend into either, so
// those bindings were absent from `proxy_locals` entirely.
if matches!(init, Some(Expr::ProxyNew { .. })) {
ctx.register_proxy_local(id);
}
if let Some(Expr::NativeMethodCall { module, method, .. }) = &init {
if module == "fetch"
&& matches!(
Expand Down
30 changes: 30 additions & 0 deletions crates/perry-hir/src/lower/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ impl LoweringContext {
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(),
wasm_instance_locals: HashSet::new(),
plain_object_locals: HashSet::new(),
Expand Down Expand Up @@ -857,6 +858,35 @@ impl LoweringContext {
self.locals.lookup(name)
}

/// Record that `id` holds a proxy. Called from the declarator lowering once
/// the binding has a resolved `LocalId` and its initializer has lowered to
/// `Expr::ProxyNew` (#7775).
pub(crate) fn register_proxy_local(&mut self, id: LocalId) {
self.proxy_local_ids.insert(id);
}

/// Is a bare `name` at THIS point in the lowering a proxy receiver?
///
/// #7775: the answer is keyed on the resolved binding, not the spelling.
/// `proxy_locals` is a module-wide, scope-blind name set — a `new Proxy`
/// bound to `a` in one function made every other function's `a.prop` lower
/// to `js_proxy_get`, which answers `undefined` on a non-proxy. Whenever the
/// receiver resolves to a local we consult `proxy_local_ids` instead, so a
/// same-named non-proxy binding is simply a different binding.
///
/// KNOWN HOLE, stated plainly: a receiver that resolves to NO local (a bare
/// global, or a module-level binding referenced from a function body lowered
/// before that binding was pre-registered) still falls back to the name set,
/// and is still scope-blind. That arm is kept because dropping it would
/// regress genuine proxies reached through those paths; it is strictly no
/// worse than the pre-#7775 behaviour, which used it for everything.
pub(crate) fn is_proxy_local(&self, name: &str) -> bool {
match self.lookup_local(name) {
Some(id) => self.proxy_local_ids.contains(&id),
None => self.proxy_locals.contains(name),
}
}

/// Like `lookup_local`, but only searches locals defined in the CURRENT
/// function scope (at or after the most recent `enter_scope` mark). Used by
/// function-declaration hoisting so a nested `function a` SHADOWS an
Expand Down
4 changes: 2 additions & 2 deletions crates/perry-hir/src/lower/expr_assign.rs
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,7 @@ fn lower_assignment_target(
// Proxy set: `proxy.foo = v` / `proxy[k] = v`
if let ast::Expr::Ident(obj_ident) = member.obj.as_ref() {
let obj_name = obj_ident.sym.to_string();
if ctx.proxy_locals.contains(&obj_name) {
if ctx.is_proxy_local(&obj_name) {
let proxy = Box::new(if let Some(id) = ctx.lookup_local(&obj_name) {
Expr::LocalGet(id)
} else {
Expand Down Expand Up @@ -1279,7 +1279,7 @@ pub(crate) fn hoist_compound_member_assign(
}
if let ast::Expr::Ident(obj_ident) = member.obj.as_ref() {
let n = obj_ident.sym.as_ref();
if ctx.proxy_locals.contains(n) || !ctx.active_with_envs_for_ident(n).is_empty() {
if ctx.is_proxy_local(n) || !ctx.active_with_envs_for_ident(n).is_empty() {
return Ok(None);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ pub(super) fn try_local_array_methods(
// gate (a proxy local is typed `Named("Proxy")`), but
// `reduce`/`forEach`/`join`/`sort`/`splice`/… did not — guard
// them all here, uniformly, by falling through.
if ctx.proxy_locals.contains(&arr_name) {
if ctx.is_proxy_local(&arr_name) {
return Ok(Err(args));
}
// Check that this is NOT a String type (Array, Set, Map are all OK)
Expand Down
2 changes: 1 addition & 1 deletion crates/perry-hir/src/lower/expr_call/post_args_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ pub(super) fn try_proxy_call(
if let ast::Callee::Expr(callee_expr) = &call.callee {
if let ast::Expr::Ident(ident) = callee_expr.as_ref() {
let name = ident.sym.to_string();
if ctx.proxy_locals.contains(&name) {
if ctx.is_proxy_local(&name) {
if let Some(id) = ctx.lookup_local(&name) {
return Ok(Expr::ProxyApply {
proxy: Box::new(Expr::LocalGet(id)),
Expand Down
2 changes: 1 addition & 1 deletion crates/perry-hir/src/lower/expr_member.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1081,7 +1081,7 @@ fn lower_member_inner(ctx: &mut LoweringContext, member: &ast::MemberExpr) -> Re
let inner = unwrap_member_obj(member.obj.as_ref());
if let ast::Expr::Ident(obj_ident) = inner {
let obj_name = obj_ident.sym.to_string();
if ctx.proxy_locals.contains(&obj_name) {
if ctx.is_proxy_local(&obj_name) {
let proxy_expr = if let Some(id) = ctx.lookup_local(&obj_name) {
Expr::LocalGet(id)
} else {
Expand Down
2 changes: 1 addition & 1 deletion crates/perry-hir/src/lower/expr_new.rs
Original file line number Diff line number Diff line change
Expand Up @@ -882,7 +882,7 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R
arg_present,
});
}
if ctx.proxy_locals.contains(&class_name) {
if ctx.is_proxy_local(&class_name) {
let args = new_expr
.args
.as_ref()
Expand Down
2 changes: 1 addition & 1 deletion crates/perry-hir/src/lower/lower_expr/arm_bin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ pub(crate) fn lower_bin_expr(ctx: &mut LoweringContext, bin: &ast::BinExpr) -> R
// Proxy fast path: `key in proxy` routes through js_proxy_has.
if let ast::Expr::Ident(obj_ident) = bin.right.as_ref() {
let obj_name = obj_ident.sym.to_string();
if ctx.proxy_locals.contains(&obj_name) {
if ctx.is_proxy_local(&obj_name) {
let key = Box::new(lower_expr(ctx, &bin.left)?);
let proxy = Box::new(lower_expr(ctx, &bin.right)?);
return Ok(Expr::ProxyHas { proxy, key });
Expand Down
40 changes: 39 additions & 1 deletion crates/perry-hir/src/lower/lower_module_fn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,31 @@ fn decl_init_is_class_expr(decl: &ast::VarDeclarator) -> bool {
}
}

/// #7775: does this declarator's initializer spell `new Proxy(...)`? Used by
/// the module-level pre-registration pass, which runs before any declarator is
/// lowered and so has no `Expr::ProxyNew` to match on. Mirrors
/// `decl_init_is_class_expr`'s unwrapping of the TS casts a `new Proxy` init
/// usually carries (`new Proxy(raw, {}) as any`).
fn decl_init_is_proxy_new(decl: &ast::VarDeclarator) -> bool {
let mut e = match &decl.init {
Some(init) => init.as_ref(),
None => return false,
};
loop {
match e {
ast::Expr::Paren(p) => e = &p.expr,
ast::Expr::TsAs(a) => e = &a.expr,
ast::Expr::TsNonNull(n) => e = &n.expr,
ast::Expr::TsTypeAssertion(a) => e = &a.expr,
ast::Expr::TsConstAssertion(a) => e = &a.expr,
ast::Expr::New(new_expr) => {
return matches!(new_expr.callee.as_ref(), ast::Expr::Ident(i) if i.sym.as_ref() == "Proxy")
}
_ => return false,
}
}
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
/// Issue #668: superset of the `_seed_and_entry` wrapper that also accepts
/// `is_external_module`. Callers in `crates/perry/src/commands/compile/`
/// pass `true` when the source file lives under any `node_modules/` segment
Expand Down Expand Up @@ -729,7 +754,20 @@ pub fn lower_module_full(
Some(("util" | "node:util", None))
)
});
ctx.define_local(name.clone(), ty);
let pre_id = ctx.define_local(name.clone(), ty);
// #7775: a module-level `const p = new Proxy(...)` is
// pre-registered here, so a function body lowered
// EARLIER already resolves `p` to this id. Since
// `is_proxy_local` prefers the resolved binding over the
// name set, the id has to be marked now or that forward
// reference would silently lose the proxy fast path.
// The pre-scan's `proxy_locals` membership is the gate,
// so the `class Proxy {}` shadow rule (#6233) still
// applies; the declarator's own lowering re-registers
// the same id and is the authority for every later use.
if ctx.proxy_locals.contains(&name) && decl_init_is_proxy_new(decl) {
ctx.register_proxy_local(pre_id);
}
ctx.pre_registered_module_vars.insert(name);
if var_decl.kind == ast::VarDeclKind::Var {
ctx.pre_registered_module_var_decls
Expand Down
15 changes: 15 additions & 0 deletions crates/perry-hir/src/lower/lowering_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -675,7 +675,22 @@ pub struct LoweringContext {
/// takes `this` as its first parameter. Consumed by `for...of` to
/// dispatch through the iterator protocol via a direct FuncRef call.
pub(crate) iterator_func_for_class: std::collections::HashMap<String, crate::types::FuncId>,
/// Bare NAMES the module-wide pre-scan saw bound to `new Proxy(...)`.
/// Scope-blind by construction, so this is no longer authoritative — it is
/// the fallback arm of `LoweringContext::is_proxy_local`, consulted only for
/// a receiver that resolves to no local at all. See `proxy_local_ids`.
pub(crate) proxy_locals: HashSet<String>,
/// #7775: the RESOLVED bindings that actually hold a proxy. Recorded when a
/// declarator's lowered initializer is `Expr::ProxyNew` (and for the
/// `Proxy.revocable` destructuring form), so "this binding is a proxy" stops
/// meaning "something in this file spells it this way".
///
/// `proxy_locals` alone rewrote property reads/writes and `in` checks to
/// proxy operations on any receiver sharing the spelling, in any function.
/// `js_proxy_get` on a non-proxy answers `undefined`, so the wrong answer
/// was silent — a plain `const a = { v: 42 }; a.v` read `undefined` merely
/// because some never-called function elsewhere also named a proxy `a`.
pub(crate) proxy_local_ids: HashSet<LocalId>,
/// #3144: local name -> builtin prototype method name, for bindings like
/// `const m = [].map` / `const s = "".slice`. Lets the `.call`/`.apply`
/// rewrite recognize `m.call(arr, ...)` (the receiver of `.call` is a plain
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-hir/src/lower/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,4 +133,6 @@ mod collection_view_tests;
#[cfg(test)]
mod for_of_counter_tests;
#[cfg(test)]
mod proxy_binding_tests;
#[cfg(test)]
mod tests;
16 changes: 13 additions & 3 deletions crates/perry-hir/src/lower/pre_scan/weakref_locals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,11 @@ pub(crate) fn pre_scan_weakref_locals(ast_module: &ast::Module, ctx: &mut Loweri
//
// Deliberately narrow: only call/await initializers, which are
// the opaque ones. A literal, a member read or an identifier
// copy stays unpoisoned, so the common `const p = new Proxy(…)`
// in a module that also does `const p = { … }` elsewhere is
// untouched by this arm.
// copy stays unpoisoned. That narrowness no longer decides
// correctness — `is_proxy_local` keys on the resolved binding
// and only consults this name set for a receiver that resolves
// to no local — but poisoning an ambiguous name still narrows
// that fallback, so the arm stays.
poison.insert(ident.id.sym.to_string());
} else if let ast::Expr::Member(member) = init_unwrapped {
// #1750: `const w = path.win32` / `const p = path.posix`.
Expand Down Expand Up @@ -368,6 +370,14 @@ pub(crate) fn pre_scan_weakref_locals(ast_module: &ast::Module, ctx: &mut Loweri
// back. A genuine proxy keeps working through the ordinary dynamic
// property path (asserted in
// `test-files/test_gap_proxy_local_name_collision_7775.ts`).
//
// This subtraction is now a second line of defence, not the fix: the
// set it edits is only the `is_proxy_local` fallback for receivers that
// resolve to no local. Poisoning cannot reach a mis-keyed receiver that
// DOES resolve, which is why every shape in that file's second half
// (a parameter, `const a: P[] = []`, an identifier copy, a `for…of`
// head, a destructured binding, an object literal) needed the
// binding-keyed check instead.
ctx.proxy_locals.remove(name);
}
}
Loading
Loading