From c487ba7c5b29fbf51c0d74bdda4e0bb032db4432 Mon Sep 17 00:00:00 2001 From: Ralph Date: Wed, 17 Jun 2026 14:21:25 -0700 Subject: [PATCH] fix(hir): skip AnnexB B.3.3 legacy var on for-head/destructuring-catch early errors (#5346) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: Claude Opus 4.8 (1M context) --- crates/perry-hir/src/lower_decl/block.rs | 85 ++++++++++++++++++++++-- 1 file changed, 81 insertions(+), 4 deletions(-) diff --git a/crates/perry-hir/src/lower_decl/block.rs b/crates/perry-hir/src/lower_decl/block.rs index ae036daffc..8111a2cc31 100644 --- a/crates/perry-hir/src/lower_decl/block.rs +++ b/crates/perry-hir/src/lower_decl/block.rs @@ -632,9 +632,26 @@ fn annexb_nested_stmt( ast::Stmt::DoWhile(do_while) => { annexb_nested_stmt(&do_while.body, forbidden, all_out, var_out) } - ast::Stmt::For(for_stmt) => annexb_nested_stmt(&for_stmt.body, forbidden, all_out, var_out), - ast::Stmt::ForIn(for_in) => annexb_nested_stmt(&for_in.body, forbidden, all_out, var_out), - ast::Stmt::ForOf(for_of) => annexb_nested_stmt(&for_of.body, forbidden, all_out, var_out), + // A `for`/`for-in`/`for-of` lexical head (`for (let f; ...)`, + // `for (let f in/of ...)`) introduces a binding whose scope encloses + // the loop body; an equivalent `var f` in the body is an early error + // (14.7.4.1 / 14.7.5.1), so the AnnexB legacy `var` for a same-named + // block function in the body must be skipped. + ast::Stmt::For(for_stmt) => { + let names = match &for_stmt.init { + Some(ast::VarDeclOrExpr::VarDecl(vd)) => var_decl_lexical_names(vd), + _ => Vec::new(), + }; + annexb_nested_loop_body(&for_stmt.body, names, forbidden, all_out, var_out); + } + ast::Stmt::ForIn(for_in) => { + let names = for_head_lexical_names(&for_in.left); + annexb_nested_loop_body(&for_in.body, names, forbidden, all_out, var_out); + } + ast::Stmt::ForOf(for_of) => { + let names = for_head_lexical_names(&for_of.left); + annexb_nested_loop_body(&for_of.body, names, forbidden, all_out, var_out); + } ast::Stmt::Labeled(labeled) => { annexb_nested_stmt(&labeled.body, forbidden, all_out, var_out) } @@ -654,7 +671,25 @@ fn annexb_nested_stmt( ast::Stmt::Try(try_stmt) => { annexb_nested_block(&try_stmt.block.stmts, forbidden, all_out, var_out); if let Some(handler) = &try_stmt.handler { - annexb_nested_block(&handler.body.stmts, forbidden, all_out, var_out); + // B.3.5: a `var` whose name is also a bound name of a + // *destructuring* CatchParameter is an early error, so the + // equivalent AnnexB legacy `var` for a same-named block + // function in the handler body must be skipped. The B.3.5 + // exception only exempts a simple `catch (e)` BindingIdentifier + // (where the var IS allowed), so only pattern catch params + // (`catch ({ f })` / `catch ([f])`) contribute to `forbidden`. + let mut handler_forbidden; + let inner = match &handler.param { + Some(param) if !matches!(param, ast::Pat::Ident(_)) => { + handler_forbidden = forbidden.clone(); + let mut names = Vec::new(); + collect_var_binding_names_from_pat(param, &mut names); + handler_forbidden.extend(names); + &handler_forbidden + } + _ => forbidden, + }; + annexb_nested_block(&handler.body.stmts, inner, all_out, var_out); } if let Some(finalizer) = &try_stmt.finalizer { annexb_nested_block(&finalizer.stmts, forbidden, all_out, var_out); @@ -667,6 +702,48 @@ fn annexb_nested_stmt( } } +/// Lexical (`let`/`const`) binding names introduced by a `VarDecl`. A `var` +/// declaration introduces no lexical names and yields an empty list. +fn var_decl_lexical_names(vd: &ast::VarDecl) -> Vec { + if vd.kind == ast::VarDeclKind::Var { + return Vec::new(); + } + let mut names = Vec::new(); + for decl in &vd.decls { + collect_var_binding_names_from_pat(&decl.name, &mut names); + } + names +} + +/// Lexical binding names of a `for-in` / `for-of` head (`for (let f in …)`). +/// A `var` head or a bare assignment-target pattern introduces no lexical +/// binding here and yields an empty list. +fn for_head_lexical_names(head: &ast::ForHead) -> Vec { + match head { + ast::ForHead::VarDecl(vd) => var_decl_lexical_names(vd), + _ => Vec::new(), + } +} + +/// Descend into a loop body, adding the loop head's lexical binding names to +/// the forbidden set so a same-named block function in the body skips its +/// AnnexB legacy `var` (the equivalent `var` would be an early error). +fn annexb_nested_loop_body( + body: &ast::Stmt, + lexical_names: Vec, + forbidden: &std::collections::HashSet, + all_out: &mut Vec, + var_out: &mut Vec, +) { + if lexical_names.is_empty() { + annexb_nested_stmt(body, forbidden, all_out, var_out); + } else { + let mut inner = forbidden.clone(); + inner.extend(lexical_names); + annexb_nested_stmt(body, &inner, all_out, var_out); + } +} + fn annexb_nested_block( stmts: &[ast::Stmt], forbidden: &std::collections::HashSet,