Skip to content

fix(hir): user class shadowing a global name loses to the intrinsic in new expressions (#6233)#6270

Merged
proggeramlug merged 2 commits into
mainfrom
fix/6233-shadowed-global-class
Jul 11, 2026
Merged

fix(hir): user class shadowing a global name loses to the intrinsic in new expressions (#6233)#6270
proggeramlug merged 2 commits into
mainfrom
fix/6233-shadowed-global-class

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Summary

A module-scope user class that legally shadows a global name — class Symbol extends Base {}new Symbol() — lost to the intrinsic: the built-in constructor arms in lower_new (perry-hir lower/expr_new.rs) fired by NAME alone. Depending on the name this either crashed at module init (Symbol/BigInt → "is not a constructor", Proxy/WeakRef/FinalizationRegistry/AggregateError → the intrinsic's argument validation) or silently constructed the wrong object (Map/Set/Date/Number/String/Boolean/Error/TypeError/Uint8Array/Int32Array → native instance, so field initializers never ran and instanceof the user class was false).

Real-world blocker: effect@4.0.0-beta.97's SchemaAST.ts declares AST node classes named Symbol and BigInt and constructs them at module init, so importing the effect barrel crashed under perry.compilePackages.

Baseline on current main (22-case blast-radius matrix from the issue): 16 of 22 shapes misbehaved. With this PR all 22 match node --experimental-strip-types byte-for-byte.

Root cause & fix

Same bug family as #5912/#5913 (new URL()) and #6003 (class Headers): unconditional by-name native routing that ignores lexical shadowing. Four surfaces:

  1. perry-hir/src/lower/expr_new.rs — the core fix. A shadowed_by_user_binding flag is snapshotted ONCE at the top of the ident arm (next to callee_local_at_entry, for the same scope-stability reason: argument lowering inside an arm can disturb the locals stack, so fresh lookups later are unreliable). It checks lookup_class / the local snapshot / lookup_func / lookup_imported_func / forward_class_names (a sibling class X declared later in the same function body). Every built-in constructor arm now backs off when the name is shadowed, so the construct falls through to the user-class/local-dispatch paths: Map, Set, Date, RegExp, the Symbol/BigInt/Math/JSON non-constructible rejection (runtime: throw TypeError for new Symbol and new BigInt #2883's missing shadow guard), Proxy, Number/String/Boolean boxed primitives, AggregateError, the Error family, WeakRef, FinalizationRegistry, Uint8Array, the other typed arrays, the bare-global MessageChannel/BroadcastChannel arm, and the existing Object/Function/URL/URLSearchParams/URLPattern/TextEncoder/TextDecoder guards are unified onto the same snapshot (they previously missed the forward-declared-class case).

  2. perry-hir/src/lower_types.rsinfer_type_from_expr's new C() arm typed new Map() as the builtin Generic { base: "Map" } by name, which routes the binding's later method calls (m.get(...)) down the collection intrinsic fast paths instead of the user methods. A user class in classes_index now wins (also gated url_encoding_constructor_type). Same guard added to the sibling inference chain in destructuring/var_decl/type_infer.rs (user-class arm moved ahead of the builtin-name arms).

  3. perry-hir/src/lower/lower_expr/arm_bin.rs — the x instanceof WeakRef|FinalizationRegistry compile-time fold answered from the weak-locals pre-scan even when a user class shadows the name; it now backs off (shadows_unqualified_global) so the generic instanceof path tests the user class id.

  4. perry-hir/src/lower/pre_scan.rs — the weak-locals pre-scan tagged const w = new WeakRef(...) as a native WeakRef instance by constructor name alone, so w.deref() dispatched to the intrinsic fast path even when WeakRef is a user class. The scan now collects shadowing declarations (class/function/var/import, same name-keyed scope-blind granularity as its existing poison mechanism) and skips tracking for shadowed names.

Testing

  • New regression test test-files/test_issue_6233_shadowed_global_class_new.ts: the exact issue repro plus the full blast-radius matrix (Symbol, BigInt, Proxy, WeakRef with a reserved deref method name, FinalizationRegistry, AggregateError, Map with reserved get/set method names, Set, Date, Number, String, Boolean, Error, TypeError, Uint8Array, Int32Array, RegExp with ctor args, and the previously-working Widget/Object/Array/Promise/Function controls). Matches node byte-for-byte.
  • 22-case standalone matrix (one file per shadowed name): 6/22 crashed and 10/22 constructed the wrong object on pristine main; 22/22 match node with this PR.
  • Unshadowed-builtin sanity file (native Map/Set/Date/WeakRef/RegExp/Uint8Array/boxed primitives/new Symbol() TypeError, iteration and .size/.exec fast paths) — byte-identical to node, so the guards don't disturb the normal native routing.
  • Targeted parity sweep (~160 existing map/set/date/error/proxy/weakref/regexp/symbol/url/typed-array/string/number tests) — no regressions vs pristine main.
  • cargo test -p perry-hir: 18/19 pass; the 1 failure (logical_property_assignment_short_circuits_the_store_4586) fails identically on pristine origin/main in a fresh worktree — pre-existing, unrelated to this change.
  • cargo fmt --all -- --check clean.

Not covered (separate defect noted in the issue): globalThis.Symbol("x") returning a typeof "object" value — that's the globalThis property read path, not new-expression lowering.

Fixes #6233.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed new resolution and constructor type inference when user-defined bindings shadow built-in constructors (including Symbol, BigInt, Proxy, WeakRef, FinalizationRegistry, Map, Set, Date, typed arrays, and errors).
    • Prevented built-in instanceof folding for WeakRef/FinalizationRegistry when the names are shadowed, ensuring correct runtime dispatch.
  • Tests

    • Added new Issue #6233 regression tests covering shadowed global classes and shadowed constructor-like functions/values.

…n `new` expressions (#6233)

A module-scope `class Symbol extends Base {}` legally shadows the global
Symbol, but the built-in constructor arms in lower_new fired by NAME
alone, so `new Symbol()` bound to the intrinsic and threw "Symbol is not
a constructor" at module init (effect's SchemaAST.ts declares AST node
classes named Symbol and BigInt — importing the effect barrel crashed at
startup under perry.compilePackages). Depending on the name this crashed
(Symbol/BigInt/Proxy/WeakRef/FinalizationRegistry/AggregateError) or
silently constructed the wrong object (Map/Set/Date/Number/String/
Boolean/Error/TypeError/Uint8Array/Int32Array: native instance, field
initializers never ran, instanceof the user class false). 16 of the
issue's 22 blast-radius shapes misbehaved on main.

Same family as #5912/#5913 (new URL) and #6003 (class Headers):
unconditional by-name native routing that ignores lexical shadowing.

- lower/expr_new.rs: snapshot `shadowed_by_user_binding` once at the
  ident-arm top (same scope-stability hazard as callee_local_at_entry:
  argument lowering inside an arm can disturb the locals stack) and gate
  every built-in constructor arm on it: Map/Set/Date/RegExp, the
  Symbol/BigInt/Math/JSON non-constructible rejection (#2883's missing
  shadow guard), Proxy, Number/String/Boolean boxed primitives,
  AggregateError, the Error family, WeakRef, FinalizationRegistry,
  Uint8Array, the other typed arrays, and the bare-global
  MessageChannel/BroadcastChannel arm. The existing Object/Function/URL/
  URLSearchParams/URLPattern/TextEncoder/TextDecoder guards are unified
  onto the same snapshot (adds the forward-declared sibling-class case
  they missed).
- lower_types.rs: infer_type_from_expr typed `new Map()` as the builtin
  Generic{base:"Map"} by name, which routes the binding's method calls
  down the collection intrinsic fast paths — SIGSEGV when the receiver
  is a user-class instance. A user class in classes_index now wins
  (also gated url_encoding_constructor_type). Mirrored in
  destructuring/var_decl/type_infer.rs (user-class arm moved ahead of
  the builtin-name arms).
- lower/lower_expr/arm_bin.rs: the `x instanceof WeakRef |
  FinalizationRegistry` compile-time fold answered from the weak-locals
  pre-scan even when the name is shadowed; it now backs off so the
  generic instanceof path tests the user class id.
- lower/pre_scan.rs: the weak-locals pre-scan tagged
  `const w = new WeakRef(x)` as a native weak instance by constructor
  name alone, so `w.deref()` dispatched to the intrinsic fast path;
  collect shadowing declarations (class/fn/var/import — same name-keyed
  scope-blind granularity as the existing poison set) and skip tracking
  shadowed names.

Regression test test_issue_6233_shadowed_global_class_new.ts covers the
issue repro plus the 22-name blast-radius matrix, reserved native method
names on shadowed classes (Map.get/set, WeakRef.deref), ctor args
through a shadowed RegExp, and instanceof for every case — matches node
byte-for-byte. Targeted parity sweep (~160 existing collection/error/
weak/url/typed-array tests) shows no regressions vs main.

Fixes #6233.
@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9f752587-7908-47ef-a0e1-88970dde333a

📥 Commits

Reviewing files that changed from the base of the PR and between 67db1d9 and e86d232.

📒 Files selected for processing (9)
  • crates/perry-hir/src/destructuring/var_decl/alias_tracking.rs
  • crates/perry-hir/src/destructuring/var_decl/binding_guards.rs
  • crates/perry-hir/src/destructuring/var_decl/native_fetch.rs
  • crates/perry-hir/src/destructuring/var_decl/native_new.rs
  • crates/perry-hir/src/destructuring/var_decl/type_infer.rs
  • crates/perry-hir/src/lower_decl/block.rs
  • crates/perry-hir/src/lower_types.rs
  • crates/perry-hir/src/lower_types/extract.rs
  • test-files/test_issue_6233_shadowed_global_fn_new.ts
💤 Files with no reviewable changes (5)
  • crates/perry-hir/src/destructuring/var_decl/binding_guards.rs
  • crates/perry-hir/src/destructuring/var_decl/native_fetch.rs
  • crates/perry-hir/src/destructuring/var_decl/native_new.rs
  • crates/perry-hir/src/destructuring/var_decl/alias_tracking.rs
  • crates/perry-hir/src/lower_decl/block.rs

📝 Walkthrough

Walkthrough

User-defined classes and bindings that shadow built-in constructor names are respected across constructor lowering, type inference, intrinsic pre-scanning, instanceof handling, and regression tests. Type-extraction helpers were moved into a dedicated module and wildcard imports were removed.

Changes

Constructor shadowing

Layer / File(s) Summary
Shadow-aware type inference and extraction
crates/perry-hir/src/lower_types.rs, crates/perry-hir/src/lower_types/extract.rs, crates/perry-hir/src/destructuring/var_decl/type_infer.rs
User-defined classes take precedence over built-in constructor inference; type-extraction and decorator helpers are centralized in lower_types/extract.rs.
Module shadow tracking
crates/perry-hir/src/lower/pre_scan.rs
Module declarations and imports are collected so shadowed names are excluded from weak and proxy intrinsic classification.
Shadow-aware constructor lowering
crates/perry-hir/src/lower/expr_new.rs, crates/perry-hir/src/lower/lower_expr/arm_bin.rs
Built-in constructor fast paths and WeakRef/FinalizationRegistry instanceof folding now require unshadowed names.
Shadowing regression coverage
test-files/test_issue_6233_shadowed_global_class_new.ts, test-files/test_issue_6233_shadowed_global_fn_new.ts
Adds coverage for shadowed intrinsic classes and functions, instance checks, method dispatch, constructor arguments, and existing constructor names.
Explicit lower_types imports
crates/perry-hir/src/destructuring/var_decl/*, crates/perry-hir/src/lower_decl/block.rs
Removes unused wildcard imports from affected modules.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

  • Issue 6233 — Directly tracks the constructor-shadowing behavior covered by these changes.

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main fix: shadowed user classes losing to intrinsic constructors in new expressions.
Description check ✅ Passed The description is detailed and covers the problem, fix, related issue, and testing, even though it doesn't follow the template exactly.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/6233-shadowed-global-class

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/perry-hir/src/lower_types.rs (1)

553-572: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Hoist the shadow guard above the generic return

new Map<number>() on a user-defined class Map<T> still lowers to Type::Generic { base: "Map", .. }, so the downstream Map fast paths can misclassify it as the builtin. Move the classes_index check ahead of the type_args early-return, or handle shadowed classes in that branch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-hir/src/lower_types.rs` around lines 553 - 572, In the
new-expression type lowering logic, user-defined classes must take precedence
over generic builtin handling. Update the logic around the type_args early
return and the classes_index shadow check so
classes_index.contains_key(name.as_str()) returns Type::Named(name) before
constructing Type::Generic, including for new Map<number>().
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-hir/src/destructuring/var_decl/type_infer.rs`:
- Around line 98-112: Update the constructor handling around the class-name
lookup to apply the same shadows_unqualified_global guard used by the other
constructor paths. Ensure user-defined or imported Map, Set, URLSearchParams,
and typed-array names bypass the builtin inference arms, while unshadowed
globals continue inferring Type::Named or Type::Generic correctly.

---

Outside diff comments:
In `@crates/perry-hir/src/lower_types.rs`:
- Around line 553-572: In the new-expression type lowering logic, user-defined
classes must take precedence over generic builtin handling. Update the logic
around the type_args early return and the classes_index shadow check so
classes_index.contains_key(name.as_str()) returns Type::Named(name) before
constructing Type::Generic, including for new Map<number>().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c3b7d37e-16cf-431b-9681-f34812dbda10

📥 Commits

Reviewing files that changed from the base of the PR and between 77ea179 and 67db1d9.

📒 Files selected for processing (6)
  • crates/perry-hir/src/destructuring/var_decl/type_infer.rs
  • crates/perry-hir/src/lower/expr_new.rs
  • crates/perry-hir/src/lower/lower_expr/arm_bin.rs
  • crates/perry-hir/src/lower/pre_scan.rs
  • crates/perry-hir/src/lower_types.rs
  • test-files/test_issue_6233_shadowed_global_class_new.ts

Comment thread crates/perry-hir/src/destructuring/var_decl/type_infer.rs
…typing

- lint: lower_types.rs hit 2007 lines (2000-line file cap) after the
  #6233 guards. Split per the repo recipe: the TS-annotation extraction
  group (extract_ts_type & friends, decorator lowering) moves to
  lower_types/extract.rs as a pure re-exported submodule — named
  re-exports keep every existing call path compiling unchanged. The six
  `use crate::lower_types::*` globs that became redundant are removed
  or replaced with named imports (back to the exact pre-change warning
  count).
- CodeRabbit Major: the type-inference shadow guards only honored user
  CLASSES — a local, `function Map() {}`, or import shadowing a
  built-in name still got the built-in declared type, routing method
  calls on the constructed instance down the intrinsic fast paths.
  Both inference sites (infer_type_from_expr's New arm +
  url_encoding_constructor_type, and the var-decl chain in
  destructuring/var_decl/type_infer.rs) now mirror the
  construction-side predicate (shadows_unqualified_global), scoped to
  a conservative builtin_constructor_inference_name set so
  module-export names that legitimately arrive through local bindings
  (Buffer, the stream and event classes) keep their arms.
- CodeRabbit Minor (outside-diff): `new Map<number>()` on a generic
  user `class Map<T>` took the explicit-type-args early return and
  still produced `Generic { base: "Map" }` — exactly what the
  collection fast-path recognizers key on. The user-class check is
  hoisted above that early return; built-in-colliding user classes
  stay `Named` even with explicit type args (both inference sites).
- New regression test test_issue_6233_shadowed_global_fn_new.ts:
  function/const constructors named Map / Set / Uint8Array /
  URLSearchParams whose instances carry intrinsic-colliding members
  (get/set/has/size/length) — matches node byte-for-byte.
@proggeramlug proggeramlug merged commit 94aeea1 into main Jul 11, 2026
23 of 25 checks passed
@proggeramlug proggeramlug deleted the fix/6233-shadowed-global-class branch July 11, 2026 05:25
proggeramlug pushed a commit that referenced this pull request Jul 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant