diff --git a/CHANGELOG.md b/CHANGELOG.md index b3d07ca5a1..661fce81db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## v0.5.1202 — revert: #5546 + #5553 (#5437 module-default wrapper auto-call machinery) — wrong layer, proven cannot close W6 + +Reverts the auto-call wrapper-resolve machinery (#5546) and its boot-crash patch (#5553). A rigorous follow-up disproved the approach: the #5437 throw (`new uw.SharedCacheControls`) fails because the captured `uw` reads a **mis-boxed closure** at request time, whereas `uw` is a correct `module.exports` **object** at every point where the registration/auto-call could fire (the `require`, the class-capture store). So registering module-default wrappers is a guaranteed no-op for this bug — the value that throws never passes through a registration site. The machinery also introduced a regression (auto-calling a CJS function-export wrapper, e.g. `debug`, on the `_interop_require_default` `.__esModule` probe → boot crash; #5553 patched that symptom but the core approach can't close W6). Removing it cleans the dead + risky auto-call path off `main`. The real root — the class-capture materialization mis-boxing the pointer (the scale-emergent captured-pointer mis-box class, CLAUDE.md "captured pointer values must be NaN-boxed before storing") — is tracked under #5437 for a dedicated fix at the capture-store layer. + ## v0.5.1201 — fix(runtime): #5437 — CJS-interop probe on a module-default wrapper must not auto-call it (boot-crash fix for #5546) Follow-up to #5546. That change registers CJS module-default wrapper closures and, on a property miss, auto-calls the wrapper (`js_closure_call0`) to obtain `module.exports`. But for a CJS module whose `module.exports` **is a function** (e.g. `debug` → `createDebug`), the wrapper *is* that function, and the ubiquitous `_interop_require_default(require("debug"))` interop reads `.__esModule` off it → property miss → the fallback **called the module's function with no args** as a side effect → e.g. `enabled(undefined)` → `undefined.length` → threw at module-init (server exits at boot). This regressed the Next.js bundle from serving (HTTP 500) to crashing at boot (HTTP 000) and could break other SWC/Babel-compiled-CJS programs. diff --git a/CLAUDE.md b/CLAUDE.md index e7fb81880e..c678337bf5 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.1201 +**Current Version:** 0.5.1202 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 05a3c84dfb..a7a29ea324 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5325,7 +5325,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "anyhow", "base64", @@ -5382,14 +5382,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "cc", "libc", @@ -5397,7 +5397,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "anyhow", "log", @@ -5412,7 +5412,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "anyhow", "perry-hir", @@ -5421,7 +5421,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "anyhow", "perry-hir", @@ -5429,7 +5429,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "anyhow", "perry-dispatch", @@ -5439,7 +5439,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "anyhow", "perry-hir", @@ -5448,7 +5448,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "anyhow", "base64", @@ -5461,7 +5461,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "anyhow", "perry-hir", @@ -5469,7 +5469,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "anyhow", "async-trait", @@ -5498,14 +5498,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "serde", "serde_json", @@ -5513,7 +5513,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1201" +version = "0.5.1202" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5524,7 +5524,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "anyhow", "clap", @@ -5539,14 +5539,14 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-argon2" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "argon2", "perry-ffi", @@ -5554,7 +5554,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "perry-ffi", "reqwest", @@ -5563,7 +5563,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "bcrypt", "perry-ffi", @@ -5571,7 +5571,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "perry-ffi", "rusqlite", @@ -5579,7 +5579,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "perry-ffi", "scraper", @@ -5587,7 +5587,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "perry-ffi", "perry-runtime", @@ -5595,7 +5595,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "chrono", "cron 0.16.0", @@ -5605,7 +5605,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "chrono", "perry-ffi", @@ -5613,7 +5613,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "perry-ffi", "rust_decimal", @@ -5621,7 +5621,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "perry-ffi", "serde_json", @@ -5629,7 +5629,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "perry-ffi", "rand 0.8.6", @@ -5637,7 +5637,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "perry-ffi", "perry-runtime", @@ -5645,14 +5645,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "bytes", "http-body-util", @@ -5669,7 +5669,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "lazy_static", "perry-ffi", @@ -5681,7 +5681,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "bytes", "lazy_static", @@ -5695,7 +5695,7 @@ dependencies = [ [[package]] name = "perry-ext-http-server" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "bytes", "h2", @@ -5718,7 +5718,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "lazy_static", "perry-ffi", @@ -5728,7 +5728,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "base64", "jsonwebtoken", @@ -5739,7 +5739,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "lru", "perry-ffi", @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "chrono", "perry-ffi", @@ -5755,7 +5755,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "bson", "futures-util", @@ -5767,7 +5767,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "chrono", "perry-ffi", @@ -5777,7 +5777,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "nanoid", "perry-ffi", @@ -5786,7 +5786,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "perry-ffi", "perry-runtime", @@ -5798,7 +5798,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "lettre", "perry-ffi", @@ -5808,7 +5808,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "perry-ffi", "printpdf", @@ -5816,7 +5816,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "perry-ffi", "sqlx", @@ -5825,7 +5825,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "governor", "perry-ffi", @@ -5833,7 +5833,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "fast_image_resize", "image", @@ -5843,14 +5843,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "lazy_static", "perry-ffi", @@ -5859,7 +5859,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "perry-ffi", "uuid", @@ -5867,7 +5867,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "perry-ffi", "regex", @@ -5877,7 +5877,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "futures-util", "lazy_static", @@ -5889,7 +5889,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "brotli", "flate2", @@ -5898,7 +5898,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "dashmap", "once_cell", @@ -5907,7 +5907,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "anyhow", "perry-api-manifest", @@ -5925,7 +5925,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "anyhow", "perry-diagnostics", @@ -5937,7 +5937,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "anyhow", "base64", @@ -5970,14 +5970,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "aes 0.8.4", "aes-gcm", @@ -6069,14 +6069,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "anyhow", "perry-hir", @@ -6086,7 +6086,7 @@ dependencies = [ [[package]] name = "perry-types" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "anyhow", "thiserror 1.0.69", @@ -6094,14 +6094,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "base64", "itoa", @@ -6118,7 +6118,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "rand 0.8.6", "serde", @@ -6128,7 +6128,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "base64", "cairo-rs", @@ -6151,7 +6151,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "base64", "block2", @@ -6167,7 +6167,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "base64", "block2", @@ -6182,7 +6182,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1201" +version = "0.5.1202" [[package]] name = "perry-ui-test" @@ -6190,11 +6190,11 @@ version = "0.1.0" [[package]] name = "perry-ui-testkit" -version = "0.5.1201" +version = "0.5.1202" [[package]] name = "perry-ui-tvos" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "base64", "block2", @@ -6210,7 +6210,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "base64", "block2", @@ -6226,7 +6226,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "block2", "libc", @@ -6239,7 +6239,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "base64", "libc", @@ -6256,14 +6256,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "base64", "ed25519-dalek", @@ -6277,7 +6277,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1201" +version = "0.5.1202" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 9bdeee23fc..f7c9d711cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -301,7 +301,7 @@ strip = false codegen-units = 16 [workspace.package] -version = "0.5.1201" +version = "0.5.1202" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/crates/perry-codegen/src/expr/new_dynamic.rs b/crates/perry-codegen/src/expr/new_dynamic.rs index 37e8af4a43..f88a771ee5 100644 --- a/crates/perry-codegen/src/expr/new_dynamic.rs +++ b/crates/perry-codegen/src/expr/new_dynamic.rs @@ -212,8 +212,16 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } // Case 1 + 2: callee is statically a class. + // + // #5437: this is a NewDynamic-routed construct (`new (Foo)()`, + // `new ns.Foo()` for a namespace import) — the bare-`ast::Expr::Ident` + // HIR arm that appends class captures was NOT taken, so the captures + // are absent from `args`. Route to `lower_new_member_captured` so a + // function-nested capturing class fills its `__perry_cap_*` ctor + // params from the decl-site snapshot. No-op for non-capturing + // classes (no cap params to fill). if let Some(name) = try_static_class_name(callee.as_ref(), ctx) { - return lower_new(ctx, name, args); + return crate::lower_call::lower_new_member_captured(ctx, name, args); } // date-fns `constructFrom(date, value)`: @@ -511,6 +519,13 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // when it sees the original literal — read it back here and // dispatch to `lower_new` instead of the empty-object // fallback. + // + // #5437: this is a MEMBER-callee construct — the class's + // captures are NOT appended at this `new` site (the captured + // enclosing local is out of scope here), so route to + // `lower_new_member_captured` which fills the synthesized + // `__perry_cap_*` ctor params from the class's decl-site capture + // snapshot instead of binding them to `undefined`. if let Expr::PropertyGet { object, property } = callee.as_ref() { if let Expr::LocalGet(obj_id) = object.as_ref() { if let Some(class_name) = ctx @@ -519,7 +534,11 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { .and_then(|f| f.get(property)) .cloned() { - return lower_new(ctx, &class_name, args); + return crate::lower_call::lower_new_member_captured( + ctx, + &class_name, + args, + ); } } } diff --git a/crates/perry-codegen/src/expr/this_super_call.rs b/crates/perry-codegen/src/expr/this_super_call.rs index 99d4b71fb8..41255ba7e8 100644 --- a/crates/perry-codegen/src/expr/this_super_call.rs +++ b/crates/perry-codegen/src/expr/this_super_call.rs @@ -771,8 +771,28 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { })); } } - let saved_scope = - bind_inline_constructor_params(ctx, &parent_ctor.params, &lowered_args); + // #5437: fill the parent's cap params from its decl-site + // capture snapshot. The snapshot is authoritative for EVERY + // parent cap param — `bind_inline_constructor_params` consumes + // the values the child-forwarding loop appended above only to + // keep the user/cap tail-split aligned, then discards them in + // favour of `js_class_capture_value`. This matters when the + // captured local is out of scope at the super-call site (the + // forwarded value would be `undefined`); the snapshot still + // holds the correct decl-site capture. `backfill` sets + // `caps_absent_from_args=false` because the caps ARE present in + // `lowered_args` (this is not the caps-absent member-new path). + let parent_capture_fill = ctx + .class_ids + .get(effective_parent_name.as_str()) + .copied() + .map(crate::lower_call::CaptureFill::backfill); + let saved_scope = bind_inline_constructor_params( + ctx, + &parent_ctor.params, + &lowered_args, + parent_capture_fill, + ); ctx.class_stack.push(effective_parent_name.clone()); crate::stmt::lower_stmts(ctx, &parent_ctor.body)?; diff --git a/crates/perry-codegen/src/lower_call/mod.rs b/crates/perry-codegen/src/lower_call/mod.rs index 24e7b1f1a3..8a810f0a4c 100644 --- a/crates/perry-codegen/src/lower_call/mod.rs +++ b/crates/perry-codegen/src/lower_call/mod.rs @@ -102,7 +102,10 @@ pub(crate) use native::lower_native_method_call; // (codegen.rs / expr.rs / stmt.rs) so `crate::lower_call::lower_new` // etc. keep resolving after the split. pub(crate) use field_init::{apply_field_initializers_recursive, FieldInitMode}; -pub(crate) use new::{bind_inline_constructor_params, lower_new, restore_inline_constructor_scope}; +pub(crate) use new::{ + bind_inline_constructor_params, lower_new, lower_new_member_captured, + restore_inline_constructor_scope, CaptureFill, +}; // The derived-ctor no-super static-throw predicates (shared with the // standalone-ctor-symbol path in `codegen/method.rs`, which is the default // `new` lowering when `force_ctor_call` redirects construction to the shared diff --git a/crates/perry-codegen/src/lower_call/new.rs b/crates/perry-codegen/src/lower_call/new.rs index 3e8daa4624..d392cc3060 100644 --- a/crates/perry-codegen/src/lower_call/new.rs +++ b/crates/perry-codegen/src/lower_call/new.rs @@ -35,6 +35,7 @@ pub(crate) fn bind_inline_constructor_params( ctx: &mut FnCtx<'_>, params: &[Param], lowered_args: &[String], + capture_fill: Option, ) -> InlineConstructorScope { let saved = InlineConstructorScope { locals: ctx.locals.clone(), @@ -43,7 +44,8 @@ pub(crate) fn bind_inline_constructor_params( }; crate::codegen::arguments::add_arguments_mapped_boxes(params, &mut ctx.boxed_vars); - let values = inline_constructor_param_values(ctx, params, lowered_args); + let values = + inline_constructor_param_values_with_class(ctx, params, lowered_args, capture_fill); for (param, arg_val) in params.iter().zip(values.iter()) { let slot = ctx.func.alloca_entry(DOUBLE); if ctx.boxed_vars.contains(¶m.id) && param.arguments_object.is_none() { @@ -70,6 +72,62 @@ fn inline_constructor_param_values( ctx: &mut FnCtx<'_>, params: &[Param], lowered_args: &[String], +) -> Vec { + inline_constructor_param_values_with_class(ctx, params, lowered_args, None) +} + +/// Where a synthesized `__perry_cap_` param's value comes from when the +/// `new` site did not supply it as an appended arg. +#[derive(Clone, Copy)] +pub(crate) struct CaptureFill { + /// The constructing class's id, used to read its DECL-SITE capture + /// snapshot (`js_class_capture_value(cid, slot)`). + cid: u32, + /// `true` when `lowered_args` does NOT contain appended cap values — the + /// member-callee `new ns.C(...)` path. Then ALL `lowered_args` are user + /// args and EVERY cap param fills from the snapshot. `false` for the + /// bare-identifier `new C(...)` path, where the HIR appended the caps as + /// trailing args (tail-split keeps binding them); the snapshot then only + /// backfills a cap the HIR didn't append. + caps_absent_from_args: bool, +} + +impl CaptureFill { + /// Snapshot-only BACKFILL for a cap param the caller's args did not + /// supply: the `lowered_args` still carry their appended cap values + /// (tail-split keeps binding them). Used by the `super(...)` inline path, + /// which explicitly forwards parent caps as args. + pub(crate) fn backfill(cid: u32) -> Self { + CaptureFill { + cid, + caps_absent_from_args: false, + } + } +} + +/// As [`inline_constructor_param_values`], but fills a synthesized +/// `__perry_cap_` param that the `new` site did not supply from the +/// class's DECL-SITE capture snapshot (`js_class_capture_value(cid, slot)`) +/// instead of `undefined`. +/// +/// #5437 (W6): a member-callee construct `new ns.C()` of a function-nested +/// class that captured an enclosing local is statically routed to +/// `lower_new("C", [])` (the `#740` object-field-alias arm in +/// `expr/new_dynamic.rs`) — the captures are NOT appended as trailing args +/// (that only happens for the bare-identifier `new C()` HIR arm). With no +/// cap args the cap params bound to `undefined` and every method reading a +/// captured local saw `undefined`. The bare-`new C()` HIR-append cannot be +/// reused for `new ns.C()`: at the outer (member) `new` site the captured +/// enclosing local is OUT OF SCOPE, so `LocalGet(cid)` would itself read +/// `undefined`. The decl-site snapshot (registered at the class's +/// declaration by `js_class_register_capture_values`) holds the correct +/// captured values. `fill = None` keeps the prior `undefined` fill (no +/// behavior change for non-capturing/unknown classes). +fn inline_constructor_param_values_with_class( + ctx: &mut FnCtx<'_>, + params: &[Param], + lowered_args: &[String], + capture_fill: Option, ) -> Vec { let undef = double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); // Synthesized `__perry_cap_` capture params are always TRAILING @@ -80,25 +138,70 @@ fn inline_constructor_param_values( // bundle), so positional binding put the user arg into the capture // slot. Bind capture params from the args TAIL and user params from // the head. - let n_caps = params - .iter() - .filter(|p| { - p.name.starts_with("__perry_cap_") && !p.is_rest && p.arguments_object.is_none() + // + // #5437: when a decl-site snapshot is available (`capture_fill = Some`), + // EVERY synthesized cap param is filled from that snapshot — the + // authoritative decl-site capture value — regardless of whether the `new` + // site appended cap args. This is what closes W6: the bundle's bare-`new + // uS(...)` appends a MIS-BOXED `uw` cap (the multi-level capture chain + // materialized the wrong value), while `uS`'s decl-site snapshot holds the + // correct module-exports object; preferring the snapshot makes + // `new uw.SharedCacheControls` resolve. + // + // The split between user args and the (now ignored) appended cap args + // still matters for the USER params: + // - member-callee `new ns.C(...)`: caps are NOT appended, so ALL + // `lowered_args` are user args → `n_caps = 0`. (`new ns.C("ARG")` + // binds the user param to `"ARG"`, the cap to the snapshot.) + // - bare-identifier `new C(...)`: the HIR appended the caps as trailing + // args, so strip them (tail-split) to recover the leading user args; + // the stripped cap values are discarded in favour of the snapshot. + let caps_absent = matches!( + capture_fill, + Some(CaptureFill { + caps_absent_from_args: true, + .. }) - .count() - .min(lowered_args.len()); + ); + let n_caps = if caps_absent { + 0 + } else { + params + .iter() + .filter(|p| { + p.name.starts_with("__perry_cap_") && !p.is_rest && p.arguments_object.is_none() + }) + .count() + .min(lowered_args.len()) + }; let user_len = lowered_args.len() - n_caps; let (user_args, cap_args) = lowered_args.split_at(user_len); let mut cap_iter = cap_args.iter(); let mut out = Vec::with_capacity(params.len()); let mut visible_index = 0usize; + // The cap-slot index of the NEXT cap param: the index of the value in + // the decl-site snapshot (registered in `captures_vec` / cap-param + // declaration order, which is the same order they appear here). + let mut cap_slot = 0u32; for param in params { if param.name.starts_with("__perry_cap_") && !param.is_rest && param.arguments_object.is_none() { - out.push(cap_iter.next().cloned().unwrap_or_else(|| undef.clone())); + let slot = cap_slot; + cap_slot += 1; + // Consume (and discard) any appended cap arg so the tail stays + // aligned, then fill from the snapshot when one is registered. + let appended = cap_iter.next(); + out.push(match capture_fill { + Some(CaptureFill { cid, .. }) => ctx.block().call( + DOUBLE, + "js_class_capture_value", + &[(I32, &cid.to_string()), (I32, &slot.to_string())], + ), + None => appended.cloned().unwrap_or_else(|| undef.clone()), + }); } else if param.arguments_object.is_some() { out.push(pack_lowered_args_array(ctx, user_args)); } else if param.is_rest { @@ -228,6 +331,7 @@ fn call_local_constructor_symbol( class: &perry_hir::Class, obj_box: &str, lowered_args: &[String], + caps_absent_from_args: bool, ) -> Option { let ctor_method_name = format!("{}_constructor", class.name); let ctor_name = ctx @@ -275,8 +379,16 @@ fn call_local_constructor_symbol( } found }; + let capture_fill = ctx + .class_ids + .get(&class.name) + .copied() + .map(|cid| CaptureFill { + cid, + caps_absent_from_args, + }); let mut ctor_values = if let Some(params) = effective_params { - inline_constructor_param_values(ctx, ¶ms, lowered_args) + inline_constructor_param_values_with_class(ctx, ¶ms, lowered_args, capture_fill) } else { lowered_args.to_vec() }; @@ -315,6 +427,30 @@ fn call_local_constructor_symbol( /// enclosing function, not the constructor body) /// - No method dispatch or vtables — those land in Phase C.2/C.3 pub(crate) fn lower_new(ctx: &mut FnCtx<'_>, class_name: &str, args: &[Expr]) -> Result { + // Bare-identifier `new C(...)` path: the HIR `Expr::New` arm appended the + // class captures as trailing `LocalGet` args, so caps are PRESENT in + // `args`. + lower_new_impl(ctx, class_name, args, false) +} + +/// Member-callee `new ns.C(...)` construct (#5437): the captures were NOT +/// appended at the `new` site (the captured enclosing local is out of scope +/// there), so every synthesized `__perry_cap_*` ctor param fills from the +/// class's decl-site capture snapshot instead. All of `args` are USER args. +pub(crate) fn lower_new_member_captured( + ctx: &mut FnCtx<'_>, + class_name: &str, + args: &[Expr], +) -> Result { + lower_new_impl(ctx, class_name, args, true) +} + +fn lower_new_impl( + ctx: &mut FnCtx<'_>, + class_name: &str, + args: &[Expr], + caps_absent_from_args: bool, +) -> Result { // Built-in Web classes that the runtime provides constructors for. // These are checked BEFORE the ctx.classes lookup because the user // code may shadow the name — if they do, the class lookup below @@ -929,7 +1065,13 @@ pub(crate) fn lower_new(ctx: &mut FnCtx<'_>, class_name: &str, args: &[Expr]) -> // function ("value is not a function" on `new Chalk(...).red(...)`). // `js_ctor_return_override` returns `obj_box` for an `undefined`/ // primitive (base) return, so ordinary ctors are unaffected. - if let Some(ctor_ret) = call_local_constructor_symbol(ctx, class, &obj_box, &lowered_args) { + if let Some(ctor_ret) = call_local_constructor_symbol( + ctx, + class, + &obj_box, + &lowered_args, + caps_absent_from_args, + ) { let is_derived = class.extends.is_some() || class.extends_name.is_some() || class.native_extends.is_some() @@ -1077,10 +1219,17 @@ pub(crate) fn lower_new(ctx: &mut FnCtx<'_>, class_name: &str, args: &[Expr]) -> // `LocalGet` codegen doesn't return 0.0. Locals/local_types are // saved-and-restored around the whole inlined ctor flow below; we // mirror that here so the ctor params don't leak out of `new`. - let mut saved_scope_for_ctor = class - .constructor - .as_ref() - .map(|ctor| bind_inline_constructor_params(ctx, &ctor.params, &lowered_args)); + let ctor_capture_fill = ctx + .class_ids + .get(class_name) + .copied() + .map(|cid| CaptureFill { + cid, + caps_absent_from_args, + }); + let mut saved_scope_for_ctor = class.constructor.as_ref().map(|ctor| { + bind_inline_constructor_params(ctx, &ctor.params, &lowered_args, ctor_capture_fill) + }); if let Some(stop_at) = inherited_ctor_class.clone() { apply_field_initializers_recursive(ctx, class_name, FieldInitMode::UpToInclusive(stop_at))?; @@ -1144,8 +1293,19 @@ pub(crate) fn lower_new(ctx: &mut FnCtx<'_>, class_name: &str, args: &[Expr]) -> while let Some(pname) = parent_name { if let Some(parent_class) = ctx.classes.get(pname).copied() { if let Some(parent_ctor) = &parent_class.constructor { - let saved_scope = - bind_inline_constructor_params(ctx, &parent_ctor.params, &lowered_args); + // #5437: fill any unfilled parent cap param from the + // parent's decl-site capture snapshot. + let parent_capture_fill = + ctx.class_ids.get(pname).copied().map(|cid| CaptureFill { + cid, + caps_absent_from_args, + }); + let saved_scope = bind_inline_constructor_params( + ctx, + &parent_ctor.params, + &lowered_args, + parent_capture_fill, + ); // Push the parent class name so `this` inside the // parent ctor body resolves field names via the diff --git a/crates/perry/tests/issue_5437_member_new_captured_class.rs b/crates/perry/tests/issue_5437_member_new_captured_class.rs new file mode 100644 index 0000000000..4f7e298559 --- /dev/null +++ b/crates/perry/tests/issue_5437_member_new_captured_class.rs @@ -0,0 +1,140 @@ +//! Regression test for #5437 (W6): a member-callee construct +//! `new ns.C()` of a function-nested class that captured an enclosing-scope +//! local DROPPED the captures, so every method that read a captured local +//! saw `undefined`. +//! +//! Root: the bare-identifier `new C()` HIR arm appends the class captures as +//! trailing `LocalGet(cid)` args, but the member-callee form +//! (`new ns.C()`, where `ns` is an object literal whose field was a class +//! expression) is statically routed by codegen's `#740` object-field-alias +//! arm to `lower_new("C", [])` with NO captures appended. The synthesized +//! `__perry_cap_*` ctor params then bound to `undefined`. The +//! `LocalGet(cid)` append cannot be reused at the outer (member) `new` +//! site — the captured enclosing local is out of scope there — so the fix +//! fills the cap params from the class's DECL-SITE capture snapshot +//! (`js_class_capture_value(cid, slot)`, registered at the class declaration +//! by `js_class_register_capture_values`). +//! +//! Exercises (all member-new of a captured class): +//! - single object capture, no own ctor +//! - inheritance (sub + base both capturing) +//! - multiple captures (object + closure + string) + inheritance +//! - a USER ctor taking args alongside the captures (the user arg must NOT +//! be mis-split into the capture slot) +//! - a class declared in a NESTED function capturing two scopes' locals +//! +//! No `node_modules` dependency — the shapes are inline so it runs in CI. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +#[test] +fn member_new_of_captured_class_fills_captures_from_snapshot() { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.ts"); + let output = dir.path().join("main_bin"); + + std::fs::write( + &entry, + r#" +// Class names are unique per case (distinct classes in one module) so the +// shapes are isolated from one another. + +// 1. single object capture, no own ctor, member-new. +function mkMin() { + const o = { m: "OK" }; + class CMin { r() { return o.m; } } + return { CMin }; +} +const nsMin = mkMin(); +console.log("1=" + new nsMin.CMin().r()); + +// 2. inheritance: sub + base both capture, member-new of the sub. +function mkInherit() { + const o = { m: "OBJ" }; + class BaseInh { ro() { return o.m; } } + class SubInh extends BaseInh { rs() { return o.m + "!"; } } + return { BaseInh, SubInh }; +} +const nsInh = mkInherit(); +const sInh = new nsInh.SubInh(); +console.log("2=" + sInh.ro() + "," + sInh.rs()); + +// 3. multiple captures (object + closure + string) + inheritance. +function mkMulti() { + const a = { x: "AX" }; + const b = () => "BCLO"; + const c = "CSTR"; + class BaseMulti { ra() { return a.x; } rc() { return c; } } + class SubMulti extends BaseMulti { rb() { return b(); } ra2() { return a.x; } } + return { SubMulti }; +} +const nsMulti = mkMulti(); +const sMulti = new nsMulti.SubMulti(); +console.log("3=" + sMulti.ra() + "," + sMulti.rb() + "," + sMulti.rc() + "," + sMulti.ra2()); + +// 4. a USER ctor taking args alongside captures — the user arg must bind to +// the user param and the capture must come from the snapshot (the tail-split +// must NOT pull the lone user arg into the capture slot). +function mkUserCtor() { + const cap = { k: "CAP" }; + class CUser { + n: string; + constructor(n: string) { this.n = n; } + get() { return this.n + "/" + cap.k; } + } + return { CUser }; +} +const nsUser = mkUserCtor(); +console.log("4=" + new nsUser.CUser("ARG").get()); + +// 5. class declared in a NESTED function, capturing two scopes' locals. +function outer() { + const tag = "OUTER"; + function inner() { + const local = { v: "INNER" }; + class CNest { both() { return tag + ":" + local.v; } } + return { CNest }; + } + return inner(); +} +const nsNest = outer(); +console.log("5=" + new nsNest.CNest().both()); +"#, + ) + .expect("write entry"); + + let compile = Command::new(perry_bin()) + .current_dir(dir.path()) + .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).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) + ); + let stdout = String::from_utf8_lossy(&run.stdout); + assert_eq!( + stdout, "1=OK\n2=OBJ,OBJ!\n3=AX,BCLO,CSTR,AX\n4=ARG/CAP\n5=OUTER:INNER\n", + "member-new of a function-nested capturing class must fill the \ + synthesized __perry_cap_* params from the decl-site snapshot (#5437)" + ); +}