Skip to content

fix(runtime,lower): in-operator walks Object.create protos; for-in skips deleted keys#6147

Merged
proggeramlug merged 2 commits into
mainfrom
fix/in-operator-objcreate-forin-deleted
Jul 9, 2026
Merged

fix(runtime,lower): in-operator walks Object.create protos; for-in skips deleted keys#6147
proggeramlug merged 2 commits into
mainfrom
fix/in-operator-objcreate-forin-deleted

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Fixes #6145, #6146.

Summary

Two related prototype-chain correctness fixes.

1. in operator walks the Object.create prototype chain (runtime)

key in Object.create(proto) wrongly returned false for every inherited
property — the custom proto's own keys and Object.prototype members:

const o = Object.create({ mid: 1 });
"mid" in o        // was false → now true
"toString" in o   // was false → now true

Object.create(proto) models [[Prototype]] via a synthetic class_id →
prototype object
(CLASS_PROTOTYPE_OBJECTS), the same mechanism the field-GET
path already walks (resolve_proto_chain_field). But the in operator's walk
(ordinary_has_property) only followed recorded static prototypes
(object_static_prototype), which Object.create does not populate — so it
stopped at the receiver's own keys. Worse, the Object.prototype fallback
(ordinary_object_prototype_property_value) bails when class_id != 0, and an
Object.create result carries the synthetic class_id, so even "toString" in o
was false.

Fix: when the static-prototype walk finds no recorded [[Prototype]], hop
through the synthetic prototype object (class_prototype_object(class_id)) and
continue — reaching the custom proto's keys and, past it, Object.prototype.
class/setPrototypeOf/__proto__ were already correct and are unchanged.

2. for-in skips keys deleted before they are visited (HIR)

Per EnumerateObjectProperties, a property deleted during enumeration but not
yet visited must not be visited. Perry snapshots the key list up front
(ForInKeys), so a mid-iteration delete still visited the stale key:

const o = { a: 1, b: 2, c: 3 };
for (const k in o) { if (k === "a") delete o.b; console.log(k); }
// was: a b c   →  now: a c

Fix: the for-in desugar now spills the receiver to a temp and wraps the loop
body in if (typeof obj === "string" ? true : (key in obj)) { … }, skipping a
key no longer present. A primitive string (the only primitive whose for-in
snapshot is non-empty) can't have keys deleted and the in operator throws on
primitives, so strings bypass the recheck. This is why fix (1) is a
prerequisite — the recheck relies on a correct in for Object.create
receivers. Applied at both desugar sites (lower/stmt_loops.rs,
lower_decl/body_stmt.rs); shared helper guard_for_in_body in for_head.rs.

Validation

Byte-for-byte vs node --experimental-strip-types across: in on
own/custom-proto/Object.prototype/absent/2-level-Object.create/create(null);
in regression on class/setPrototypeOf/__proto__/plain; for-in
delete-before-visit / delete-current / delete-past / delete-inherited,
for-in over string & number primitives, for-in over Object.create (inherited
enumerated), and the same deletion inside a function body.

Out of scope (pre-existing, unchanged by this PR)

While testing I confirmed two separate pre-existing in gaps that this PR
does not touch (both already false on origin/main): <idx> in <small typed array> and static data props via <key> in <ClassRef> (e.g. "s" in K).
These use early-return paths in js_object_has_property, not the generic
prototype walk fixed here; filed separately.

Summary by CodeRabbit

  • New Features
    • Improved for...in iteration to better match JavaScript behavior when the receiver changes during looping.
    • Loop enumeration is now stable and key visits are skipped if properties are deleted before they’re reached.
  • Bug Fixes
    • Fixed cases where deleted properties could still be visited during for...in iteration.
    • Improved inherited property lookup during prototype walks, including more reliable traversal when prototype-like relationships are involved.

…eted keys

Two related prototype-chain correctness fixes.

1. `in` operator (#6145): `key in Object.create(proto)` wrongly returned false
   for inherited properties — the custom proto's own keys and Object.prototype
   members alike. Object.create models [[Prototype]] via a synthetic class_id →
   prototype object (CLASS_PROTOTYPE_OBJECTS), but `ordinary_has_property` only
   walked recorded static prototypes (object_static_prototype), which
   Object.create never populates, and the Object.prototype fallback bails on
   class_id != 0. Fix: when no static prototype is recorded, hop through the
   synthetic prototype object (class_prototype_object) and continue the walk —
   the same chain the field-GET path resolves via resolve_proto_chain_field.

2. for-in (#6146): a key deleted from the receiver before it is visited was
   still visited, because for-in snapshots the key list up front (ForInKeys).
   Fix: spill the receiver to a temp and wrap the loop body in
   `if (typeof obj === "string" ? true : (key in obj)) { ... }`, skipping keys
   no longer present. Primitive strings (the only primitive with a non-empty
   for-in snapshot) bypass the recheck since `in` throws on primitives — which
   is why fix (1) is a prerequisite for Object.create receivers. Applied at both
   desugar sites via shared helper guard_for_in_body.
@coderabbitai

coderabbitai Bot commented Jul 8, 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: 263ce2bf-76b7-4828-8ad4-bd1de578c7a2

📥 Commits

Reviewing files that changed from the base of the PR and between 5d11483 and 2959df1.

📒 Files selected for processing (1)
  • crates/perry-runtime/src/object/field_get_set/has_property.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/perry-runtime/src/object/field_get_set/has_property.rs

📝 Walkthrough

Walkthrough

Adds a guard_for_in_body helper for lowered for-in loops, wires it into two lowering paths that now cache the receiver in a temporary, and updates ordinary_has_property to follow synthetic class_id-derived prototype links.

Changes

For-in Deletion Guard and Prototype Chain Fix

Layer / File(s) Summary
guard_for_in_body helper and re-export
crates/perry-hir/src/lower/for_head.rs, crates/perry-hir/src/lower/mod.rs
Adds guard_for_in_body, which wraps loop body statements in an if checking typeof obj === "string" or keys[idx] in obj, and re-exports it from the lower module.
stmt_loops for-in lowering
crates/perry-hir/src/lower/stmt_loops.rs
lower_stmt_for_in evaluates the receiver once into a temp local, builds ForInKeys from that local, and wraps the loop body with guard_for_in_body.
body_stmt for-in lowering
crates/perry-hir/src/lower_decl/body_stmt.rs
lower_body_stmt's ForIn arm caches the receiver in a temp local for ForInKeys and wraps the final loop body with guard_for_in_body.
Prototype chain lookup fix
crates/perry-runtime/src/object/field_get_set/has_property.rs
ordinary_has_property's prototype walk now follows a synthetic prototype derived from class_id via class_prototype_object instead of immediately terminating when no recorded prototype exists.

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

Possibly related PRs

  • PerryTS/perry#6024: Updates in-operator lowering/runtime behavior that the new for-in deletion guard depends on for key-presence rechecks.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The for-in deletion-skip lowering is unrelated to the provided linked issue #6145 and appears out of scope here. Either include #6146 in the linked-issues context or split the for-in fix into a separate PR scoped to that issue.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes both the runtime prototype-chain fix and the for-in deletion-skip change.
Description check ✅ Passed The description covers the summary, linked fixes, validation, and out-of-scope notes, though it omits some template sections.
Linked Issues check ✅ Passed The runtime change addresses #6145 by continuing the Object.create prototype walk through synthetic prototypes, including nested chains.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ 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/in-operator-objcreate-forin-deleted

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

🧹 Nitpick comments (1)
crates/perry-hir/src/lower_decl/body_stmt.rs (1)

1805-1877: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Consider extracting the shared for-in desugaring to eliminate the duplicate with lower_stmt_for_in.

This arm is a near-verbatim copy of lower/stmt_loops.rs::lower_stmt_for_in (receiver spill → ForInKeys → keys/idx temps → body lowering → binding prepend → guard_for_in_bodyStmt::For); the only real difference is emitting into result vs module.init. This PR had to touch both copies in lockstep, which is exactly the failure mode that lets one path stay buggy after the other is fixed. A shared helper parameterized over the statement sink would keep the two for-in semantics from drifting.

🤖 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/body_stmt.rs` around lines 1805 - 1877, The
for-in lowering logic is duplicated between this branch and lower_stmt_for_in,
so changes can drift and leave one path buggy. Extract the shared desugaring
sequence into a helper that encapsulates the receiver spill, ForInKeys creation,
key/index temps, body lowering, binding prepending, and guard_for_in_body
wrapping, and parameterize it by the statement sink so it can emit into either
result or module.init. Then update this ast::Stmt::ForIn arm and
lower/stmt_loops.rs::lower_stmt_for_in to call the shared helper while keeping
the existing semantics intact.
🤖 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-runtime/src/object/field_get_set/has_property.rs`:
- Around line 687-693: The synthetic prototype fallback in has_property
currently runs for arrays too, which can read class_id from an ArrayHeader and
incorrectly continue through class_prototype_object. Update the None branch in
has_property to skip the synthetic prototype lookup whenever cur_is_array is
true, so array property checks only follow the intended prototype chain and do
not spuriously change in results.

---

Nitpick comments:
In `@crates/perry-hir/src/lower_decl/body_stmt.rs`:
- Around line 1805-1877: The for-in lowering logic is duplicated between this
branch and lower_stmt_for_in, so changes can drift and leave one path buggy.
Extract the shared desugaring sequence into a helper that encapsulates the
receiver spill, ForInKeys creation, key/index temps, body lowering, binding
prepending, and guard_for_in_body wrapping, and parameterize it by the statement
sink so it can emit into either result or module.init. Then update this
ast::Stmt::ForIn arm and lower/stmt_loops.rs::lower_stmt_for_in to call the
shared helper while keeping the existing semantics intact.
🪄 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: 5a7d305f-223d-4276-b87d-81e8027028d0

📥 Commits

Reviewing files that changed from the base of the PR and between 7159c2a and 5d11483.

📒 Files selected for processing (5)
  • crates/perry-hir/src/lower/for_head.rs
  • crates/perry-hir/src/lower/mod.rs
  • crates/perry-hir/src/lower/stmt_loops.rs
  • crates/perry-hir/src/lower_decl/body_stmt.rs
  • crates/perry-runtime/src/object/field_get_set/has_property.rs

Comment thread crates/perry-runtime/src/object/field_get_set/has_property.rs
…rototype nodes

Address CodeRabbit review on #6147: a prototype hop in `ordinary_has_property`
can land on a real `ArrayHeader` (`Foo.prototype = [1,2,3]`), which has no
`class_id` field. Skip the synthetic-prototype lookup when `cur_is_array` so the
array's `length`/`capacity` bytes are never misread as a class id.
@proggeramlug proggeramlug merged commit d51b057 into main Jul 9, 2026
24 of 25 checks passed
@proggeramlug proggeramlug deleted the fix/in-operator-objcreate-forin-deleted branch July 9, 2026 04:32
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.

in operator: inherited properties on Object.create receivers report false

1 participant