Skip to content

fix(hir): parenthesized / cast assignment target must honour const immutability (#6300)#6308

Merged
proggeramlug merged 1 commit into
mainfrom
fix/6300-const-cast-assign
Jul 13, 2026
Merged

fix(hir): parenthesized / cast assignment target must honour const immutability (#6300)#6308
proggeramlug merged 1 commit into
mainfrom
fix/6300-const-cast-assign

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Fixes #6300.

The bug

const c = 1;
(c as any) = 9;
console.log("const after cast-assign:", c);   // perry main: 9 — node: TypeError

Every wrapper spelling of the same target was affected, not just as:
(c) = 9, (c as any) = 9, (c satisfies number) = 9, (c!) = 9, <any>c = 9,
plus the compound and logical forms ((c as any) += 1, (c as any) ||= 9).

Root cause

Two divergent identifier-store arms.

lower_assignment_target (expr_assign.rs) handled a bare Ident target with the
full binding resolution — active with env, const-immutability throw,
class-inner-name throw, class/function binding, strict/sloppy global. But its
Paren / TsAs / TsNonNull / TsTypeAssertion / TsSatisfies arms delegate to
lower_expr_assignment (lower_expr/assignment.rs), and that function's Ident
arm was a stripped-down copy that went straight to Expr::LocalSet with no
immutability check
. So any wrapper on the LHS routed around the const check.

The fix extracts the resolution into one shared lower_ident_assignment and calls it
from both arms, so the check can't be bypassed. lower_expr_assignment has no callers
other than those wrapper arms, so the bare-ident path is byte-identical to before.

x++ had the same hole, from the other direction

lower_update emitted Expr::Update for any local without consulting
is_local_immutable, so even the bare const c = 1; c++ mutated silently — and
(c as any)++ didn't compile at all (Update expression only supports identifiers and member expressions; the unwrap loop only knew Paren / TsNonNull). This teaches the
loop the three TS cast nodes and throws on an immutable target.

The throw is sequenced after the operand's js_to_numeric, not before it. Per ES
13.4.2.1, ToNumeric(GetValue(lhs)) runs ahead of PutValue, and that coercion is
observable:

node 26 this PR
const s = Symbol(); s++ TypeError: Cannot convert a Symbol value to a number same
const b = 1n; b++ TypeError: Assignment to constant variable. same

A BigInt passes ToNumeric cleanly and does reach the const TypeError; a Symbol
throws in the coercion first. Both now match V8.

Before / after vs node --experimental-strip-types

case perry main this PR node 26
(c) = 9 no throw, c → 9 TypeError TypeError
(c as any) = 9 no throw, c → 9 TypeError TypeError
(c satisfies number) = 9 no throw, c → 9 TypeError TypeError
(c!) = 9 no throw, c → 9 TypeError TypeError
(c as any) += 1 no throw, c → 2 TypeError TypeError
(c as any)++ compile error TypeError TypeError
c++ (bare) no throw, c → 2 TypeError TypeError
(c as any) ||= 9, c truthy no throw, c unchanged no throw, c unchanged no throw (short-circuit)
let d = 1; (d as any) = 9 d → 9 d → 9 d → 9
(obj as any).x = 1 obj.x → 1 obj.x → 1 obj.x → 1

Blast radius

  • The shared helper is only reachable from the wrapper arms — lower_expr_assignment
    has no other callers — so bare-identifier assignment lowering is unchanged.
  • Every mark_local_immutable call site (7 of them) is a genuine const binding
    (kind == Const / !mutable), so the new lower_update throw cannot fire on a
    let / var / param.
  • Legal writes are explicitly covered in the fixture and still work: let through a
    cast, (obj as any).x = 1 / .x++ / ["x"] += 10, mutating a const array.

Test

test-files/test_gap_6300_const_cast_assign.ts — all wrapper spellings,
compound / update / logical forms, RHS-side-effect ordering (rhsRuns is incremented
before the TypeError), the ||= short-circuit that must not throw, BigInt ++,
for (const x of …) bindings, function-local consts, and the legal writes above.
Byte-for-byte identical to node --experimental-strip-types.

Regression checks

cargo-test is green in CI. Locally under --profile perry-dev, cargo test -p perry-hir is byte-for-byte unchanged from main: same 223 + 2 + 18 passing, and the
same single logical_property_assignment_short_circuits_the_store_4586 failure, which
reproduces by building main's exact source in the same tree (it asserts on the
member-target ??= shape, untouched here).

A dual sweep of the full 286-test test_gap_* corpus — same source compiled and run
with a main-baseline binary and with this branch, diffing output — reports 2 changed,
both noise:

No real behavioural change outside the new fixture.

Note: lint and conformance-smoke are already red on clean main (addr-class
ratchet on map.rs; #6297 fixes it).

@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@proggeramlug, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 44 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b9690f5b-0a80-4889-985a-ee55ed341519

📥 Commits

Reviewing files that changed from the base of the PR and between 0db7677 and 6117cfb.

📒 Files selected for processing (4)
  • crates/perry-hir/src/lower/expr_assign.rs
  • crates/perry-hir/src/lower/expr_misc.rs
  • crates/perry-hir/src/lower/lower_expr/assignment.rs
  • test-files/test_gap_6300_const_cast_assign.ts
📝 Walkthrough

Walkthrough

Changes

Const assignment lowering

Layer / File(s) Summary
Shared identifier assignment lowering
crates/perry-hir/src/lower/expr_assign.rs, crates/perry-hir/src/lower/lower_expr/assignment.rs
Identifier assignment logic is centralized in lower_ident_assignment, preserving const checks, scope handling, and global assignment behavior.
Wrapped update and immutable-local handling
crates/perry-hir/src/lower/expr_misc.rs
Update operands unwrap additional TypeScript wrappers, and immutable-local updates coerce values before throwing a const-assignment error.
Const assignment regression coverage
test-files/test_gap_6300_const_cast_assign.ts
Tests cover wrapped assignment and update targets, scope and loop bindings, mutable let bindings, and legal property mutations.

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

Possibly related PRs

  • PerryTS/perry#6014: Both modify identifier-assignment lowering and shared strict-mode assignment behavior.
  • PerryTS/perry#6021: Both update identifier-assignment lowering for immutable class-inner or own-name bindings.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address #6300 by routing wrapped assignment targets through the shared immutability check so const writes now throw.
Out of Scope Changes check ✅ Passed The additional update-handling and regression coverage still stay within the same const-immutability fix and do not look unrelated.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Title check ✅ Passed The title matches the main change: fixing const-immutability bypasses for wrapped assignment targets.
Description check ✅ Passed The description is detailed and covers the issue, root cause, fix, and tests, even though it doesn't follow the template headings exactly.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/6300-const-cast-assign

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

🤖 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 `@test-files/test_gap_6300_const_cast_assign.ts`:
- Around line 48-52: The `satisfies` assignment cases in the test still produce
expected TypeScript errors. Add `@ts-expect-error` immediately before both
`satisfies` assignment targets, including the `(c4 satisfies number) = 9` case
and the second `satisfies` site, matching the suppression used for the `!` case.
🪄 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: c79188e3-0c30-49d9-8e11-da9d96ef8dd6

📥 Commits

Reviewing files that changed from the base of the PR and between 0960415 and 0db7677.

📒 Files selected for processing (4)
  • crates/perry-hir/src/lower/expr_assign.rs
  • crates/perry-hir/src/lower/expr_misc.rs
  • crates/perry-hir/src/lower/lower_expr/assignment.rs
  • test-files/test_gap_6300_const_cast_assign.ts

Comment on lines +48 to +52
const c4 = 4;
expectThrow("satisfies", () => {
(c4 satisfies number) = 9;
});
console.log("c4 =", c4);

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 | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check if tsc flags `satisfies` in assignment target position.

# Create a minimal test file
cat > /tmp/test_satisfies_assign.ts << 'EOF'
const c4 = 4;
(c4 satisfies number) = 9;

let d6 = 1;
(d6 satisfies number) = 6;
EOF

# Run tsc in check-only mode (if available in the project)
npx tsc --noEmit --strict /tmp/test_satisfies_assign.ts 2>&1 || echo "TSC_NOT_AVAILABLE"

Repository: PerryTS/perry

Length of output: 416


Add @ts-expect-error on the satisfies assignment targets
(c4 satisfies number) = 9 still errors as an invalid assignment target/const assignment under tsc, so the same suppression used for the ! case is needed here too (and at the second satisfies site).

🤖 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 `@test-files/test_gap_6300_const_cast_assign.ts` around lines 48 - 52, The
`satisfies` assignment cases in the test still produce expected TypeScript
errors. Add `@ts-expect-error` immediately before both `satisfies` assignment
targets, including the `(c4 satisfies number) = 9` case and the second
`satisfies` site, matching the suppression used for the `!` case.

…mutability (#6300)

`const c = 1; (c as any) = 9;` silently mutated the binding and printed 9;
node throws `TypeError: Assignment to constant variable.`. Every wrapper
spelling of the same target was affected — `(c) = 9`, `(c as any) = 9`,
`(c satisfies number) = 9`, `(c!) = 9`, `<any>c = 9`, and the compound and
logical forms (`(c as any) += 1`, `(c as any) ||= 9`).

Root cause: two divergent identifier-store arms. `lower_assignment_target`
(expr_assign.rs) handled a bare `Ident` target with the full binding
resolution — active `with` env, const-immutability throw, class-inner-name
throw, class/function binding, strict/sloppy global — while its
`Paren`/`TsAs`/`TsNonNull`/`TsTypeAssertion`/`TsSatisfies` arms delegated to
`lower_expr_assignment` (lower_expr/assignment.rs), whose own `Ident` arm was
a stripped-down copy that went straight to `Expr::LocalSet` with no
immutability check. Any wrapper on the LHS therefore routed around the const
check. Extract the resolution into one shared `lower_ident_assignment` and
call it from both, so the check can't be bypassed.

`x++` had the same hole, from the other direction: `lower_update` emitted
`Expr::Update` for any local without consulting `is_local_immutable`, so even
the bare `const c = 1; c++` mutated silently — and `(c as any)++` didn't
compile at all ("Update expression only supports identifiers and member
expressions", the unwrap loop only knew `Paren`/`TsNonNull`). Teach the loop
the three TS cast nodes and throw on an immutable target. The throw is
sequenced after the operand's `js_to_numeric`, not before it: per ES
13.4.2.1 `ToNumeric(GetValue(lhs))` runs ahead of `PutValue`, and the
coercion is observable — `const s = Symbol(); s++` reports "Cannot convert a
Symbol value to a number" while `const b = 1n; b++` passes ToNumeric and does
get the const TypeError. Both now match V8.

Reachability of the shared helper is confined to the wrapper arms
(`lower_expr_assignment` has no other callers), and every
`mark_local_immutable` site is a genuine `const` binding, so the bare-ident
and `let` paths are untouched.

test-files/test_const_cast_assign_6300.ts covers all wrapper spellings,
compound/update/logical forms, RHS-side-effect ordering, the `||=`
short-circuit that must NOT throw, for-of const bindings, and the legal
writes that must keep working (`let` through a cast, `(obj as any).x = 1`,
mutating a const array). Byte-for-byte identical to
`node --experimental-strip-types`.
@proggeramlug proggeramlug force-pushed the fix/6300-const-cast-assign branch from fc6cfb9 to 6117cfb Compare July 12, 2026 18:48
@proggeramlug proggeramlug merged commit a809bdf into main Jul 13, 2026
25 checks passed
@proggeramlug proggeramlug deleted the fix/6300-const-cast-assign branch July 13, 2026 04:31
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.

runtime: parenthesized / as-cast assignment target silently mutates a const (no TypeError)

1 participant