Skip to content

fix(hir): class own-name binding is const inside the class body - #6021

Merged
proggeramlug merged 1 commit into
mainfrom
fix/t262-class-name-binding-const
Jul 5, 2026
Merged

fix(hir): class own-name binding is const inside the class body#6021
proggeramlug merged 1 commit into
mainfrom
fix/t262-class-name-binding-const

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Summary

The binding for a class's own name is an immutable (const) binding in the class scope — the declarative environment record created for the class body. Assigning to that name from anywhere inside the class body (constructor, method, accessor, or an expression in the heritage clause) must throw a TypeError. The outer declaration binding introduced by a class C {} statement is a separate, mutable binding, so reassigning C from outside the body stays legal.

Perry previously dropped such an assignment silently: the <Name> = X branch in the identifier-assignment lowering evaluated the RHS for side effects and returned, treating the class binding as a no-op target. So none of the assert.throws(TypeError, ...) cases fired.

Fix

Track the source-level inner binding name on the lowering context:

  • current_class_inner_name — saved/restored around each class body (both lower_class_decl and lower_class_from_ast).
  • pending_class_inner_name — fed by the class-expression call sites (arm_class.rs, expr_new/non_ident.rs, and the var C = class Named {...} fast path in stmt.rs), because those receive a synthetic dedup registration key rather than the user-visible name.

When an identifier assignment target matches that inner name — and no local/param shadows it (the lookup_local arm runs first, so a shadowing binding wins) — the lowering now evaluates the RHS and then throws via the existing js_throw_type_error_const_assignment runtime helper.

Handles constructor, method, accessor (get/set), and heritage-expression forms, for both class declarations and named class expressions. Outer-binding reassignment is unchanged.

Tests (internal Linux sweep host, test262 class cluster)

  • Fixes language/statements/class/name-binding/const.js.
  • Full language/statements/class + language/expressions/class sweep: +1 pass, zero regressions (diffed the failure sidecar before/after).
  • Manually verified: constructor / method / getter / setter / class-expression forms all throw TypeError; shadowing param/local, nested-class own-name, and outer-scope reassignment do not false-throw.

Part of the test262 class-cluster parity work (tracker #793).

Summary by CodeRabbit

  • Bug Fixes
    • Improved handling of class inner bindings so assignments to a class’s own immutable name inside the class body now correctly raise a TypeError.
    • Preserved the correct class name context during class lowering, including for class expressions, to ensure binding checks use the visible inner name.

Per the class definition semantics, the binding for a class's own name is
an *immutable* (const) binding in the class scope — the environment record
created for the class body. Assigning to that name from anywhere inside the
class body (constructor, method, accessor, or an expression in the heritage
clause) must throw a TypeError. The outer declaration binding introduced by
a `class C {}` statement is a separate, mutable binding, so reassigning `C`
from *outside* the body stays legal.

Perry previously dropped such an assignment silently (the `<Name> = X`
branch in the ident-assignment lowering evaluated the RHS for side effects
and returned, treating the class binding as a no-op target), so none of the
`assert.throws(TypeError, ...)` cases fired.

This tracks the source-level inner binding name on the lowering context
(`current_class_inner_name`, saved/restored around each class body, and fed
for class *expressions* via `pending_class_inner_name` since those receive a
synthetic dedup registration key rather than the user-visible name). When an
identifier assignment target matches that inner name — and no local/param
shadows it — the lowering now evaluates the RHS and then throws via the
existing `js_throw_type_error_const_assignment` runtime helper.

Fixes the test262 language/{statements,expressions}/class name-binding
const cases and the inner-binding-immutability assertions in the
scope-name-lex heritage/no-heritage tests, with no change to outer-binding
reassignment.
@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds two new fields to LoweringContext (current_class_inner_name, pending_class_inner_name) to track a class's own inner binding name during HIR lowering, then uses current_class_inner_name to detect and reject assignments to a class's own identifier from within its body, throwing a TypeError.

Changes

Const assignment guard for class inner binding names

Layer / File(s) Summary
Context field declarations
crates/perry-hir/src/lower/lowering_context.rs, crates/perry-hir/src/lower/context.rs
Adds current_class_inner_name and pending_class_inner_name fields to LoweringContext with documentation, and initializes both to None in with_class_id_start.
Const-assignment rejection
crates/perry-hir/src/lower/expr_assign.rs
Adds a branch in the assignment target lowering that matches against current_class_inner_name, evaluates the RHS for side effects, then throws a TypeError for constant assignment.
Pending inner name population at call sites
crates/perry-hir/src/lower/expr_new/non_ident.rs, crates/perry-hir/src/lower/lower_expr/arm_class.rs, crates/perry-hir/src/lower/stmt.rs
Sets pending_class_inner_name from the class expression's source identifier before calling lower_class_from_ast in the new callee path, the class-expression arm, and the variable-declarator fast path.
Snapshot/set/restore lifecycle
crates/perry-hir/src/lower_decl/class_decl.rs
In lower_class_decl and lower_class_from_ast, saves the prior current_class_inner_name, sets it from the declaration identifier or pending_class_inner_name (falling back to name), and restores it at function epilogue.

Estimated code review effort: 2 (Simple) | ~12 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Source as Class source (decl/expr)
  participant Lowerer as Lowering entry points
  participant Ctx as LoweringContext
  participant AssignLowering as lower_assignment_target

  Source->>Lowerer: class declaration/expression with ident
  Lowerer->>Ctx: set pending_class_inner_name (if applicable)
  Lowerer->>Ctx: snapshot current_class_inner_name
  Lowerer->>Ctx: set current_class_inner_name from pending/decl ident
  Note over Lowerer: lower class body statements
  AssignLowering->>Ctx: read current_class_inner_name
  alt assignment target matches inner name
    AssignLowering->>AssignLowering: lower RHS for side effects
    AssignLowering->>AssignLowering: throw TypeError (const assignment)
  else no match
    AssignLowering->>AssignLowering: lower normal assignment
  end
  Lowerer->>Ctx: restore previous current_class_inner_name
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main semantic fix to class own-name bindings.
Description check ✅ Passed The description covers the change, implementation details, related tracker, and validation notes, with only template formatting missing.
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/t262-class-name-binding-const

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.

@proggeramlug
proggeramlug merged commit 13168ab into main Jul 5, 2026
15 of 17 checks passed
@proggeramlug
proggeramlug deleted the fix/t262-class-name-binding-const branch July 5, 2026 13:13

@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

🤖 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/lower_decl/class_decl.rs`:
- Around line 1431-1437: The class lowering in `class_decl.rs` is incorrectly
falling back to `name` when `pending_class_inner_name` is absent, which makes
anonymous `class {}` expressions inherit an inner binding they should not have.
Update the `current_class_inner_name` setup in the class declaration lowering
path so it is only set from `pending_class_inner_name` and remains `None`
otherwise, preserving anonymous class behavior in
`lower_decl`/`current_class_inner_name` handling.
🪄 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: 0860c23a-b012-4101-bcba-c79c97d12a87

📥 Commits

Reviewing files that changed from the base of the PR and between 8366761 and 88a836c.

📒 Files selected for processing (7)
  • crates/perry-hir/src/lower/context.rs
  • crates/perry-hir/src/lower/expr_assign.rs
  • crates/perry-hir/src/lower/expr_new/non_ident.rs
  • crates/perry-hir/src/lower/lower_expr/arm_class.rs
  • crates/perry-hir/src/lower/lowering_context.rs
  • crates/perry-hir/src/lower/stmt.rs
  • crates/perry-hir/src/lower_decl/class_decl.rs

Comment on lines +1431 to +1437
let old_inner_name = ctx.current_class_inner_name.take();
// A class-expression caller stashes the source ident here; fall back
// to the (possibly synthetic) registration name when absent.
ctx.current_class_inner_name = ctx
.pending_class_inner_name
.take()
.or_else(|| Some(name.to_string()));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== file list ==\n'
git ls-files | rg 'crates/perry-hir/src/lower/(decl/class_decl\.rs|stmt\.rs|arm_class\.rs|non_ident\.rs)$'

printf '\n== ast outlines ==\n'
ast-grep outline crates/perry-hir/src/lower/decl/class_decl.rs --view expanded | sed -n '1,220p'
printf '\n--- stmt.rs ---\n'
ast-grep outline crates/perry-hir/src/lower/stmt.rs --view expanded | sed -n '1,260p'
printf '\n--- arm_class.rs ---\n'
ast-grep outline crates/perry-hir/src/lower/arm_class.rs --view expanded | sed -n '1,220p'
printf '\n--- non_ident.rs ---\n'
ast-grep outline crates/perry-hir/src/lower/non_ident.rs --view expanded | sed -n '1,220p'

printf '\n== relevant text search ==\n'
rg -n "current_class_inner_name|pending_class_inner_name|class \\{|class-expression|bind_name|anon_class|__anon_class" crates/perry-hir/src/lower -S

Repository: PerryTS/perry

Length of output: 10378


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== lowering_context.rs relevant range ==\n'
sed -n '220,245p;570,690p' crates/perry-hir/src/lower/lowering_context.rs

printf '\n== lower_expr/arm_class.rs relevant range ==\n'
sed -n '1,130p;240,285p' crates/perry-hir/src/lower/lower_expr/arm_class.rs

printf '\n== expr_new/non_ident.rs relevant range ==\n'
sed -n '110,175p' crates/perry-hir/src/lower/expr_new/non_ident.rs

printf '\n== stmt.rs relevant range ==\n'
sed -n '630,805p' crates/perry-hir/src/lower/stmt.rs

printf '\n== expr_assign.rs relevant range ==\n'
sed -n '420,470p' crates/perry-hir/src/lower/expr_assign.rs

Repository: PerryTS/perry

Length of output: 37316


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
files = [
    "crates/perry-hir/src/lower/lowering_context.rs",
    "crates/perry-hir/src/lower/lower_expr/arm_class.rs",
    "crates/perry-hir/src/lower/expr_new/non_ident.rs",
    "crates/perry-hir/src/lower/stmt.rs",
    "crates/perry-hir/src/lower/expr_assign.rs",
]
for f in files:
    p = Path(f)
    print(f"\n## {f} lines={sum(1 for _ in p.open())}")
PY

Repository: PerryTS/perry

Length of output: 447


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "current_class_inner_name\s*=|pending_class_inner_name\s*=|current_class_inner_name\.take\(|pending_class_inner_name\.take\(" crates/perry-hir/src/lower -S

printf '\n== expr_assign inner-name branch ==\n'
sed -n '430,455p' crates/perry-hir/src/lower/expr_assign.rs

printf '\n== lower_expr/arm_class.rs start ==\n'
sed -n '1,110p' crates/perry-hir/src/lower/lower_expr/arm_class.rs

printf '\n== expr_new/non_ident.rs start ==\n'
sed -n '120,155p' crates/perry-hir/src/lower/expr_new/non_ident.rs

Repository: PerryTS/perry

Length of output: 9461


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "or_else\(\|\| Some\(name\.to_string\(\)\)\)|pending_class_inner_name.*name\.to_string\(\)|current_class_inner_name = ctx" crates/perry-hir/src/lower -S

Repository: PerryTS/perry

Length of output: 151


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "pending_class_inner_name\.take\(\)\.or_else|current_class_inner_name = .*pending_class_inner_name|old_inner_name = ctx\.current_class_inner_name\.take\(\)" crates/perry-hir/src -S

Repository: PerryTS/perry

Length of output: 376


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== all current_class_inner_name references ==\n'
rg -n "current_class_inner_name" .

printf '\n== all pending_class_inner_name references ==\n'
rg -n "pending_class_inner_name" .

printf '\n== all or_else fallback references ==\n'
rg -n "pending_class_inner_name\.take\(\)\.or_else|or_else\(\|\| Some\(name\.to_string\(\)\)\)" .

Repository: PerryTS/perry

Length of output: 2066


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== class_decl.rs around 1418-1450 ==\n'
sed -n '1418,1450p' crates/perry-hir/src/lower_decl/class_decl.rs

printf '\n== class_decl.rs function signature / entry context ==\n'
rg -n "fn lower_class_from_ast|current_class_inner_name = ctx|pending_class_inner_name" crates/perry-hir/src/lower_decl/class_decl.rs -n -S
sed -n '1,120p' crates/perry-hir/src/lower_decl/class_decl.rs

Repository: PerryTS/perry

Length of output: 7037


Anonymous class expressions should not fall back to name Leave current_class_inner_name as None unless pending_class_inner_name is set; otherwise an anonymous class {} inherits the outer registration name as an inner binding and assignments in the body hit the const-binding TypeError path incorrectly.

🤖 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_decl/class_decl.rs` around lines 1431 - 1437, The
class lowering in `class_decl.rs` is incorrectly falling back to `name` when
`pending_class_inner_name` is absent, which makes anonymous `class {}`
expressions inherit an inner binding they should not have. Update the
`current_class_inner_name` setup in the class declaration lowering path so it is
only set from `pending_class_inner_name` and remains `None` otherwise,
preserving anonymous class behavior in `lower_decl`/`current_class_inner_name`
handling.

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