Skip to content

fix(hir): boxed primitive wrappers are not arrays — stop the array fold from hijacking their methods (#5902) - #7470

Merged
proggeramlug merged 2 commits into
mainfrom
fix/5902-string-tail-2
Aug 6, 2026
Merged

fix(hir): boxed primitive wrappers are not arrays — stop the array fold from hijacking their methods (#5902)#7470
proggeramlug merged 2 commits into
mainfrom
fix/5902-string-tail-2

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Fixes a coherent single-root subcluster of #5902 (test262 built-ins/String worklist): a boxed primitive wrapper receiver is not an array, but the array fast path claimed its methods anyway.

Root cause

try_local_array_methods decides whether to fold recv.<m>(…) into the dense Expr::Array* builtins. The gate that admits the fold reads "definitely not a string" as positive evidence of array-ness (is_known_not_string). So a local typed Named("Object") / Named("Number") / Named("Boolean") — i.e. new Object(true), new Number(1), new Boolean — walked straight in, and

var __instance = new Object(true);
__instance.indexOf = String.prototype.indexOf;
__instance.indexOf(true, false);   // node: 0     perry: -1

lowered to Expr::ArrayIndexOf, which reads the wrapper's ObjectHeader as an ArrayHeader and answers -1. The borrowed String.prototype.indexOf never ran. S15.5.4.7_A4_T4 is the same bug one level up the chain — it puts the borrowed method on Number.prototype and calls it on a new Number(…) receiver.

Fix

Named("String") was already excluded one arm earlier (is_boxed_string_wrapper, added so a boxed String routes to the ToString-coercing string dispatch). This extends the same reasoning to the remaining boxed primitive wrappers and to Object, routing them to the generic runtime dispatch instead — which resolves the own/inherited property first and still reaches the array engine for a genuine array.

Declining a fold is always semantically safe here: the generic dispatch is the fallback the rest of this file already uses for exactly this reason, so a mistyped-but-really-an-array receiver keeps working (it just doesn't take the dense path).

Real arrays are structurally unaffectedExpr::New { class_name: "Array" } already infers Type::Array, not Type::Named("Array"), so new Array() never reaches the new predicate.

Verification

test262 built-ins/String (pinned 4249661, node 26.5.1), full slice:

before (#7451) after
pass 916 922
runtime-fail 16 10
parity 96.9%→98.3% 98.9%

Six flips, zero regressions — exactly the targeted cases:

  • prototype/indexOf/S15.5.4.7_A1_T1.js, _A1_T2.js, _A4_T4.js
  • prototype/lastIndexOf/S15.5.4.8_A1_T1.js, _A1_T2.js, _A4_T4.js

A mixed probe (typed array, inferred array, string primitive, boxed String, class instance with an own indexOf) matches node byte-for-byte, and --print-hir confirms the Array* folds are still emitted for the real arrays (2 ArrayIndexOf, 2 ArrayLastIndexOf, 1 ArrayIncludes, 2 ArraySlice, 1 ArrayJoin) — the optimization is preserved, not disabled.

New coverage is per-PR visible (cargo test -p perry-hir --lib, not crates/*/tests/): three unit tests on the extracted receiver_is_non_array_builtin_wrapper predicate, including one asserting Named("String") is deliberately not claimed here (claiming it would send a boxed String to generic dispatch instead of the coercing string path).

rustfmt clean; scripts/check_file_size.sh clean (1176 lines).

Deliberately out of scope

The 7th case in this cluster, prototype/concat/S15.5.4.6_A4_T1.js, has a different root in a different crate: classify_own_slot (perry-runtime/src/array/generic.rs) labels any borrowed builtin closure a borrowed Array builtin, so obj.concat = String.prototype.concat runs the array engine on the receiver. Fixing it needs identity comparison against the real Array.prototype.concat and belongs in its own PR.

No version bump / CLAUDE.md edit per the worklist's contributor instructions — maintainer folds metadata at merge.

Refs #5902.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed incorrect handling of boxed Object, Number, Boolean, Symbol, and BigInt values when calling array-like methods.
    • These values now use the correct generic behavior instead of array-specific optimizations.
    • Preserved existing handling for boxed strings and genuine arrays.
  • Tests
    • Added coverage for wrapper objects, real arrays, unknown types, and similarly named custom types.

…ld from hijacking their methods (#5902)

A local typed `Named("Object")` / `Named("Number")` / `Named("Boolean")` —
i.e. `new Object(true)`, `new Number(1)`, `new Boolean` — walked straight
into the array fast path in `try_local_array_methods`, because the gate
that guards it (`is_known_not_string`) reads "definitely not a string" as
positive evidence of array-ness. `inst.indexOf(x)` then lowered to
`Expr::ArrayIndexOf`, which reads the wrapper's ObjectHeader as an
ArrayHeader and answers -1 — even when the receiver owns or inherits a
real `indexOf`, so a borrowed `String.prototype.indexOf` never ran.

`Named("String")` was already excluded one arm earlier
(`is_boxed_string_wrapper`, which routes to the ToString-coercing string
dispatch); this extends the same reasoning to the remaining boxed
primitive wrappers and to `Object`, routing them to the generic runtime
dispatch instead, which resolves the own/inherited property first and
still reaches the array engine for a genuine array.

`new Array()` is unaffected: `Expr::New { class_name: "Array" }` already
infers `Type::Array`, not `Type::Named("Array")`, so real arrays keep the
fast path (verified: the `Array*` folds are still emitted, and a
mixed array/string/boxed-string/class probe matches node byte-for-byte).

Adds `receiver_is_non_array_builtin_wrapper` as a standalone predicate
with three per-PR-visible unit tests (`cargo test -p perry-hir --lib`),
including one asserting `Named("String")` is deliberately NOT claimed
here.
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The array-method lowering now excludes boxed Object, Number, Boolean, Symbol, and BigInt receivers from array fast paths. Boxed strings and genuine arrays retain their existing handling. Tests and a changelog entry document the behavior.

Changes

Array receiver lowering

Layer / File(s) Summary
Builtin wrapper classification
crates/perry-hir/src/lower/expr_call/local_array_methods.rs
Adds a predicate for identifying named non-array builtin wrappers while excluding strings and unrelated types.
Fast-path gating and tests
crates/perry-hir/src/lower/expr_call/local_array_methods.rs, changelog.d/7470-boxed-wrapper-array-fold.md
Updates array fast-path eligibility and adds coverage for wrappers, strings, arrays, unknown receivers, and similarly named user types.

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

Possibly related PRs

  • PerryTS/perry#7188: Prevents incorrect array-method folding in a separate local-vs-imported lowering path.

Suggested labels: bug

Suggested reviewers: thehypnoo

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely identifies the main fix: preventing boxed primitive wrappers from using the array fast path.
Description check ✅ Passed The description clearly covers the root cause, fix, tests, issue reference, preserved behavior, and out-of-scope work.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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/5902-string-tail-2

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 `@crates/perry-hir/src/lower/expr_call/local_array_methods.rs`:
- Around line 65-73: Update receiver_is_non_array_builtin_wrapper to inspect
Type::Union variants recursively or directly, returning true when any variant is
the named Object, Number, Boolean, Symbol, or BigInt wrapper, so the array
fast-path gate declines folding possible wrapper receivers. Add a unit test
covering a union containing one of these wrapper types and verify no
Expr::Array* operation is emitted.
🪄 Autofix

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: 8c402ac2-469a-4d96-b9ee-8b09d3908f8b

📥 Commits

Reviewing files that changed from the base of the PR and between 351742d and 8348262.

📒 Files selected for processing (2)
  • changelog.d/7470-boxed-wrapper-array-fold.md
  • crates/perry-hir/src/lower/expr_call/local_array_methods.rs

Comment on lines +65 to +73
fn receiver_is_non_array_builtin_wrapper(recv_ty: Option<&Type>) -> bool {
matches!(
recv_ty,
Some(Type::Named(n))
if matches!(
n.as_str(),
"Object" | "Number" | "Boolean" | "Symbol" | "BigInt"
)
)

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

Handle union-typed wrapper receivers before enabling the array fast path.

receiver_is_non_array_builtin_wrapper matches only a top-level Type::Named. If lookup_local_type returns a Type::Union containing Type::Named("Object"), Type::Named("Number"), Type::Named("Boolean"), Type::Named("Symbol"), or Type::Named("BigInt"), is_union_with_string remains false and is_known_not_string becomes true. The gate can then emit an Expr::Array* operation for a possible wrapper receiver and recreate the ObjectHeader/ArrayHeader mismatch.

Make the predicate inspect union variants and decline the fold when any non-array wrapper is possible. Add a union case to the unit tests.

Proposed fix
 fn receiver_is_non_array_builtin_wrapper(recv_ty: Option<&Type>) -> bool {
-    matches!(
-        recv_ty,
-        Some(Type::Named(n))
-            if matches!(
-                n.as_str(),
-                "Object" | "Number" | "Boolean" | "Symbol" | "BigInt"
-            )
-    )
+    match recv_ty {
+        Some(Type::Named(n)) => matches!(
+            n.as_str(),
+            "Object" | "Number" | "Boolean" | "Symbol" | "BigInt"
+        ),
+        Some(Type::Union(variants)) => variants.iter().any(|ty| {
+            receiver_is_non_array_builtin_wrapper(Some(ty))
+        }),
+        _ => false,
+    }
 }
 
     fn real_array_receivers_keep_the_fold() {
+        assert!(receiver_is_non_array_builtin_wrapper(Some(&Type::Union(vec![
+            Type::Named("Object".to_string()),
+            Type::Named("Number".to_string()),
+        ]))));

Also applies to: 141-141, 280-281, 1131-1174

🤖 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/expr_call/local_array_methods.rs` around lines 65
- 73, Update receiver_is_non_array_builtin_wrapper to inspect Type::Union
variants recursively or directly, returning true when any variant is the named
Object, Number, Boolean, Symbol, or BigInt wrapper, so the array fast-path gate
declines folding possible wrapper receivers. Add a unit test covering a union
containing one of these wrapper types and verify no Expr::Array* operation is
emitted.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Reviewed and merged. Independently verified both directions on a rebased build: real arrays still emit the Array* folds (--print-hir on a number[] probe), and the wrapper case now matches node byte-for-byte (new Number(5) with borrowed String.prototype.indexOf returns 0, was -1). perry-hir 275/0. The decline-is-safe argument is the right shape — a mistyped receiver falls to generic dispatch and stays correct — and the Named("String") exclusion test pins the boundary with the sibling arm.

@proggeramlug
proggeramlug merged commit df0637e into main Aug 6, 2026
7 of 11 checks passed
@proggeramlug
proggeramlug deleted the fix/5902-string-tail-2 branch August 6, 2026 04:35
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