Skip to content

fix(hir): drop static extends_name for lexically-shadowed class heritage - #6106

Merged
proggeramlug merged 1 commit into
PerryTS:mainfrom
proggeramlug:fix/lexically-shadowed-heritage-extends-name
Jul 8, 2026
Merged

fix(hir): drop static extends_name for lexically-shadowed class heritage#6106
proggeramlug merged 1 commit into
PerryTS:mainfrom
proggeramlug:fix/lexically-shadowed-heritage-extends-name

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Problem

class A extends Base where Base is an in-scope lexical local (a let/const/param), not a class, is heritage-shadowed: the parent is a runtime value that must be resolved dynamically via extends_expr.

The lowering already handled this partially — it captured extends_expr and set extends = None — but it still left extends_name = Some("Base"). That textual name is then re-resolved by the many STATIC parent-chain walks in codegen (packed-keys field layout, the js_register_class_parent edge, inherited-method / vtable install, type-facts) through the module-wide name→class map, binding the subclass to an unrelated same-named class elsewhere in the module — e.g. a function-local class Base that leaked into the global map.

How it surfaces

In a large minified program, a helper does:

let Y = _?.Parent ?? Object;   // Y is a LOCAL variable
class A extends Y {}           // extends the runtime value, not a class

Y was mis-bound to an unrelated captured, function-local iterator class also named Y (which declares a private #q). A instances then inherited that class's field layout / methods, and a this.#q access threw:

TypeError: Cannot access private member from an object whose class did not declare it

on a perfectly legal receiver (A's real runtime parent is Object, so its class-id chain never reaches the iterator's).

Fix

For a lexically-shadowed heritage, leave both extends and extends_name None — matching the well-supported fully-dynamic class X extends <runtimeValue> shape. The parent edge is wired at runtime via js_register_class_parent_dynamic, and super() runs through extends_expr + heritage_lexically_shadowed.

The super-call codegen gate (this_super_call.rs) is updated to proceed via extends_expr when extends_name is absent, so a shadowed subclass's super() still runs its (dynamic) parent constructor rather than silently no-op'ing.

Both the class-declaration and class-expression arms in class_decl.rs are fixed (they already documented this intent for extends, but the retained extends_name defeated it).

Why this is safe

locally_shadowed requires the parent name to be a let/const/var/param local; class declarations are not in ctx.locals, so ordinary class-to-class inheritance is unaffected (verified by the second regression test and by the existing #5437 suite, which extends same-named class declarations, not locals).

Tests

  • test_lexically_shadowed_heritage_drops_static_extends_name: a let Base = …; class A extends Base {} lowers to extends_name == None, extends == None, extends_expr == Some, heritage_lexically_shadowed == true.
  • test_plain_class_to_class_heritage_keeps_static_extends_name: a plain class Sub extends Base {} (both class decls) keeps its static extends_name.

Also verified end-to-end: the private-member throw no longer occurs in the affected program, and a shadowed subclass calling super() on a runtime-value parent still runs the parent constructor (instanceof + inherited + own fields all correct).

Summary by CodeRabbit

  • Bug Fixes
    • Improved class inheritance handling when a parent name is shadowed by a local value.
    • super() now works correctly with dynamically resolved parents instead of stopping early.
    • Classes without a parent still safely return an undefined-like result after evaluating arguments.

@coderabbitai

coderabbitai Bot commented Jul 7, 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: cce1eb31-a360-435a-9f00-a744b71300d8

📥 Commits

Reviewing files that changed from the base of the PR and between 5d3d4a7 and 2cc8a60.

📒 Files selected for processing (3)
  • crates/perry-codegen/src/expr/this_super_call.rs
  • crates/perry-hir/src/lower/tests.rs
  • crates/perry-hir/src/lower_decl/class_decl.rs

📝 Walkthrough

Walkthrough

Codegen for SuperCall now derives parent_name via a three-way match on extends_name/extends_expr, allowing dynamic dispatch when the parent is lexically shadowed instead of returning undefined early. HIR lowering clears extends_name (keeping extends_expr) in this shadowed case, and new tests validate both scenarios.

Changes

Lexically shadowed extends handling

Layer / File(s) Summary
Clear extends_name for lexically shadowed heritage
crates/perry-hir/src/lower_decl/class_decl.rs
In lower_class_decl and lower_class_from_ast, the tuple returned for a lexically shadowed extends local now sets extends_name to None (was Some(parent_name)) on success, keeping extends_expr set for runtime resolution, with None on error paths too.
Update SuperCall codegen for dynamic extends_expr
crates/perry-codegen/src/expr/this_super_call.rs
Expr::SuperCall lowering now matches three cases: extends_name present uses it as parent_name; extends_name absent with extends_expr present sets parent_name to an empty string and continues execution; both absent lowers super_args and returns 0.0.
Regression tests for shadowed vs non-shadowed extends
crates/perry-hir/src/lower/tests.rs
Adds tests confirming a lexically shadowed parent clears extends_name/extends while keeping extends_expr, and confirming a plain class-declaration parent preserves extends_name with heritage_lexically_shadowed false.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
    participant Lowering as HIR Lowering
    participant ClassDecl as ClassDecl (extends)
    participant Codegen as SuperCall Codegen
    participant Runtime as Runtime Dispatch

    Lowering->>ClassDecl: resolve extends parent identifier
    alt parent is lexically shadowed local
        ClassDecl->>ClassDecl: set extends_name = None, extends_expr = lowered expr
    else parent is class declaration
        ClassDecl->>ClassDecl: keep extends_name = Some(name)
    end
    ClassDecl->>Codegen: current_class.extends_name / extends_expr
    alt extends_name present
        Codegen->>Codegen: parent_name = extends_name
    else extends_name absent, extends_expr present
        Codegen->>Codegen: parent_name = "" (dynamic)
        Codegen->>Runtime: dispatch super() via extends_expr
    else both absent
        Codegen->>Codegen: lower super_args, return undefined
    end
Loading

Possibly related PRs

  • PerryTS/perry#5705: Both PRs update the same Expr::SuperCall/class-inheritance lowering+codegen paths so lexically shadowed extends parents use dynamic extends_expr instead of extends_name.
  • PerryTS/perry#5724: Both PRs fix HIR class-name resolution to respect lexical shadowing so super() uses extends_expr when the parent identifier is shadowed by a local.
  • PerryTS/perry#5686: Both PRs change lower_decl/class_decl.rs inheritance lowering to resolve the extends superclass via scope-aware handling instead of a stale/static parent name.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is detailed but does not follow the required template sections for Summary, Changes, Related issue, Test plan, and Checklist. Reformat into the repo template with Summary, Changes, Related issue, Test plan, Screenshots/output, and Checklist sections.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main fix: clearing static extends_name for lexically shadowed class heritage.
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 unit tests (beta)
  • Create PR with unit tests

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.

`class A extends Base` where `Base` is an in-scope lexical local (a
let/const/param), not a class, is heritage-shadowed: the parent is a
runtime value resolved dynamically via `extends_expr`. Lowering already
captured `extends_expr` and set `extends = None`, but still left
`extends_name = Some("Base")`. The many STATIC parent-chain walks in
codegen (packed-keys field layout, `js_register_class_parent` edge,
inherited-method / vtable install, type-facts) re-resolve that bare name
through the module-wide name->class map, binding to an UNRELATED same-named
class elsewhere in the module -- e.g. a function-local `class Base` that
leaked into the global map.

In a large minified program this mis-bound
`let Y = _?.Parent ?? Object; class A extends Y {}` to a captured
function-local iterator class also named `Y` (declaring a private `#q`), so
`A` instances inherited that class's layout/methods and a `this.#q` access
threw "Cannot access private member from an object whose class did not
declare it" on a legal receiver.

Fix: for a lexically-shadowed heritage, leave both `extends` and
`extends_name` `None` (matching the fully-dynamic
`class X extends <runtimeValue>` shape). The parent edge is wired at runtime
via `js_register_class_parent_dynamic` and `super()` runs through
`extends_expr` + `heritage_lexically_shadowed`. The super-call codegen gate
is updated to proceed via `extends_expr` when `extends_name` is absent, so a
shadowed subclass's `super()` still runs its (dynamic) parent constructor.

Adds HIR lowering regression tests: a lexically-shadowed heritage lowers to
`extends_name = None` + `extends_expr = Some`, while a plain
class-to-class heritage keeps its static `extends_name`.
@proggeramlug
proggeramlug force-pushed the fix/lexically-shadowed-heritage-extends-name branch from 1e465bf to 2cc8a60 Compare July 7, 2026 17:26
@proggeramlug
proggeramlug merged commit 5cf866f into PerryTS:main Jul 8, 2026
25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant