Skip to content

feat(hir): Annex B B.3.3 sloppy block-level function hoisting (#5297)#5319

Merged
proggeramlug merged 1 commit into
mainfrom
worktree-fix-5297-annexb-b33
Jun 17, 2026
Merged

feat(hir): Annex B B.3.3 sloppy block-level function hoisting (#5297)#5319
proggeramlug merged 1 commit into
mainfrom
worktree-fix-5297-annexb-b33

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements Annex B B.3.3 legacy block-level function declarations. In sloppy mode, a function f(){} declared inside a block must ALSO create a var-style binding in the enclosing function/program scope: f is visible (as a var that is undefined until the declaration runs) outside the block, while the block keeps its own independent binding. Perry previously only block-scoped it, so referencing f outside the block threw ReferenceError: f is not defined — the dominant annexB/language test262 failure (126× in the issue).

Fixes #5297.

What changed (HIR lowering only)

  • New traversal collect_annexb_block_fn_decl_names (in lower_decl/block.rs) walks a body's nested block/if/loops/switch/try/labeled/with (never entering nested function or class bodies) and yields, in one pass, every block-nested function name plus the subset that gets the legacy var. The var is suppressed when the name collides with a parameter or an enclosing let/const/class (which would make the equivalent var an early error) or is arguments, per spec — this clears the skip-* cases too.
  • Wired into all three body-lowering paths: function declarations / arrows (predefine_var_bindings_in_function_body), function expressions — the IIFE shape the function-code tests use (lower_fn_expr_anon), and program scope (lower_module_fn). Each registers one hoisted, undefined-initialised enclosing slot per eligible name and records name -> slot in the new annexb_block_fn_var_ids context map (saved/restored across nested bodies alongside annexb_block_fn_names_all).
  • lower_nested_fn_decl now gives every block-nested declaration a fresh block-local (so it never clobbers an enclosing same-named parameter/var) and, for the eligible subset, copies that closure into the enclosing var at the declaration point — keeping later block-local mutation independent of the outer binding (block-decl-*-block-scoping).

Strict bodies and ES modules are untouched (pure block scoping, no outer var).

Measurement

test262 annexB/language (node v26 differential, scripts/test262_subset.py --dir annexB/language --all-features):

pass parity
baseline 385 / 801 48.1%
this PR 498 / 801 62.2%

+113 cases. The bulk of the remainder is eval-code (needs eval enablement, separate issue) and a few non-B.3.3 Annex B features (catch-var redeclaration, legacy regex escapes, CallExpression-as-LHS).

Testing

  • perry-hir, perry-codegen, perry-transform, perry-types, and perry (driver/integration) test suites all pass with 0 failures.
  • Manual differential checks vs Node v26 for: block-fn visibility, value-before-block (undefined), independence of block-local vs outer var (f = 123 inside the body), strict-mode suppression, switch-case block fns, parameter shadowing (skip-dft-param), enclosing-let suppression (skip-early-err-block), existing-var-update, multiple redeclarations, recursion, and closure capture of a block fn.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved JavaScript compatibility for Annex B block-nested function declarations in non-strict mode, including correct legacy var-style hoisting behavior and block-scoped visibility.

@coderabbitai

coderabbitai Bot commented Jun 17, 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: a5cafc7e-2b2c-41d8-b381-a0385ee09cb5

📥 Commits

Reviewing files that changed from the base of the PR and between 3cfe931 and b8ad179.

📒 Files selected for processing (7)
  • crates/perry-hir/src/lower/context.rs
  • crates/perry-hir/src/lower/expr_function.rs
  • crates/perry-hir/src/lower/lower_module_fn.rs
  • crates/perry-hir/src/lower/lowering_context.rs
  • crates/perry-hir/src/lower_decl/block.rs
  • crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs
  • crates/perry-hir/src/lower_decl/mod.rs
✅ Files skipped from review due to trivial changes (1)
  • crates/perry-hir/src/lower/context.rs
🚧 Files skipped from review as they are similar to previous changes (5)
  • crates/perry-hir/src/lower_decl/mod.rs
  • crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs
  • crates/perry-hir/src/lower/lowering_context.rs
  • crates/perry-hir/src/lower_decl/block.rs
  • crates/perry-hir/src/lower/lower_module_fn.rs

📝 Walkthrough

Walkthrough

Implements ECMAScript Annex B B.3.3 sloppy-mode block-nested function hoisting. Two new fields are added to LoweringContext to track block-nested function names and their enclosing var-slot mappings. New traversal helpers collect eligible names, sloppy-mode pre-passes in the module and function-expression lowerers preallocate var slots, and lower_nested_fn_decl emits the write-through assignment.

Changes

Annex B B.3.3 Sloppy Block-Function Hoisting

Layer / File(s) Summary
LoweringContext Annex B state fields and initialization
crates/perry-hir/src/lower/lowering_context.rs, crates/perry-hir/src/lower/context.rs
LoweringContext gains annexb_block_fn_var_ids: HashMap<String, LocalId> and annexb_block_fn_names_all: HashSet<String>; both are zero-initialized in the constructor.
Annex B name-collection helpers
crates/perry-hir/src/lower_decl/block.rs, crates/perry-hir/src/lower_decl/mod.rs
collect_lexical_decl_names gathers top-level let/const/class names; collect_annexb_block_fn_decl_names traverses nested block structures to partition block-nested function names into all-nested vs var-eligible sets. Both are re-exported from lower_decl::mod.
Sloppy-mode pre-passes at module and function-expression scope
crates/perry-hir/src/lower/lower_module_fn.rs, crates/perry-hir/src/lower/expr_function.rs, crates/perry-hir/src/lower_decl/block.rs
lower_module_full adds a sloppy pre-pass that preallocates global hoisted var slots and populates ctx.annexb_block_fn_var_ids; lower_fn_expr_anon adds a parallel pre-pass emitting Stmt::Let into nested_var_prologue; predefine_var_bindings_in_function_body is extended for function-declaration bodies.
State save/restore guards and var write-through emission
crates/perry-hir/src/lower/expr_function.rs, crates/perry-hir/src/lower_decl/block.rs, crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs
Annex B context state is saved and restored at all exit paths in lower_fn_body_block_stmt and lower_fn_expr_anon. lower_nested_fn_decl creates a fresh block-local binding and, when an enclosing var slot is present, emits LocalSet(outer_id, LocalGet(local_id)) after the block-local Stmt::Let.

Sequence Diagram(s)

sequenceDiagram
  participant Parser as AST / Parser
  participant PrePass as Annex B Pre-Pass
  participant LoweringContext as LoweringContext
  participant BlockLower as lower_fn_body_block_stmt
  participant NestedFnLower as lower_nested_fn_decl
  participant HIR as HIR Output

  Parser->>PrePass: sloppy-mode statements
  PrePass->>LoweringContext: collect_lexical_decl_names → forbidden set
  PrePass->>LoweringContext: collect_annexb_block_fn_decl_names → eligible names
  PrePass->>LoweringContext: allocate/reuse hoisted var LocalId per name
  PrePass->>HIR: emit undefined Stmt::Let for each hoisted var slot

  BlockLower->>LoweringContext: mem::take annexb_block_fn_var_ids / annexb_block_fn_names_all (save)
  BlockLower->>NestedFnLower: lower block-nested function declaration
  NestedFnLower->>LoweringContext: check annexb_block_fn_names_all → is_block_nested
  NestedFnLower->>LoweringContext: check annexb_block_fn_var_ids → annexb_outer_var
  NestedFnLower->>HIR: emit Stmt::Let(block_local, closure)
  NestedFnLower->>HIR: emit LocalSet(outer_var_id, LocalGet(block_local)) when annexb_outer_var present
  BlockLower->>LoweringContext: restore saved annexb state
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • PerryTS/perry#5112: Modifies the same lower_nested_fn_decl function that this PR extends with Annex B block-local binding and LocalSet write-through emission.
  • PerryTS/perry#5270: Modifies predefine_var_bindings_in_function_body and surrounding scope-lookup code paths that this PR also extends for Annex B sloppy-mode bookkeeping.

Poem

🐇 Hop inside a block, declare a function f,
In sloppy mode it must escape the curly cleft.
We save and restore state so nothing goes astray,
A LocalSet writes through to keep the var in play.
Annex B B.3.3 — the spec's old legacy way! 🌿

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically summarizes the main change: implementing Annex B B.3.3 sloppy block-level function hoisting, directly addressing the dominant test262 failure root cause.
Description check ✅ Passed The description is comprehensive and well-structured, covering summary, detailed changes across all modified files, measurement results, and testing performed. It follows the repository template guidelines and provides specific context about what changed and why.
Linked Issues check ✅ Passed The PR fully addresses the primary objective from issue #5297: implementing Annex B B.3.3 sloppy block-function hoisting by adding the enclosing-scope var binding for block-nested functions. The implementation covers all three body-lowering paths and includes collision-handling logic.
Out of Scope Changes check ✅ Passed All code changes are directly scoped to implementing Annex B B.3.3 sloppy block-function hoisting as specified in issue #5297. New traversals, context fields, and lowering logic all serve this single objective without introducing unrelated modifications.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 worktree-fix-5297-annexb-b33

Comment @coderabbitai help to get the list of available commands and usage tips.

@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: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
crates/perry-hir/src/lower_decl/block.rs (1)

704-719: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add parameter names to the function-body Annex B forbidden set.

Line 727 only forbids body lexical names and arguments. If a parameter also has a same-named var, lines 713-718 mark the parameter id as var_hoisted, so lines 755-758 incorrectly map a block function f(){} to the parameter binding instead of suppressing the legacy var as required.

Suggested fix
     let mut created = Vec::new();
     let scope_start = ctx.scope_local_marks.last().copied().unwrap_or(0);
+    let preexisting_scope_names: std::collections::HashSet<String> = ctx
+        .locals
+        .iter()
+        .skip(scope_start)
+        .map(|(name, _, _)| name.clone())
+        .collect();
     for name in names {
         // O(1) innermost-in-scope lookup instead of an O(n) reverse scan of
         // `locals[scope_start..]` per var name — the per-binding scan made a
         // function body with N `var`s lower in O(n²) (`#5267`).
         let existing_current_scope = ctx
@@
     if !ctx.current_strict {
         // Forbidden: names for which the legacy `var` must be skipped. The
         // body's own top-level `let`/`const`/`class` make `var f` an early
         // error; `arguments` is excluded by spec. Parameters are handled below
@@
-        let mut forbidden = std::collections::HashSet::new();
+        let mut forbidden = preexisting_scope_names;
         collect_lexical_decl_names(&block.stmts, &mut forbidden);
         forbidden.insert("arguments".to_string());

Also applies to: 727-762

🤖 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/block.rs` around lines 704 - 719, The
function-body Annex B forbidden set is not including parameter names, which
causes parameters with same-named var declarations to be incorrectly mapped to
block functions instead of suppressing the legacy var. Add the parameter names
to the forbidden set construction around line 727 (where body lexical names and
arguments are already being added) to ensure that when var_hoisted_ids marks a
parameter id as var_hoisted in lines 713-718, the subsequent block function
mapping logic at lines 755-758 properly suppresses the legacy var instead of
incorrectly mapping to the parameter binding.
crates/perry-hir/src/lower/lower_module_fn.rs (1)

411-477: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve same-named top-level function bindings when creating Annex B slots.

Line 656 always emits Undefined. For function f(){} plus a block-nested function f(){}, this either creates a new local that shadows the registered top-level function as undefined, or overwrites the FuncRef local already emitted for reassigned function candidates. The legacy var binding should start with the hoisted top-level function value when one exists.

Suggested fix direction
         for name in names {
             // Reuse an existing global `var`, else mint a fresh hoisted slot;
             // either way emit an undefined-init entry slot so the block's B.3.3
             // write (which runs before any source-position `var f = …`) has
             // storage to target.
             let id = if let Some(existing) = ctx.lookup_local(&name) {
                 existing
             } else {
                 ctx.define_local(name.clone(), Type::Any)
             };
+            let init = ctx
+                .lookup_func(&name)
+                .map(Expr::FuncRef)
+                .unwrap_or(Expr::Undefined);
             module.init.push(Stmt::Let {
                 id,
                 name: name.clone(),
                 ty: Type::Any,
                 mutable: true,
-                init: Some(Expr::Undefined),
+                init: Some(init),
             });

Also avoid adding a second Undefined initializer for an id that the reassigned-function pre-pass already initialized with FuncRef.

Also applies to: 646-662

🤖 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/lower_module_fn.rs` around lines 411 - 477, When
creating Annex B legacy var bindings for block-nested functions (the code at
line 656 and surrounding area), check if a top-level function with the same name
was already registered via register_func or included in
reassigned_function_candidates. If a top-level function exists, initialize the
Annex B binding with a FuncRef to that top-level function instead of always
initializing with Undefined. Additionally, skip creating a duplicate binding if
the reassigned-function pre-pass in the register_func block already created and
initialized a local with FuncRef for the same function name, to avoid shadowing
or overwriting the existing function reference.
🤖 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/block.rs`:
- Around line 538-565: The collect_lexical_decl_names function only collects
lexical declarations from statement lists but does not handle lexical bindings
introduced in loop headers or catch handler parameters when recursing into those
nested bodies. To fix this, extend the match statement to add cases for
ast::Stmt::For, ast::Stmt::ForIn, ast::Stmt::ForOf, and ast::Stmt::Try
statements. For each loop statement type, extract lexical bindings from the loop
head (such as variable declarations in for loop initialization), and for Try
statements with Catch handlers, extract the catch parameter from handler.param.
Create an inner forbidden set that combines these loop-head and catch-parameter
bindings with the currently collected lexical names, then pass this combined set
when recursively processing the nested body statements to ensure Annex B
forbidden-name collection is complete.

In `@crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs`:
- Around line 41-48: The current implementation tracks Annex B block function
write-through eligibility only by function name in the annexb_block_fn_var_ids
map, which causes all block-scoped function declarations with the same name to
inherit the eligibility of any matching name, rather than tracking eligibility
per individual declaration. Instead of keying annexb_block_fn_var_ids by
func_name only, modify the lookup in the is_block_nested branch to use a
declaration-specific identifier (such as a declaration span or unique key) that
distinguishes between different declarations of functions with the same name.
This ensures that if one declaration of `f` is ineligible due to shadowing (like
an enclosing `let f`), it will not incorrectly write through just because
another declaration of `f` in the same body was eligible. Also apply this same
fix to the related code mentioned at lines 274-282.

In `@crates/perry-hir/src/lower/expr_function.rs`:
- Around line 905-958: The Annex B prologue statements stored in
nested_var_prologue must be preserved when lowering function-expression bodies
that contain using statements. When the has_using branch returns the result of
lower_stmts_using_aware directly, the nested_var_prologue statements are being
dropped. Fix this by prepending the nested_var_prologue statements to the result
of lower_stmts_using_aware so that the undefined-init Stmt::Let entries are
present before the using-aware statements are executed, ensuring block-nested
function declarations have their hoisted slots initialized.

---

Outside diff comments:
In `@crates/perry-hir/src/lower_decl/block.rs`:
- Around line 704-719: The function-body Annex B forbidden set is not including
parameter names, which causes parameters with same-named var declarations to be
incorrectly mapped to block functions instead of suppressing the legacy var. Add
the parameter names to the forbidden set construction around line 727 (where
body lexical names and arguments are already being added) to ensure that when
var_hoisted_ids marks a parameter id as var_hoisted in lines 713-718, the
subsequent block function mapping logic at lines 755-758 properly suppresses the
legacy var instead of incorrectly mapping to the parameter binding.

In `@crates/perry-hir/src/lower/lower_module_fn.rs`:
- Around line 411-477: When creating Annex B legacy var bindings for
block-nested functions (the code at line 656 and surrounding area), check if a
top-level function with the same name was already registered via register_func
or included in reassigned_function_candidates. If a top-level function exists,
initialize the Annex B binding with a FuncRef to that top-level function instead
of always initializing with Undefined. Additionally, skip creating a duplicate
binding if the reassigned-function pre-pass in the register_func block already
created and initialized a local with FuncRef for the same function name, to
avoid shadowing or overwriting the existing function reference.
🪄 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: 7669600a-b014-4701-b8be-04beb523d174

📥 Commits

Reviewing files that changed from the base of the PR and between 221ad00 and 3cfe931.

📒 Files selected for processing (7)
  • crates/perry-hir/src/lower/context.rs
  • crates/perry-hir/src/lower/expr_function.rs
  • crates/perry-hir/src/lower/lower_module_fn.rs
  • crates/perry-hir/src/lower/lowering_context.rs
  • crates/perry-hir/src/lower_decl/block.rs
  • crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs
  • crates/perry-hir/src/lower_decl/mod.rs

Comment on lines +538 to +565
/// Collect the lexically-declared names (`let` / `const` / `class`) at the top
/// level of a statement list. A `var` or a `function` declaration is NOT
/// lexical and does not belong here. Used to build the Annex B "forbidden" set:
/// a block-level function declaration whose name collides with a lexical
/// binding in an enclosing scope would make the equivalent `var` an early
/// error, so B.3.3 skips creating the enclosing-scope `var`.
pub(crate) fn collect_lexical_decl_names(
stmts: &[ast::Stmt],
out: &mut std::collections::HashSet<String>,
) {
for stmt in stmts {
match stmt {
ast::Stmt::Decl(ast::Decl::Var(var_decl))
if var_decl.kind != ast::VarDeclKind::Var =>
{
for decl in &var_decl.decls {
let mut names = Vec::new();
collect_var_binding_names_from_pat(&decl.name, &mut names);
out.extend(names);
}
}
ast::Stmt::Decl(ast::Decl::Class(class_decl)) => {
out.insert(class_decl.ident.sym.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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Complete the Annex B forbidden-name collection for loop/catch/using lexical scopes.

collect_lexical_decl_names only sees statement-list let/const/class, and the traversal recurses into for bodies and catch bodies without adding loop-head lexical bindings, using declarations, or catch parameters to forbidden. That can create an enclosing var for shapes where replacing the block function with var f would be an early error.

Suggested direction
 pub(crate) fn collect_lexical_decl_names(
     stmts: &[ast::Stmt],
     out: &mut std::collections::HashSet<String>,
 ) {
     for stmt in stmts {
         match stmt {
             ast::Stmt::Decl(ast::Decl::Var(var_decl))
                 if var_decl.kind != ast::VarDeclKind::Var =>
             {
                 for decl in &var_decl.decls {
                     let mut names = Vec::new();
                     collect_var_binding_names_from_pat(&decl.name, &mut names);
                     out.extend(names);
                 }
             }
+            ast::Stmt::Decl(ast::Decl::Using(using_decl)) => {
+                for decl in &using_decl.decls {
+                    let mut names = Vec::new();
+                    collect_var_binding_names_from_pat(&decl.name, &mut names);
+                    out.extend(names);
+                }
+            }
             ast::Stmt::Decl(ast::Decl::Class(class_decl)) => {
                 out.insert(class_decl.ident.sym.to_string());
             }
             _ => {}
         }
     }
 }

Also derive an inner forbidden set from For/ForIn/ForOf lexical heads and handler.param before recursing into those bodies.

Also applies to: 637-663

🤖 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/block.rs` around lines 538 - 565, The
collect_lexical_decl_names function only collects lexical declarations from
statement lists but does not handle lexical bindings introduced in loop headers
or catch handler parameters when recursing into those nested bodies. To fix
this, extend the match statement to add cases for ast::Stmt::For,
ast::Stmt::ForIn, ast::Stmt::ForOf, and ast::Stmt::Try statements. For each loop
statement type, extract lexical bindings from the loop head (such as variable
declarations in for loop initialization), and for Try statements with Catch
handlers, extract the catch parameter from handler.param. Create an inner
forbidden set that combines these loop-head and catch-parameter bindings with
the currently collected lexical names, then pass this combined set when
recursively processing the nested body statements to ensure Annex B
forbidden-name collection is complete.

Comment on lines +41 to +48
let is_block_nested = ctx.inside_block_scope > 0
&& !ctx.current_strict
&& ctx.annexb_block_fn_names_all.contains(&func_name);
let annexb_outer_var = if is_block_nested {
ctx.annexb_block_fn_var_ids.get(&func_name).copied()
} else {
None
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Key Annex B write-through eligibility by declaration, not only by name.

annexb_block_fn_var_ids.get(&func_name) makes every block function f(){} write to the outer var once any f in the same body is eligible. If another f is under an enclosing let f and should be suppressed, it still writes through. Track eligibility by declaration span/key instead of only by name.

Also applies to: 274-282

🤖 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/nested_fn_decl.rs` around lines 41
- 48, The current implementation tracks Annex B block function write-through
eligibility only by function name in the annexb_block_fn_var_ids map, which
causes all block-scoped function declarations with the same name to inherit the
eligibility of any matching name, rather than tracking eligibility per
individual declaration. Instead of keying annexb_block_fn_var_ids by func_name
only, modify the lookup in the is_block_nested branch to use a
declaration-specific identifier (such as a declaration span or unique key) that
distinguishes between different declarations of functions with the same name.
This ensures that if one declaration of `f` is ineligible due to shadowing (like
an enclosing `let f`), it will not incorrectly write through just because
another declaration of `f` in the same body was eligible. Also apply this same
fix to the related code mentioned at lines 274-282.

Comment on lines +905 to +958
// Annex B B.3.3 (#5297): a block-nested `function f(){}` in this sloppy
// function-expression body also gets an enclosing-scope `var f`
// (undefined until the declaration runs). Register one hoisted slot per
// such name and record name -> slot so the block-nested declaration
// writes the closure into it while keeping its block-local binding
// independent. The IIFE wrapper `(function(){ { function f(){} } f(); }())`
// is exactly the test262 `annexB/.../function-code` shape. The legacy
// `var` is skipped when the name collides with a parameter or a lexical
// binding (it would make `var f` an early error) — `forbidden` carries
// the parameter names, the body's top-level lexical names, and
// `arguments`; nested blocks add their own lexical names as we descend.
if !ctx.current_strict {
let mut forbidden: std::collections::HashSet<String> =
params.iter().map(|p| p.name.clone()).collect();
crate::lower_decl::collect_lexical_decl_names(&block.stmts, &mut forbidden);
forbidden.insert("arguments".to_string());

let mut all_names = Vec::new();
let mut names = Vec::new();
crate::lower_decl::collect_annexb_block_fn_decl_names(
&block.stmts,
&forbidden,
&mut all_names,
&mut names,
);
ctx.annexb_block_fn_names_all.extend(all_names);
names.sort();
names.dedup();
for name in names {
// Reuse an existing in-scope `var` (parameters are excluded by
// `forbidden`, so any same-name binding here is a hoisted
// `var`); otherwise mint a fresh hoisted slot. Either way emit
// an undefined-init entry slot: a direct top-level `var f = …`
// in a function expression has no entry `Let` (only its source-
// position one), but the block's B.3.3 write runs BEFORE that
// position, so the slot must already exist (#5297
// `existing-var-update`).
let id =
if let Some(pos) = ctx.locals.lookup_index_in_scope(&name, outer_locals_len) {
ctx.locals[pos].1
} else {
ctx.define_local(name.clone(), Type::Any)
};
nested_var_prologue.push(Stmt::Let {
id,
name: name.clone(),
ty: Type::Any,
mutable: true,
init: Some(Expr::Undefined),
});
ctx.var_hoisted_ids.insert(id);
ctx.annexb_block_fn_var_ids.insert(name, id);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not drop the Annex B prologue in using function-expression bodies.

The Annex B pass records hoisted slots in nested_var_prologue, but the has_using branch returns lower_stmts_using_aware(...) directly. A block-nested function in a function expression containing using can then emit LocalSet(outer_id, ...) without the undefined entry Stmt::Let for outer_id.

Suggested fix
         if has_using {
-            crate::lower_decl::lower_stmts_using_aware(ctx, &block.stmts)?
+            let mut lowered = crate::lower_decl::lower_stmts_using_aware(ctx, &block.stmts)?;
+            if !nested_var_prologue.is_empty() {
+                let mut combined = std::mem::take(&mut nested_var_prologue);
+                combined.append(&mut lowered);
+                lowered = combined;
+            }
+            lowered
         } else {

Also applies to: 1019-1042

🤖 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_function.rs` around lines 905 - 958, The
Annex B prologue statements stored in nested_var_prologue must be preserved when
lowering function-expression bodies that contain using statements. When the
has_using branch returns the result of lower_stmts_using_aware directly, the
nested_var_prologue statements are being dropped. Fix this by prepending the
nested_var_prologue statements to the result of lower_stmts_using_aware so that
the undefined-init Stmt::Let entries are present before the using-aware
statements are executed, ensuring block-nested function declarations have their
hoisted slots initialized.

In sloppy mode a `function f(){}` declared inside a block must ALSO create a
`var`-style binding in the enclosing function/program scope (Annex B.3.3 legacy
block-level function declarations): `f` is visible — as a `var` that is
`undefined` until the declaration runs — outside the block, while the block
keeps its own independent binding. Perry previously only block-scoped it, so
referencing `f` outside the block threw `ReferenceError: f is not defined`
(the dominant test262 `annexB/language` failure).

Implementation (HIR lowering):
- New traversal `collect_annexb_block_fn_decl_names` walks a body's nested
  blocks/if/loops/switch/try/labeled/with (never entering nested function or
  class bodies) and yields, in one pass, every block-nested function name plus
  the subset that gets the legacy `var`. The `var` is suppressed when the name
  collides with a parameter or a `let`/`const`/`class` in an enclosing scope
  (which would make the equivalent `var` an early error) or is `arguments`, per
  spec — clearing the `skip-*` cases too.
- Wired into all three body-lowering paths: function declarations / arrows
  (`predefine_var_bindings_in_function_body`), function expressions — the IIFE
  shape used by the `function-code` tests (`lower_fn_expr_anon`), and program
  scope (`lower_module_fn`). Each registers one hoisted, undefined-initialised
  enclosing slot per eligible name and records name -> slot in the new
  `annexb_block_fn_var_ids` context map (saved/restored across nested bodies
  alongside `annexb_block_fn_names_all`).
- `lower_nested_fn_decl` gives every block-nested declaration a fresh block-local
  (so it never clobbers an enclosing same-named parameter/var) and, for the
  eligible subset, copies that closure into the enclosing `var` at the
  declaration point — keeping later block-local mutation independent of the
  outer binding (`block-decl-*-block-scoping`).

Strict bodies and ES modules are untouched (pure block scoping, no outer var).

test262 `annexB/language` (node v26 differential, `--all-features`): 385/801
(48.1%) -> 498/801 (62.2%). The bulk of the remainder is `eval-code`, which
needs eval enablement (separate issue).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@proggeramlug proggeramlug force-pushed the worktree-fix-5297-annexb-b33 branch from 3cfe931 to b8ad179 Compare June 17, 2026 13:43
@proggeramlug proggeramlug merged commit 8e45631 into main Jun 17, 2026
15 checks passed
@proggeramlug proggeramlug deleted the worktree-fix-5297-annexb-b33 branch June 17, 2026 14:56
proggeramlug added a commit that referenced this pull request Jun 18, 2026
…h early errors (#5346) (#5356)

The #5319 AnnexB B.3.3 collection (`collect_annexb_block_fn_decl_names`)
suppresses the legacy enclosing-scope `var` for a block-nested sloppy
function whenever the equivalent `var` would be an early error, but it
seeded the `forbidden` set only from block/switch/param lexical names. It
missed two enclosing-binding sources, so the legacy `var` was wrongly
created — leaking the function name to the outer scope:

- **`for`/`for-in`/`for-of` lexical heads** (`for (let f; …) { function f(){} }`,
  `for (let f in/of …) { … }`): the loop binding scopes the body, and a
  same-named `var` in the body is an early error (14.7.4.1 / 14.7.5.1).
- **destructuring CatchParameter** (`catch ({ f }) { function f(){} }`): B.3.5
  makes a same-named `var` an early error unless the catch param is a simple
  `catch (e)` BindingIdentifier, which stays exempt (no-skip).

Add the for-head lexical names and pattern catch-param bound names to the
`forbidden` set before descending into those bodies. A simple identifier
catch param is left untouched so its no-skip `var` is still created.

test262 annexB/language/eval-code/direct skip-early-err cluster (node v26
differential): 42/96 -> 72/96 passing (+30). The residual failures are the
`switch`-case-prefixed templates (a block function declared directly in a
switch case), a separate pre-existing issue. Verified no regression: the
no-skip cases fail identically on both binaries (cache-disabled), and
`cargo test -p perry-hir` passes.

Co-authored-by: Ralph <ralph@skelpo.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

test262 annexB/language at 50% — Annex B B.3.3 sloppy block-function hoisting

1 participant