Skip to content

fix(hir): keep the pure first declarator in For::init — restores the versioned loop clones (#9106) - #9116

Merged
proggeramlug merged 1 commit into
PerryTS:mainfrom
proggeramlug:fix-9106-for-head-init
Aug 30, 2026
Merged

fix(hir): keep the pure first declarator in For::init — restores the versioned loop clones (#9106)#9116
proggeramlug merged 1 commit into
PerryTS:mainfrom
proggeramlug:fix-9106-for-head-init

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Fixes #9106. The culprit is #9062, not the issue's stated window — the bisect (fast IR-grep oracle: count js_packed_arraylike_loop_guard in scan()'s IR) walked to a CI-only commit, an impossible verdict that exposed the window as wrong; the claimed-good endpoint 8b40634 already fails on this machine. #9070/#9084/#9091 are innocent.

Mechanism: #9062 correctly fixed out-of-order initialization of multi-declarator lexical for heads by hoisting every declarator into a pre-loop prelude — leaving For::init = None. But every versioned counted-loop matcher identifies the counter exclusively through For::init (stable_packed_loop.rs's match_candidate returns None immediately). The canonical wolf-ecs idiom for (let j = 0, length = current.length; j < length; j++) is a multi-declarator head, so the nested scan loops silently fell to generic lowering: 0 guard sites where 3 are expected, correct output, fast clones gone.

Fix (HIR, contained): new predicate for_head_first_decl_keeps_init_slot — when the FIRST declarator is a plain identifier bound to a pure literal and no tail declarator mentions it (swc Visit scan over tail patterns + initializers), the tail-only hoist is provably unobservable, so the first declarator stays in For::init and only the tail hoists. Order-observable heads — including both #9052 fixtures, whose tails read i — stay on #9062's prelude path; hoisted tails keep the classic_for_lexical_bindings capture semantics. Applied at both twin lowering sites; HIR regression test pins the carve-out; changelog fragment included.

Validation (at tip, post-fix): issue_8690_loop_versioned_arraylike 3/3 (guard sites 3 again), issue_8773_closure_capture_packed_loops 4/4, issue_9052_for_lexical_declarators 2/2 (the culprit's own wins intact), perry-codegen lib 1347/0, native_proof_regressions 284/0, perry-hir lib 360/0, fmt clean.

Goal-relevant: this restores the loop family's beat-node standing (the ECS 2.3×/1.5× shape ran at generic speed on main). Implemented by a subagent in an isolated worktree; reviewed and shipped by the coordinating session.

Summary by CodeRabbit

  • Performance Improvements

    • Restored optimized handling for common counted for loops, such as for (let i = 0, len = arr.length; i < len; i++).
    • Preserved existing source-order behavior when changing declaration handling could affect observable execution.
  • Bug Fixes

    • Improved loop variable handling to preserve correct per-iteration capture semantics.
  • Tests

    • Added regression coverage for optimized multi-declaration for loops.

…PerryTS#9106)

PerryTS#9062 fixed source-order semantics for multi-declarator lexical for heads
by hoisting EVERY declarator into the loop-scoped prelude and leaving
`For::init = None`. That silently demoted every versioned counted-loop
fast clone whose head is spelled the classic way —
`for (let i = 0, length = arr.length; i < length; i++)` — because all of
codegen's loop matchers (stable_packed, packed_f64, range, class-field,
element-shape) identify the counter through the
`For::init == Let { id, Integer(0) }` slot. The wolf-ecs nested
`Query`/`Archetype` scans in issue_8690_loop_versioned_arraylike lost all
three `js_packed_arraylike_loop_guard` admissions (3 -> 0); output stayed
correct, only the fast clones vanished.

Carve-out: when hoisting the tail declarators around the first one is
provably unobservable — the first declarator is a plain identifier bound
to a pure literal, and no tail declarator mentions that identifier in its
pattern or initializer — keep the first declarator in `For::init` and
hoist only the tail. Under those conditions the reorder can observe
neither the value nor the TDZ state of the first binding, and no closure
can capture it early. Every order-observable head (including both PerryTS#9052
regression fixtures, whose tails read `i`) stays on the PerryTS#9062 prelude
path, and hoisted tail bindings keep their `classic_for_lexical_bindings`
per-iteration capture semantics.

Validation: issue_8690_loop_versioned_arraylike 3/3,
issue_8773_closure_capture_packed_loops 4/4,
issue_9052_for_lexical_declarators 2/2, perry-codegen --lib 1347,
native_proof_regressions 284, perry-hir --lib 360 (incl. a new pin that
the safe head keeps its init slot), cargo fmt clean.

Claude-Session: https://claude.ai/code/session_01Ay8VyLkKbm8Hkc1xmvTEsP
@coderabbitai

coderabbitai Bot commented Aug 30, 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: a3c889f9-eb93-460c-a4f5-6c429d1a5285

📥 Commits

Reviewing files that changed from the base of the PR and between 55c1368 and 30d1822.

📒 Files selected for processing (6)
  • changelog.d/9106-for-head-counter-keeps-init-slot.md
  • crates/perry-hir/src/lower/for_multi_decl_tests.rs
  • crates/perry-hir/src/lower/stmt.rs
  • crates/perry-hir/src/lower_decl/body_stmt.rs
  • crates/perry-hir/src/lower_decl/helpers.rs
  • crates/perry-hir/src/lower_decl/mod.rs

Included review availability: Your plan provides up to 8 included reviews per hour; 2 remain after this review.


📝 Walkthrough

Walkthrough

The PR restores For::init placement for safe literal-initialized counters in multi-declarator classic for heads. Tail bindings remain hoisted when they do not reference the counter. A regression test and changelog entry cover the behavior.

Changes

For-loop init-slot lowering

Layer / File(s) Summary
Safe first-declarator predicate
crates/perry-hir/src/lower_decl/helpers.rs, crates/perry-hir/src/lower_decl/mod.rs
Adds and re-exports a helper that permits init-slot retention only for plain identifiers with literal initializers that tail declarators do not reference.
For-loop lowering integration
crates/perry-hir/src/lower/stmt.rs, crates/perry-hir/src/lower_decl/body_stmt.rs
Uses the helper to keep the first declarator in For::init and hoist only the remaining declarators when safe.
Regression coverage and changelog
crates/perry-hir/src/lower/for_multi_decl_tests.rs, changelog.d/9106-for-head-counter-keeps-init-slot.md
Tests that i remains in For::init while len remains a hoisted lexical binding, and records the change.

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

Merge Risk: 🟡 Moderate · up to 30d18

The change restores optimized lowering for eligible multi-declarator loops, but a tail initializer using direct eval may observe lexical bindings in the wrong order and change program behavior. Merge should wait for an explicit safeguard or owner acceptance of this bounded correctness risk.

Sequence Diagram(s)

sequenceDiagram
  participant ForHeadLowering
  participant InitSlotHelper
  participant HIRFor
  participant LexicalBindings
  ForHeadLowering->>InitSlotHelper: Check first declarator and tail references
  InitSlotHelper-->>ForHeadLowering: Return safe init-slot decision
  ForHeadLowering->>HIRFor: Keep literal counter in For::init
  ForHeadLowering->>LexicalBindings: Hoist tail declarators
Loading

Suggested reviewers: thehypnoo

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 7 functions across 5 files. (1 skipped: 1… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main HIR fix: keeping the pure first declarator in For::init to restore versioned loop clones. The issue reference is relevant.
Description check ✅ Passed The description is mostly complete. It explains the regression, mechanism, contained fix, affected lowering sites, regression coverage, related issue, and validation results. It does not use the templ…
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.
Full details: Description check

Explanation

The description is mostly complete. It explains the regression, mechanism, contained fix, affected lowering sites, regression coverage, related issue, and validation results. It does not use the template headings or include the checklist, but the missing items are non-critical for understanding and reviewing this change.

Full details: Docstring Coverage

Explanation

Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 7 functions across 5 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ 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.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Merged. Fixes #9106.

The attribution correction is the most valuable part of this PR, and I confirmed it: #9062 is the culprit, and #9070/#9084/#9091 are innocent. Reproduced on main (d1d6d03b91), running from the perry crate:

main this PR
issue_8690_loop_versioned_arraylike FAILED 2 passed / 1 failed ok 3 passed
issue_8773_closure_capture_packed_loops FAILED 3 passed / 1 failed ok 4 passed

with assertion failed: the outer fast/slow copies each own a preheader-versioned inner loop. Program output is identical to node on both arms — this was purely a silent fall to generic lowering, which is exactly the kind of regression that a parity suite cannot see.

Your note that the bisect "walked to a CI-only commit, an impossible verdict that exposed the window as wrong" is worth keeping in the changelog fragment. A bisect landing on a commit that cannot affect the subject is evidence about the window, not about the commit — easy to rationalize away instead of acting on.

The carve-out is correctly conditioned. The risk here is obvious — #9062 removed this reordering precisely because it was observable — so the question is whether the three conditions are jointly sufficient. First declarator a plain identifier, initializer a pure Expr::Lit, and no tail declarator mentioning the binding's name anywhere in pattern or initializer. A literal has no effects and no dependencies, and if nothing names the binding, neither its value nor its TDZ state can be observed before the tail runs. That closes it.

I tested the ways it could leak — 18 shapes, byte-identical to node, and #9062's own for_multi_decl tests still pass (now 3):

shape node
3 both initializers are calls — evaluation order visible ["f0","f1"]
4 tail declarator reads the first (let i = 0, j = i + 5) [[0,5],[1,5]]
5 chained (a = 1, b = a * 2, c = b * 3) [[1,2,6]]
6 comma-expression side effects in both initializers ["i","j"]
7 TDZ reference from the tail ReferenceError
8, 9 per-iteration closure capture, single and multi-declarator [0,1,2], [[0,100],[1,101],[2,102]]
12, 13 first initializer NOT a literal — must take the prelude path 6, 15
14, 15 shadowing an outer i; a tail name that is a prefix of the first [[0,1],"outer"], [[0,9],[1,9]]
17, 18 mutating the array / its length inside a length-cached head [6,4], NaN

Cases 4, 5 and 7 are the ones that would fail if the "no tail mentions it" check were weak, and 12/13 confirm the non-literal path is untouched.

Two process notes from validating this:

Validation: hir 360 passed (for_multi_decl 3/3), codegen 1347, runtime 2819 under both the dev profile (47.5 s, exit 0, no aborts) and perry-dev, perry --bins 1066, both regressed suites green, fmt clean, run_lint_gates.sh all 60 gates passed; 2 CI-only skipped.

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.

regression(main): wolf-ecs-shaped nested subclass loops lost their versioned fast clones (packed-guard sites 3 → 0)

1 participant