diff --git a/CHANGELOG.md b/CHANGELOG.md index 24bb51d095..ff609c7b36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## v0.5.1230 — class-capture snapshot P0 pair: resolved-name re-registration + same-body assignment tracking + +Two defects in the decl-site class-capture snapshot (`js_class_register_capture_values`), both extracted from the #5437 Next.js branch: + +1. **End-of-body re-registration used the SOURCE class name while the body was compiled under the RENAMED one.** When a function-nested class collides with another declaration (`class_renames` in play), the end-of-body capture re-registration ran after the rename map was restored, so it registered under the wrong name and the refresh never reached the renamed class's id. Re-registration now happens before the `class_renames` restore and goes through `resolve_class_name`. (`crates/perry-hir/src/lower_decl/block.rs`, `crates/perry-hir/src/lower/expr_function.rs`) +2. **The snapshot only refreshed before `return` statements — a captured local ASSIGNED after the class decl in the same body kept its stale decl-time value.** `insert_class_capture_refresh_after_assignments` now re-snapshots after every top-level assignment/update of a captured local that follows the class declaration in the same body (companion to the existing before-returns refresh). + +Salvages the stranded `issue_5437_incremental_cache_get_chain` e2e from the probe branch and adds `capture_rereg_renamed_class` (4 tests). Verified: parity vs the 2026-07-03 baseline shows 0 real newly-failing (one fs-fd flake ruled out by 6/6 local matches) and 6 newly-passing. + ## v0.5.1229 — write-path hardening slice 2: transition_fast tags, structuredClone band, rejection printer (#5948) Continuation of #5943 (2026-07-02 audit §6). js_object_set_field_by_name_transition_fast — the first thing every generic `obj.field = v` hits until any defineProperty runs — admitted SSO strings/tag remnants via a `top16 >= 0x7FF8` catch-all then deref'd a bare GcHeader floor (a 2–5-char SSO payload lands in the 2–5.5TB range and passes the macOS heap floor — the write-side #5429 twin); now POINTER-tag-only, full handle band, try_read_gc_header, with return-0 deferring to the full dynamic path. structuredClone deref'd any POINTER payload ≥0x10000 as GcHeader bytes after the registry probes (fetch/zlib registry ids crashed); full-band gate + validated header probe, pass-through unchanged for non-probeables. The unhandled-rejection printer deref'd POINTER-tagged reasons with a bare ≥0x10000 (a thrown fetch Response id crashed instead of printing); is_plausible_heap_addr gate. e2e: write_path_band_slice2.rs. diff --git a/CLAUDE.md b/CLAUDE.md index a9f8181a85..9810e0ce2c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1229 +**Current Version:** 0.5.1230 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 6b47ad6f5e..45dc2198f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5338,7 +5338,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "anyhow", "base64", @@ -5395,14 +5395,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "cc", "libc", @@ -5410,7 +5410,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "anyhow", "log", @@ -5425,7 +5425,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "anyhow", "perry-hir", @@ -5434,7 +5434,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "anyhow", "perry-hir", @@ -5442,7 +5442,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "anyhow", "perry-dispatch", @@ -5452,7 +5452,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "anyhow", "perry-hir", @@ -5461,7 +5461,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "anyhow", "base64", @@ -5474,7 +5474,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "anyhow", "perry-hir", @@ -5482,7 +5482,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "anyhow", "async-trait", @@ -5511,14 +5511,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "serde", "serde_json", @@ -5526,7 +5526,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1229" +version = "0.5.1230" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5537,7 +5537,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "anyhow", "clap", @@ -5552,7 +5552,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "block2", "objc2", @@ -5562,7 +5562,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "argon2", "perry-ffi", @@ -5570,7 +5570,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "perry-ffi", "reqwest", @@ -5579,7 +5579,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "bcrypt", "perry-ffi", @@ -5587,7 +5587,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "perry-ffi", "rusqlite", @@ -5595,7 +5595,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "perry-ffi", "scraper", @@ -5603,7 +5603,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "perry-ffi", "perry-runtime", @@ -5611,7 +5611,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "chrono", "cron", @@ -5621,7 +5621,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "chrono", "perry-ffi", @@ -5629,7 +5629,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "perry-ffi", "rust_decimal", @@ -5637,7 +5637,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "perry-ffi", "serde_json", @@ -5645,7 +5645,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "perry-ffi", "rand 0.8.6", @@ -5653,7 +5653,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "perry-ffi", "perry-runtime", @@ -5661,14 +5661,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "bytes", "http-body-util", @@ -5686,7 +5686,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "bytes", "lazy_static", @@ -5699,7 +5699,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "bytes", "lazy_static", @@ -5713,7 +5713,7 @@ dependencies = [ [[package]] name = "perry-ext-http-server" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "bytes", "h2", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "lazy_static", "perry-ffi", @@ -5746,7 +5746,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "base64", "jsonwebtoken", @@ -5757,7 +5757,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "lru", "perry-ffi", @@ -5765,7 +5765,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "chrono", "perry-ffi", @@ -5773,7 +5773,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "bson", "futures-util", @@ -5785,7 +5785,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "chrono", "perry-ffi", @@ -5795,7 +5795,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "nanoid", "perry-ffi", @@ -5804,7 +5804,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "bytes", "perry-ffi", @@ -5817,7 +5817,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "lettre", "perry-ffi", @@ -5827,7 +5827,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "perry-ffi", "printpdf", @@ -5835,7 +5835,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "perry-ffi", "sqlx", @@ -5844,7 +5844,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "governor", "perry-ffi", @@ -5852,7 +5852,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "fast_image_resize", "image", @@ -5862,14 +5862,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "lazy_static", "perry-ffi", @@ -5878,7 +5878,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "perry-ffi", "uuid", @@ -5886,7 +5886,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "perry-ffi", "regex", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "futures-util", "lazy_static", @@ -5908,7 +5908,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "brotli", "flate2", @@ -5917,7 +5917,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "dashmap", "once_cell", @@ -5926,7 +5926,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "anyhow", "perry-api-manifest", @@ -5944,7 +5944,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "anyhow", "perry-diagnostics", @@ -5956,7 +5956,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "anyhow", "base64", @@ -5989,14 +5989,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "aes 0.8.4", "aes-gcm", @@ -6089,14 +6089,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "anyhow", "perry-hir", @@ -6106,7 +6106,7 @@ dependencies = [ [[package]] name = "perry-types" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "anyhow", "thiserror 1.0.69", @@ -6114,14 +6114,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "base64", "itoa", @@ -6138,7 +6138,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "rand 0.8.6", "serde", @@ -6148,7 +6148,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6171,7 +6171,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "base64", "block2", @@ -6187,7 +6187,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "base64", "block2", @@ -6202,7 +6202,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1229" +version = "0.5.1230" [[package]] name = "perry-ui-test" @@ -6210,11 +6210,11 @@ version = "0.1.0" [[package]] name = "perry-ui-testkit" -version = "0.5.1229" +version = "0.5.1230" [[package]] name = "perry-ui-tvos" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "base64", "block2", @@ -6230,7 +6230,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "base64", "block2", @@ -6246,7 +6246,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "block2", "libc", @@ -6259,7 +6259,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "base64", "libc", @@ -6276,14 +6276,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "base64", "ed25519-dalek", @@ -6297,7 +6297,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1229" +version = "0.5.1230" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 49ef952f28..bc7126a756 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -301,7 +301,7 @@ strip = false codegen-units = 16 [workspace.package] -version = "0.5.1229" +version = "0.5.1230" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/crates/perry-hir/src/lower/expr_function.rs b/crates/perry-hir/src/lower/expr_function.rs index 55cf433c4c..e69f4993cb 100644 --- a/crates/perry-hir/src/lower/expr_function.rs +++ b/crates/perry-hir/src/lower/expr_function.rs @@ -1144,6 +1144,7 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul // trailing `return`). if let Some(ref block) = fn_expr.function.body { let mut re_regs: Vec = Vec::new(); + let mut re_reg_capsets: Vec<(Stmt, std::collections::HashSet)> = Vec::new(); for stmt in &block.stmts { if let ast::Stmt::Decl(ast::Decl::Class(class_decl)) = stmt { // A colliding `class X` may have been renamed during body @@ -1159,15 +1160,21 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul for s in body.iter_mut() { crate::lower_decl::append_new_args_stmt(s, &cname, &cap_args, true); } - re_regs.push(Stmt::Expr(Expr::RegisterClassCaptures { + let re_reg = Stmt::Expr(Expr::RegisterClassCaptures { class_name: cname, captures, - })); + }); + re_reg_capsets.push((re_reg.clone(), captured.iter().copied().collect())); + re_regs.push(re_reg); } } } } if !re_regs.is_empty() { + // Audit P0-B twin of the block-body path: refresh after every + // same-body assignment to a captured local so mid-body constructs + // read the live value through the authoritative snapshot. + insert_class_capture_refresh_after_assignments(&mut body, &re_reg_capsets); // Refresh the snapshot before EVERY reachable `return` in the body // (not only a trailing one): an EARLY `return ` after the // captured locals are assigned would otherwise return a class with @@ -1262,7 +1269,7 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul /// — a closure's `return` exits a different function and must keep its own /// snapshot. Each return path then records the live capture values at that /// point. See the call site in `lower_fn_expr_anon` (CodeRabbit #5739). -fn insert_class_capture_refresh_before_returns(stmts: &mut Vec, re_regs: &[Stmt]) { +pub(crate) fn insert_class_capture_refresh_before_returns(stmts: &mut Vec, re_regs: &[Stmt]) { let mut i = 0; while i < stmts.len() { insert_class_capture_refresh_into_stmt(&mut stmts[i], re_regs); @@ -1399,3 +1406,88 @@ fn compute_closure_captures( (captures, mutable_captures) } + +/// Insert a class-capture refresh immediately AFTER every statement that +/// assigns one of the class's captured locals (2026-07-02 audit capture +/// P0-B). The decl-site snapshot is AUTHORITATIVE at construct time (the +/// appended cap arg can be a mis-boxed multi-level capture — the W6 class), +/// so a same-body assignment after the class declaration must update the +/// snapshot or every later construct reads the stale decl-time value and +/// `emit_class_capture_writeback` then resets the outer local to it: +/// +/// let x = 1; +/// class C { m() { return x; } } +/// x = 2; // ← refresh inserted after this statement +/// new C().m() // reads 2 (was: 1, and x reset to 1) +/// +/// Descends into nested statement bodies (assignments inside if/loop arms +/// get their refresh at that level) but not into closures — a closure's +/// assignment happens at ITS call time, not lexically here. Refreshes +/// inserted before the class's own declaration are harmless: the decl-site +/// registration overwrites them in program order. +pub(crate) fn insert_class_capture_refresh_after_assignments( + stmts: &mut Vec, + regs: &[(Stmt, std::collections::HashSet)], +) { + let mut i = 0; + while i < stmts.len() { + // Recurse first so nested bodies get their own refreshes. + match &mut stmts[i] { + Stmt::If { + then_branch, + else_branch, + .. + } => { + insert_class_capture_refresh_after_assignments(then_branch, regs); + if let Some(eb) = else_branch { + insert_class_capture_refresh_after_assignments(eb, regs); + } + } + Stmt::While { body, .. } | Stmt::DoWhile { body, .. } => { + insert_class_capture_refresh_after_assignments(body, regs); + } + Stmt::For { body, .. } => { + insert_class_capture_refresh_after_assignments(body, regs); + } + Stmt::Try { + body, + catch, + finally, + } => { + insert_class_capture_refresh_after_assignments(body, regs); + if let Some(c) = catch { + insert_class_capture_refresh_after_assignments(&mut c.body, regs); + } + if let Some(f) = finally { + insert_class_capture_refresh_after_assignments(f, regs); + } + } + Stmt::Switch { cases, .. } => { + for case in cases.iter_mut() { + insert_class_capture_refresh_after_assignments(&mut case.body, regs); + } + } + _ => {} + } + // Does this statement (at THIS level, closures excluded by the + // collector) assign any watched capture? + let mut assigned = Vec::new(); + collect_assigned_locals_stmt(&stmts[i], &mut assigned); + if !assigned.is_empty() { + let mut inserts: Vec = Vec::new(); + for (re_reg, capset) in regs { + if matches!(&stmts[i], Stmt::Expr(Expr::RegisterClassCaptures { .. })) { + continue; + } + if assigned.iter().any(|id| capset.contains(id)) { + inserts.push(re_reg.clone()); + } + } + for (j, s) in inserts.iter().cloned().enumerate() { + stmts.insert(i + 1 + j, s); + } + i += inserts.len(); + } + i += 1; + } +} diff --git a/crates/perry-hir/src/lower/mod.rs b/crates/perry-hir/src/lower/mod.rs index 8eeef1c9d3..583aefaca3 100644 --- a/crates/perry-hir/src/lower/mod.rs +++ b/crates/perry-hir/src/lower/mod.rs @@ -37,7 +37,7 @@ mod context; pub(crate) mod expr_assign; mod expr_call; -mod expr_function; +pub(crate) mod expr_function; pub(crate) use expr_function::capture_function_source; mod expr_member; pub(crate) use expr_member::{wrap_private_guard, PRIV_OP_WRITE}; diff --git a/crates/perry-hir/src/lower_decl/block.rs b/crates/perry-hir/src/lower_decl/block.rs index 2e200c18f1..05178b391e 100644 --- a/crates/perry-hir/src/lower_decl/block.rs +++ b/crates/perry-hir/src/lower_decl/block.rs @@ -1025,23 +1025,30 @@ pub fn lower_fn_body_block_stmt( return Err(err); } }; - ctx.forward_class_names = saved_forward_class_names; - ctx.forward_class_decl_depth = saved_forward_class_decl_depth; - ctx.class_renames = saved_class_renames; - // Re-register capture snapshots for classes declared in this body at // its END. The decl-site `RegisterClassCaptures` runs before later // statements assign captured vars (tsc emits TS-enum namespaces AFTER // the classes that reference them — vendored zod's // ZodFirstPartyTypeKind), so static-method snapshot reads and post- - // return dynamic constructions need the FINAL values. Inserted before - // a trailing `return` when present; bodies with early returns keep the - // decl-site snapshot for those paths. + // return dynamic constructions need the FINAL values. + // + // MUST run while this body's `class_renames` are still ACTIVE, using the + // RESOLVED class name (2026-07-02 audit P0): with the restore-first + + // raw-ident order, factory B's renamed `class e` (→ `e$0`) re-registered + // the OUTER factory's `e` with B's out-of-scope ids — codegen's LocalGet + // soft-fallback then clobbered A's snapshot to all-undefined the moment + // B ran, resurrecting the W6 undefined-captures class for every dynamic + // construct of A's class; meanwhile `e$0` itself never got refreshed and + // its forward-ref `new` sites never got their cap args appended. + // Mirrors the function-expression twin (`lower_fn_expr`), including the + // refresh-before-EVERY-return placement. { let mut re_regs: Vec = Vec::new(); + let mut re_reg_capsets: Vec<(Stmt, std::collections::HashSet)> = + Vec::new(); for stmt in &block.stmts { if let ast::Stmt::Decl(ast::Decl::Class(class_decl)) = stmt { - let cname = class_decl.ident.sym.to_string(); + let cname = ctx.resolve_class_name(class_decl.ident.sym.as_str()); if let Some(captured) = ctx.lookup_class_captures(&cname) { if !captured.is_empty() { let captures: Vec = @@ -1065,25 +1072,34 @@ pub fn lower_fn_body_block_stmt( for s in body.iter_mut() { super::class_captures::append_new_args_stmt(s, &cname, &cap_args, true); } - re_regs.push(Stmt::Expr(Expr::RegisterClassCaptures { + let re_reg = Stmt::Expr(Expr::RegisterClassCaptures { class_name: cname, captures, - })); + }); + re_reg_capsets.push((re_reg.clone(), captured.iter().copied().collect())); + re_regs.push(re_reg); } } } } if !re_regs.is_empty() { - let insert_at = if matches!(body.last(), Some(Stmt::Return(_))) { - body.len() - 1 - } else { - body.len() - }; - for (i, s) in re_regs.into_iter().enumerate() { - body.insert(insert_at + i, s); - } + // Audit P0-B: the decl-site snapshot is authoritative at + // construct time, so keep it TRACKING same-body assignments — + // refresh after every statement that assigns a captured local + // (else `let x=1; class C{..x..}; x=2; new C()` reads 1 and the + // capture write-back resets x to 1). + crate::lower::expr_function::insert_class_capture_refresh_after_assignments( + &mut body, + &re_reg_capsets, + ); + crate::lower::expr_function::insert_class_capture_refresh_before_returns( + &mut body, &re_regs, + ); } } + ctx.forward_class_names = saved_forward_class_names; + ctx.forward_class_decl_depth = saved_forward_class_decl_depth; + ctx.class_renames = saved_class_renames; // Undefined-initialised entry slots for hoisted `var`s declared in // nested blocks (see predefine_var_bindings_in_function_body docs). diff --git a/crates/perry/tests/capture_rereg_renamed_class.rs b/crates/perry/tests/capture_rereg_renamed_class.rs new file mode 100644 index 0000000000..ee1c30d22c --- /dev/null +++ b/crates/perry/tests/capture_rereg_renamed_class.rs @@ -0,0 +1,178 @@ +//! Regression tests for the 2026-07-02 audit's capture-re-registration P0: +//! the end-of-body `RegisterClassCaptures` refresh in arrow/function-decl +//! bodies (a) ran AFTER `ctx.class_renames` was restored and looked the +//! class up by its RAW ident, and (b) inserted only before a TRAILING +//! return. +//! +//! (a) meant factory B's `class e` (renamed `e$0` when factory A's `class e` +//! was lowered first) re-registered factory A's `e` with B's out-of-scope +//! ids — codegen's LocalGet soft-fallback then clobbered A's snapshot to +//! all-undefined the moment B ran (minified bundles reuse one-letter class +//! names across hundreds of factories, so both preconditions were +//! near-certain at bundle scale); `e$0` itself never got refreshed. (b) +//! meant early-return paths kept the stale decl-site snapshot. +//! +//! Static-method reads go through the snapshot-only prologue, so they +//! discriminate the snapshot's value directly. Expected outputs are +//! byte-for-byte `node --experimental-strip-types`. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn compile_and_run(dir: &std::path::Path, source: &str) -> String { + let entry = dir.join("main.ts"); + let output = dir.join("main_bin"); + std::fs::write(&entry, source).expect("write entry"); + + let compile = Command::new(perry_bin()) + .current_dir(dir) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .arg("--no-cache") + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(&output) + .current_dir(dir) + .output() + .expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed (exit {:?})\nstdout:\n{}\nstderr:\n{}", + run.status.code(), + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + String::from_utf8_lossy(&run.stdout).into_owned() +} + +/// Two arrow factories each declaring `class e` with their own captures: +/// running factory B must not clobber factory A's capture snapshot (A's +/// static read still sees "alpha" after B ran). +#[test] +fn same_class_name_across_factories_keeps_snapshots_separate() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +const makeA = () => { + const tagA = "alpha"; + class e { + static who() { + return tagA; + } + } + return () => e.who(); +}; +const whoA = makeA(); +const makeB = (x: number) => { + const tagB = "beta" + x; + class e { + static who() { + return tagB; + } + } + return e.who(); +}; +console.log("A1:", whoA()); +console.log("B:", makeB(1)); +console.log("A2:", whoA()); +"#, + ); + assert_eq!(stdout, "A1: alpha\nB: beta1\nA2: alpha\n"); +} + +/// The snapshot refresh must run before EVERY return, not only a trailing +/// one: an early return after a captured-var mutation must see the mutated +/// value in the snapshot (static reads are snapshot-only). +#[test] +fn snapshot_refresh_covers_early_returns() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +function makeC(flag: boolean) { + let n = 1; + class e { + static val() { + return n; + } + } + n = 2; + if (flag) return e.val; + n = 3; + return e.val; +} +console.log("C-early:", makeC(true)()); +console.log("C-late:", makeC(false)()); +"#, + ); + assert_eq!(stdout, "C-early: 2\nC-late: 3\n"); +} + +/// Audit P0-B: a same-body assignment AFTER the class declaration must be +/// visible to a mid-body construct (the decl-site snapshot is authoritative +/// at construct time, so it must track assignments), and the capture +/// write-back must not reset the outer local to the stale value. +#[test] +fn mid_body_construct_sees_assignment_after_class_decl() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +function f() { + let x = 1; + class C { + m() { + return x; + } + } + x = 2; + const c = new C(); + console.log("m:", c.m(), "x:", x); +} +f(); +"#, + ); + assert_eq!(stdout, "m: 2 x: 2\n"); +} + +/// Assignment inside an if-branch after the declaration (the zod +/// enum-namespace-after-class shape): static reads are snapshot-only, so +/// the branch's refresh must land at its own nesting level. +#[test] +fn branch_assignment_refreshes_snapshot() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +function g(flag: boolean) { + let cfg: any = null; + class D { + static read() { + return cfg === null ? "null" : cfg.mode; + } + } + if (flag) { + cfg = { mode: "live" }; + } + console.log("D:", D.read()); +} +g(true); +g(false); +"#, + ); + assert_eq!(stdout, "D: live\nD: null\n"); +} diff --git a/crates/perry/tests/issue_5437_incremental_cache_get_chain.rs b/crates/perry/tests/issue_5437_incremental_cache_get_chain.rs new file mode 100644 index 0000000000..9958a84e46 --- /dev/null +++ b/crates/perry/tests/issue_5437_incremental_cache_get_chain.rs @@ -0,0 +1,128 @@ +//! Regression test for #5437 (Next.js `context.incrementalCache.get(...)` wall). +//! +//! This is the end-to-end shape that the per-implementor-arity dispatch fix +//! (#5622, `crates/perry-codegen/src/lower_call/property_get.rs`) unblocks. Two +//! walls lived on this exact chain in the Next.js app-router render: +//! +//! 1. `Cannot read properties of undefined (reading 'get')` — the 3rd +//! positional arg `context` of `responseCache.get(key, gen, context)` was +//! collapsed to `0.0` because `get` has a `...rest` implementor and the +//! dispatch tower applied ONE global rest-bundle to every case, dropping +//! the non-rest 3-arg implementor's positional params. `const { +//! incrementalCache } = context` then destructured off `0.0`. +//! +//! 2. `get is not a function` — once `context` survives, the chained +//! `context.incrementalCache.get(key, { kind })` must itself resolve +//! through the (multi-implementor) dispatch tower. If the tower mis-routes +//! or the receiver's class-id misses every case, it falls through to +//! `js_native_call_method`'s catch-all and throws the bare +//! `get is not a function`. +//! +//! With the per-implementor-arity fix both walls are gone: `context` arrives +//! intact AND the chained async `incrementalCache.get` dispatches to the right +//! 2-arg method. This test models the chain (object-literal 3rd arg carrying an +//! IncrementalCache instance; `get` co-implemented by a `...rest` class and a +//! 1-arg class so the tower has multiple cases) and asserts the same byte +//! output as `node --experimental-strip-types`. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn compile_and_run(dir: &std::path::Path, source: &str) -> String { + let entry = dir.join("main.ts"); + let output = dir.join("main_bin"); + std::fs::write(&entry, source).expect("write entry"); + + let compile = Command::new(perry_bin()) + .current_dir(dir) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(&output) + .current_dir(dir) + .output() + .expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + String::from_utf8_lossy(&run.stdout).into_owned() +} + +/// The 3rd positional arg of an untyped `f.get(key, gen, context)` call is an +/// object literal carrying an `IncrementalCache` instance. `f.get` (the +/// non-rest 3-arg `ResponseCache.get`) must receive `context` intact, destructure +/// `incrementalCache` off it, and the chained async `incrementalCache.get(key, +/// { kind })` must dispatch to the 2-arg method WITHOUT "get is not a function" — +/// even though `get` is also implemented by a `...rest` class and a 1-arg class. +#[test] +fn incremental_cache_get_chain_resolves() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +class IncrementalCache { + async get(cacheKey: string, ctx: { kind: string }): Promise { + return "IC.get(" + cacheKey + "," + ctx.kind + ")"; + } +} + +// Sibling `get` implementors with differing arity, including a ...rest one, so +// the dynamic-dispatch tower has multiple cases (the shape that made the +// rest-bundle collapse every case pre-#5622). +class WithRest { + get(...r: any[]): string { return "rest:" + r.length; } +} +class OneArg { + get(a: any): string { return "one:" + String(a); } +} + +class ResponseCache { + // 3-arg async method; 3rd param `context` MUST survive dispatch (pre-#5622 it + // read 0.0 → `const { incrementalCache } = context` off a number). + async get(key: string, gen: number, context: any): Promise { + const { incrementalCache } = context; + const r = await incrementalCache.get(key, { kind: "APP" }); + return "handleGet[" + r + "]"; + } +} + +function pickGetter(n: number): any { + if (n === 0) return new WithRest(); + if (n === 1) return new ResponseCache(); + return new OneArg(); +} + +async function main() { + const f: any = pickGetter(1); + const ic: any = new IncrementalCache(); + // 3rd positional arg = object literal carrying the IncrementalCache instance. + const out = await f.get("k1", 7, { routeKind: "APP", incrementalCache: ic, waitUntil: null }); + console.log(out); + // Exercise the rest + one-arg siblings so the tower keeps >1 implementor. + console.log(pickGetter(0).get(1, 2, 3)); + console.log(pickGetter(2).get(9, 9, 9)); +} +main(); +"#, + ); + // node --experimental-strip-types prints exactly this. + assert_eq!(stdout, "handleGet[IC.get(k1,APP)]\nrest:3\none:9\n"); +}