From 2a44b61cfca86d4f8a3e1aa4d4e1e0213adb7c85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 14 Sep 2026 00:02:08 +0200 Subject: [PATCH 1/2] fix: initialize sloppy block functions at block entry --- .github/workflows/test.yml | 9 +- .../10079-script-block-function-hoisting.md | 6 ++ crates/perry-hir/src/lower/context.rs | 1 + .../perry-hir/src/lower/lowering_context.rs | 4 + crates/perry-hir/src/lower_decl/block.rs | 53 ++++----- .../src/lower_decl/block/hoisting_tests.rs | 102 ++++++++++++++++++ .../lower_decl/body_stmt/nested_fn_decl.rs | 8 +- ...ue_10079_script_block_function_hoisting.rs | 83 ++++++++++++++ scripts/test-require-runtime.test.mjs | 11 ++ .../test_gap_10079_block_function_hoisting.ts | 59 ++++++++++ 10 files changed, 309 insertions(+), 27 deletions(-) create mode 100644 changelog.d/10079-script-block-function-hoisting.md create mode 100644 crates/perry-hir/src/lower_decl/block/hoisting_tests.rs create mode 100644 crates/perry/tests/issue_10079_script_block_function_hoisting.rs create mode 100644 test-files/test_gap_10079_block_function_hoisting.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7c91cfff57..986f8dfe68 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1524,8 +1524,13 @@ jobs: --features perry-stdlib/external-net-pump export PERRY_TEST_RUNTIME_PREBUILT=1 else - cargo build --release -p perry-runtime -p perry-stdlib \ - -p perry-runtime-static -p perry-stdlib-static + runtime_packages=(-p perry-runtime -p perry-stdlib -p perry-runtime-static -p perry-stdlib-static) + if printf '%s\n' "$SUITES" | grep -qE '^perry issue_10079_script_block_function_hoisting '; then + # The pinned script/ESM matrix uses --platform bun, whose + # startup installs the net provider even without imports. + runtime_packages+=(-p perry-ext-net) + fi + cargo build --release "${runtime_packages[@]}" fi export PERRY_RUNTIME_DIR="$PWD/target/release" fi diff --git a/changelog.d/10079-script-block-function-hoisting.md b/changelog.d/10079-script-block-function-hoisting.md new file mode 100644 index 0000000000..bb47162ceb --- /dev/null +++ b/changelog.d/10079-script-block-function-hoisting.md @@ -0,0 +1,6 @@ +Initialize block-scoped function declarations at block entry in sloppy scripts +as well as strict code. Forward reads and retained callbacks now use the +correct lexical binding on each loop entry. Annex B's separate outer variable +is still updated at the textual declaration, and block functions correctly +shadow enclosing parameters. Regression coverage pins script/ESM package +contexts and compares native O0, Os, and Oz output with Node. diff --git a/crates/perry-hir/src/lower/context.rs b/crates/perry-hir/src/lower/context.rs index 32bf916239..3d4e00a380 100644 --- a/crates/perry-hir/src/lower/context.rs +++ b/crates/perry-hir/src/lower/context.rs @@ -166,6 +166,7 @@ impl LoweringContext { catch_param_scopes: Vec::new(), annexb_block_fn_var_ids: HashMap::new(), annexb_block_fn_names_all: HashSet::new(), + block_fn_decl_bindings: HashMap::new(), lexical_forward_decls: HashMap::new(), nested_forward_scope_ids: HashSet::new(), functions_index: HashMap::new(), diff --git a/crates/perry-hir/src/lower/lowering_context.rs b/crates/perry-hir/src/lower/lowering_context.rs index 6e0584d0ef..2d1c6992a7 100644 --- a/crates/perry-hir/src/lower/lowering_context.rs +++ b/crates/perry-hir/src/lower/lowering_context.rs @@ -569,6 +569,10 @@ pub struct LoweringContext { /// reusing an enclosing same-named binding (e.g. a parameter). Saved/restored /// across nested function bodies alongside `annexb_block_fn_var_ids`. pub(crate) annexb_block_fn_names_all: HashSet, + /// Block-entry function bindings, keyed by declaration span and name. + /// Scoped by the block hoisting pass; declaration lowering reuses these + /// identities so earlier reads and Annex B's later outer copy agree. + pub(crate) block_fn_decl_bindings: HashMap<(u32, String), LocalId>, /// #4973: top-of-function-body `let`/`const` Ident bindings pre-registered /// by the function-body hoist pass so hoisted sibling FUNCTIONS that /// reference them before their lexical position bind the (boxed) local diff --git a/crates/perry-hir/src/lower_decl/block.rs b/crates/perry-hir/src/lower_decl/block.rs index b20d746865..be343d2a5c 100644 --- a/crates/perry-hir/src/lower_decl/block.rs +++ b/crates/perry-hir/src/lower_decl/block.rs @@ -10,6 +10,8 @@ use crate::lower::LoweringContext; use super::*; mod closure_ident_scan; +#[cfg(test)] +mod hoisting_tests; mod var_names; use closure_ident_scan::{cic_expr, cic_stmt}; @@ -1045,18 +1047,10 @@ pub fn lower_block_stmt_scoped( block: &ast::BlockStmt, ) -> Result> { let mark = ctx.push_block_scope(); - // #9466: the strict-mode branch does NOT route through `lower_block_stmt`, - // so the block-scoped class disambiguation is bracketed here, around both - // branches. On the non-strict path `lower_block_stmt`'s own bracket sees - // this same span key and is a no-op. + // #9466: this path does not route through `lower_block_stmt`, so bracket + // block-scoped class disambiguation here. let saved_class_renames = enter_class_rename_scope(ctx, block.span.lo.0, &block.stmts); - // Via `lower_block_stmt` so this scope's pre-registered forward-captured - // lets are re-bound at entry (`rebind_nested_forward_scope_lets`). - let stmts = if ctx.current_strict { - lower_strict_block_fn_decls(ctx, block) - } else { - lower_block_stmt(ctx, block) - }; + let stmts = lower_block_fn_decls(ctx, block); exit_class_rename_scope(ctx, saved_class_renames); // `?` deliberately AFTER the rename restore but BEFORE `pop_block_scope`, // preserving this function's original error control flow exactly. @@ -1065,19 +1059,17 @@ pub fn lower_block_stmt_scoped( Ok(stmts) } -/// Strict-mode block function declarations are lexical bindings initialized -/// when the block is entered. Pre-register their locals before lowering an -/// earlier callback that captures one, then move the declarations' closure -/// initializers ahead of the block's executable statements. -fn lower_strict_block_fn_decls( - ctx: &mut LoweringContext, - block: &ast::BlockStmt, -) -> Result> { - use std::collections::HashSet; +/// Block functions are lexical bindings initialized at block entry in both +/// strict and sloppy code (#10079). Only their closure initializers move: +/// Annex B's outer-var copies stay at the textual declaration positions. +fn lower_block_fn_decls(ctx: &mut LoweringContext, block: &ast::BlockStmt) -> Result> { + use std::collections::{HashMap, HashSet}; rebind_nested_forward_scope_lets(ctx, &block.stmts); let mut hoisted_ids = HashSet::new(); + let mut block_ids = HashMap::new(); + let mut saved_bindings = Vec::new(); for stmt in &block.stmts { let ast::Stmt::Decl(ast::Decl::Fn(fn_decl)) = stmt else { continue; @@ -1086,9 +1078,14 @@ fn lower_strict_block_fn_decls( continue; } let name = fn_decl.ident.sym.to_string(); - let id = ctx - .lookup_local_in_current_scope(&name) - .unwrap_or_else(|| ctx.define_local(name, Type::Any)); + // Always shadow enclosing bindings, including parameters and Annex + // B's hoisted var. Duplicate declarations in this block share one id. + let id = *block_ids + .entry(name.clone()) + .or_insert_with(|| ctx.define_local(name.clone(), Type::Any)); + let key = (fn_decl.ident.span.lo.0, name); + let previous = ctx.block_fn_decl_bindings.insert(key.clone(), id); + saved_bindings.push((key, previous)); hoisted_ids.insert(id); } if hoisted_ids.is_empty() { @@ -1098,7 +1095,15 @@ fn lower_strict_block_fn_decls( // Lower in source order first: a declaration body may capture lexical // bindings declared earlier in the block. Only its runtime initializer is // hoisted after every reference has resolved to the correct LocalId. - let body = lower_stmts_using_aware(ctx, &block.stmts)?; + let body = lower_stmts_using_aware(ctx, &block.stmts); + for (key, previous) in saved_bindings.into_iter().rev() { + if let Some(id) = previous { + ctx.block_fn_decl_bindings.insert(key, id); + } else { + ctx.block_fn_decl_bindings.remove(&key); + } + } + let body = body?; let mut hoisted = Vec::new(); let mut other = Vec::new(); for stmt in body { diff --git a/crates/perry-hir/src/lower_decl/block/hoisting_tests.rs b/crates/perry-hir/src/lower_decl/block/hoisting_tests.rs new file mode 100644 index 0000000000..e585273001 --- /dev/null +++ b/crates/perry-hir/src/lower_decl/block/hoisting_tests.rs @@ -0,0 +1,102 @@ +use crate::{Expr, Stmt}; + +fn lower(source: &str) -> crate::Module { + let mut cache = perry_diagnostics::SourceCache::new(); + let parsed = + perry_parser::parse_typescript_with_cache(source, "hoist.cts", &mut cache).unwrap(); + crate::lower_module(&parsed.module, "hoist", "hoist.cts").unwrap() +} + +#[test] +fn sloppy_block_initialization_precedes_reads_but_outer_copy_stays_at_declaration() { + let module = lower( + r#" + function test() { + for (let i = 0; i < 2; i++) { + const before = read; + function read() { return i; } + const after = read; + } + return read; + } + "#, + ); + let function = module.functions.iter().find(|f| f.name == "test").unwrap(); + let body = function + .body + .iter() + .find_map(|s| match s { + Stmt::For { body, .. } => Some(body), + _ => None, + }) + .unwrap(); + let (init_position, inner_id) = body + .iter() + .enumerate() + .find_map(|(pos, stmt)| match stmt { + Stmt::Let { + id, + name, + init: Some(Expr::Closure { .. }), + .. + } if name == "read" => Some((pos, *id)), + _ => None, + }) + .unwrap(); + let read_position = body.iter().position(|s| matches!(s, + Stmt::Let { name, init: Some(Expr::LocalGet(id)), .. } if name == "before" && *id == inner_id + )).expect("a pre-declaration read must resolve to the block-local function"); + let (copy_position, outer_id) = body.iter().enumerate().find_map(|(pos, stmt)| match stmt { + Stmt::Expr(Expr::LocalSet(outer, value)) if matches!(value.as_ref(), Expr::LocalGet(id) if *id == inner_id) => Some((pos, *outer)), + _ => None, + }).unwrap(); + assert!( + init_position < read_position && read_position < copy_position, + "{body:#?}" + ); + assert_ne!(inner_id, outer_id, "Annex B must keep two bindings"); + assert!(function.body.iter().any(|s| matches!(s, + Stmt::Return(Some(Expr::LocalGet(id))) if *id == outer_id + ))); +} + +#[test] +fn block_function_shadows_a_parameter_in_both_strictness_modes() { + for directive in ["", "'use strict';"] { + let source = format!( + r#" + function test(read) {{ + {directive} + {{ + const before = read; + function read() {{ return 7; }} + }} + return read; + }} + "# + ); + let module = lower(&source); + let function = module.functions.iter().find(|f| f.name == "test").unwrap(); + let parameter = function.params[0].id; + let inner_id = function + .body + .iter() + .find_map(|s| match s { + Stmt::Let { + id, + name, + init: Some(Expr::Closure { .. }), + .. + } if name == "read" => Some(*id), + _ => None, + }) + .unwrap(); + assert_ne!(inner_id, parameter, "directive={directive}"); + assert!(function.body.iter().any(|s| matches!(s, + Stmt::Let { name, init: Some(Expr::LocalGet(id)), .. } if name == "before" && *id == inner_id + )), "the early read must use the shadowing block binding"); + assert!(function.body.iter().any(|s| matches!(s, + Stmt::Return(Some(Expr::LocalGet(id))) if *id == parameter + ))); + } +} diff --git a/crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs b/crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs index 7aa53d0167..230a46e4e3 100644 --- a/crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs +++ b/crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs @@ -62,7 +62,13 @@ pub(super) fn lower_nested_fn_decl( // LocalGet(local_id) rather than FuncRef(func_id). This ensures // the LLVM backend's boxed-var analysis sees the same LocalId at // both the declaration and self-reference sites. - let local_id = if is_block_nested { + let predeclared = ctx + .block_fn_decl_bindings + .get(&(fn_decl.ident.span.lo.0, func_name.clone())) + .copied(); + let local_id = if let Some(id) = predeclared { + id + } else if is_block_nested { // Fresh block-local binding, independent of any enclosing same-named // parameter / `var` (the latter is written separately below). ctx.define_local(func_name.clone(), Type::Any) diff --git a/crates/perry/tests/issue_10079_script_block_function_hoisting.rs b/crates/perry/tests/issue_10079_script_block_function_hoisting.rs new file mode 100644 index 0000000000..150409c292 --- /dev/null +++ b/crates/perry/tests/issue_10079_script_block_function_hoisting.rs @@ -0,0 +1,83 @@ +//! Pin package context and native optimization level for #10079. The gap +//! runner's enclosing ESM package otherwise hides the sloppy-mode regression. + +use std::process::Command; + +const SOURCE: &str = include_str!("../../../test-files/test_gap_10079_block_function_hoisting.ts"); + +#[test] +fn script_and_esm_block_functions_match_node_at_o0_os_and_oz() { + let node = Command::new("node") + .arg("--version") + .output() + .expect("Node oracle"); + assert!(node.status.success()); + assert_eq!( + String::from_utf8_lossy(&node.stdout).trim(), + format!("v{}", include_str!("../../../.node-version").trim()), + "use the pinned Node oracle" + ); + + for kind in ["commonjs", "module"] { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("package.json"), + format!(r#"{{"type":"{kind}"}}"#), + ) + .unwrap(); + let entry = dir.path().join("fixture.ts"); + std::fs::write(&entry, SOURCE).unwrap(); + let oracle = Command::new("node") + .arg("--experimental-strip-types") + .arg(&entry) + .current_dir(dir.path()) + .output() + .expect("run Node"); + assert!( + oracle.status.success(), + "{kind}: {}", + String::from_utf8_lossy(&oracle.stderr) + ); + assert!(String::from_utf8_lossy(&oracle.stdout).contains("var 2,2,2\nlet 10,11,12\n")); + + for level in ["0", "s", "z"] { + let output = dir.path().join(format!("fixture-{level}")); + let compile = Command::new(env!("CARGO_BIN_EXE_perry")) + .args([ + "compile", + "--no-cache", + "--no-auto-optimize", + "--no-codegen", + "--platform", + "bun", + ]) + .arg(&entry) + .arg("-o") + .arg(&output) + .env("PERRY_LL_OPT_LEVEL", level) + .current_dir(dir.path()) + .output() + .expect("compile fixture"); + assert!( + compile.status.success(), + "{kind}/O{level}: {}", + String::from_utf8_lossy(&compile.stderr) + ); + let run = Command::new(&output) + .current_dir(dir.path()) + .output() + .expect("run fixture"); + assert!( + run.status.success(), + "{kind}/O{level}: stdout={} stderr={}", + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&oracle.stdout), + "{kind}/O{level}: native output must match its own package-context oracle" + ); + } + } +} diff --git a/scripts/test-require-runtime.test.mjs b/scripts/test-require-runtime.test.mjs index 6e3aacb5d5..aecc3d9ef7 100644 --- a/scripts/test-require-runtime.test.mjs +++ b/scripts/test-require-runtime.test.mjs @@ -97,6 +97,17 @@ test('scoped CI prepares coherent providers for each standalone native consumer' } }); +test('sloppy block-function suite builds its Bun startup provider with the runtime', () => { + const result = ciSetup('perry issue_10079_script_block_function_hoisting 1500'); + assert.equal(result.status, 0, result.stderr); + const calls = result.stdout.split('\n').filter(line => line.startsWith('cargo:')); + assert.equal(calls.length, 1); + assert.match(calls[0], /-p perry-runtime-static /); + assert.match(calls[0], /-p perry-stdlib-static /); + assert.match(calls[0], /-p perry-ext-net$/); + assert.match(result.stdout, /prepared:unset\n/); +}); + test('scoped CI does not mark unrelated or partial runtime setup prepared', () => { for (const suites of ['', 'perry-codegen minsize_inline_policy 300', 'perry minsize_inline_policy_extra 1500', 'perry unrelated 1500']) { diff --git a/test-files/test_gap_10079_block_function_hoisting.ts b/test-files/test_gap_10079_block_function_hoisting.ts new file mode 100644 index 0000000000..3071061f83 --- /dev/null +++ b/test-files/test_gap_10079_block_function_hoisting.ts @@ -0,0 +1,59 @@ +// #10079: also run by the integration test in explicitly pinned script/ESM packages. +function hoistedVar() { + const callbacks: Array<() => number> = []; + for (let index = 0; index < 3; index++) { + callbacks.push(read); + var value = index; + function read() { return value; } + } + return callbacks.map(callback => callback()).join(","); +} +console.log("var", hoistedVar()); + +function lexicalCaptures() { + const callbacks: Array<() => number> = []; + for (let index = 0; index < 3; index++) { + callbacks.push(read); + let value = index + 10; + function read() { return value; } + } + return callbacks.map(callback => callback()).join(","); +} +console.log("let", lexicalCaptures()); + +function shadowParameter(read: any) { + let result = ""; + { + result = String(read()); + function read() { return 7; } + } + return result + ":" + read; +} +console.log("parameter", shadowParameter("outer")); + +function annexBUpdates() { + function outerValue() { + return typeof read === "function" ? read() : "absent"; + } + for (let index = 0; index < 2; index++) { + console.log("before block", outerValue()); + { + console.log("before declaration", read(), outerValue()); + function read() { return index; } + console.log("after declaration", outerValue()); + read = () => 90 + index; + console.log("after local assignment", read(), outerValue()); + } + console.log("after block", outerValue()); + } +} +annexBUpdates(); + +function mutual() { + for (let index = 0; index < 2; index++) { + console.log("mutual", even(4), odd(4)); + function even(n: number): boolean { return n === 0 || odd(n - 1); } + function odd(n: number): boolean { return n !== 0 && even(n - 1); } + } +} +mutual(); From 8b5593a3aff15df59279108ac9e55e2e82c3a95f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 14 Sep 2026 00:03:40 +0200 Subject: [PATCH 2/2] fix: name changelog fragment for PR 10232 --- ...nction-hoisting.md => 10232-script-block-function-hoisting.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{10079-script-block-function-hoisting.md => 10232-script-block-function-hoisting.md} (100%) diff --git a/changelog.d/10079-script-block-function-hoisting.md b/changelog.d/10232-script-block-function-hoisting.md similarity index 100% rename from changelog.d/10079-script-block-function-hoisting.md rename to changelog.d/10232-script-block-function-hoisting.md