feat(hir): Annex B B.3.3 sloppy block-level function hoisting (#5297)#5319
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (7)
✅ Files skipped from review due to trivial changes (1)
🚧 Files skipped from review as they are similar to previous changes (5)
📝 WalkthroughWalkthroughImplements ECMAScript Annex B B.3.3 sloppy-mode block-nested function hoisting. Two new fields are added to ChangesAnnex B B.3.3 Sloppy Block-Function Hoisting
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
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 winAdd 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-namedvar, lines 713-718 mark the parameter id asvar_hoisted, so lines 755-758 incorrectly map a blockfunction 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 winPreserve same-named top-level function bindings when creating Annex B slots.
Line 656 always emits
Undefined. Forfunction f(){}plus a block-nestedfunction f(){}, this either creates a new local that shadows the registered top-level function asundefined, or overwrites theFuncReflocal 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
Undefinedinitializer for an id that the reassigned-function pre-pass already initialized withFuncRef.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
📒 Files selected for processing (7)
crates/perry-hir/src/lower/context.rscrates/perry-hir/src/lower/expr_function.rscrates/perry-hir/src/lower/lower_module_fn.rscrates/perry-hir/src/lower/lowering_context.rscrates/perry-hir/src/lower_decl/block.rscrates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rscrates/perry-hir/src/lower_decl/mod.rs
| /// 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()); | ||
| } | ||
| _ => {} | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
| 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 | ||
| }; |
There was a problem hiding this comment.
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.
| // 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); | ||
| } | ||
| } |
There was a problem hiding this comment.
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>
3cfe931 to
b8ad179
Compare
…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>
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 avar-style binding in the enclosing function/program scope:fis visible (as avarthat isundefineduntil the declaration runs) outside the block, while the block keeps its own independent binding. Perry previously only block-scoped it, so referencingfoutside the block threwReferenceError: f is not defined— the dominantannexB/languagetest262 failure (126× in the issue).Fixes #5297.
What changed (HIR lowering only)
collect_annexb_block_fn_decl_names(inlower_decl/block.rs) walks a body's nestedblock/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 legacyvar. Thevaris suppressed when the name collides with a parameter or an enclosinglet/const/class(which would make the equivalentvaran early error) or isarguments, per spec — this clears theskip-*cases too.predefine_var_bindings_in_function_body), function expressions — the IIFE shape thefunction-codetests use (lower_fn_expr_anon), and program scope (lower_module_fn). Each registers one hoisted, undefined-initialised enclosing slot per eligible name and recordsname -> slotin the newannexb_block_fn_var_idscontext map (saved/restored across nested bodies alongsideannexb_block_fn_names_all).lower_nested_fn_declnow 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 enclosingvarat 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):+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, andperry(driver/integration) test suites all pass with 0 failures.undefined), independence of block-local vs outervar(f = 123inside the body), strict-mode suppression,switch-case block fns, parameter shadowing (skip-dft-param), enclosing-letsuppression (skip-early-err-block),existing-var-update, multiple redeclarations, recursion, and closure capture of a block fn.🤖 Generated with Claude Code
Summary by CodeRabbit
functiondeclarations in non-strict mode, including correct legacyvar-style hoisting behavior and block-scoped visibility.