diff --git a/crates/perry-codegen/src/lower_call/native_table/node_core/module_sea_tls_test.rs b/crates/perry-codegen/src/lower_call/native_table/node_core/module_sea_tls_test.rs index da16c08b47..85579fc74d 100644 --- a/crates/perry-codegen/src/lower_call/native_table/node_core/module_sea_tls_test.rs +++ b/crates/perry-codegen/src/lower_call/native_table/node_core/module_sea_tls_test.rs @@ -8,7 +8,11 @@ pub(crate) const NODE_CORE_MODULE_SEA_TLS_TEST_ROWS: &[NativeModSig] = &[ has_receiver: false, method: "createRequire", class_filter: None, - runtime: "js_module_create_require", + // #6644: the devirt wrapper arms the nm/submod install-all hooks (the + // returned require closure resolves builtins from a runtime string, so + // codegen can't emit precise per-module installs). Mirrors + // js_process_get_builtin_module_devirt. + runtime: "js_module_create_require_devirt", args: &[NA_F64], ret: NR_F64, }, diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index 682cec98b3..46f071a408 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -1397,6 +1397,9 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { // #5389 Tier 2: synchronous ambient require(spec) resolution — the codegen // fallthrough when a computed require() didn't const-fold to a compiled target. module.declare_function("js_module_ambient_require_apply", DOUBLE, &[DOUBLE]); + // #6644: `module.createRequire(...)` devirt entry — arms the nm/submod + // install-all hooks before delegating (see js_process_get_builtin_module_devirt). + module.declare_function("js_module_create_require_devirt", DOUBLE, &[DOUBLE]); // Non-throwing global read for `typeof ` + global read-modify- // write for `i++`/`i--` on a sloppy implicit global (#3575). module.declare_function("js_global_get_optional", DOUBLE, &[DOUBLE]); diff --git a/crates/perry-hir/src/lower/context.rs b/crates/perry-hir/src/lower/context.rs index 443c5e4aff..702bc587b5 100644 --- a/crates/perry-hir/src/lower/context.rs +++ b/crates/perry-hir/src/lower/context.rs @@ -169,6 +169,7 @@ impl LoweringContext { next_anon_shape_id: 0, class_method_return_types: Vec::new(), class_captures: Vec::new(), + body_class_expr_captures: Vec::new(), let_class_aliases: Vec::new(), global_this_aliases: HashSet::new(), prototype_aliases: HashMap::new(), diff --git a/crates/perry-hir/src/lower/expr_function.rs b/crates/perry-hir/src/lower/expr_function.rs index ef8d0732fc..842ba9dfc7 100644 --- a/crates/perry-hir/src/lower/expr_function.rs +++ b/crates/perry-hir/src/lower/expr_function.rs @@ -193,6 +193,10 @@ pub(super) fn lower_arrow(ctx: &mut LoweringContext, arrow: &ast::ArrowExpr) -> // #4101: retain source text for `fn.toString()`. capture_function_source(ctx, func_id, &arrow.span, arrow.is_async); let scope_mark = ctx.enter_scope(); + // #6604: truncate mark for capturing class expressions recorded while + // lowering THIS arrow — placed at scope entry so default-param + // expressions are covered too; see the truncate below the body match. + let body_class_expr_captures_mark = ctx.body_class_expr_captures.len(); let strict = ctx.current_strict_mode() || match &*arrow.body { ast::BlockStmtOrExpr::BlockStmt(block) => { @@ -377,6 +381,17 @@ pub(super) fn lower_arrow(ctx: &mut LoweringContext, arrow: &ast::ArrowExpr) -> vec![Stmt::Return(Some(return_expr))] } }; + // #6604: a capturing class expression in an EXPRESSION-bodied arrow + // (`x => new (class { … })(x)`) records a body-class-expr entry that no + // body twin will drain (the block-bodied arm drains its own inside + // `lower_fn_body_block_stmt`; default-param entries are self-truncated by + // `get_param_default`). Truncate on exit so the entry — whose ids are + // only meaningful in the arrow's own local numbering — never leaks into + // the ENCLOSING body's refresh statements. Nothing is lost: a + // single-expression body has no later statements that could reassign the + // class's captured locals. + ctx.body_class_expr_captures + .truncate(body_class_expr_captures_mark); ctx.current_strict = outer_strict; // Prepend destructuring statements to body @@ -579,6 +594,13 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul fn_expr.function.is_async, ); let scope_mark = ctx.enter_scope(); + // #6604: capturing class EXPRESSIONS lowered in THIS function register + // from here for the end-of-body refresh (twin of + // `lower_fn_body_block_stmt`); the mark sits at scope entry so nothing + // recorded for this function can leak into the enclosing body. + // (Default-param entries never reach the drain — `get_param_default` + // self-truncates.) + let body_class_expr_captures_mark = ctx.body_class_expr_captures.len(); // A plain function has its own `arguments` object, so a direct `eval` // inside its body may reference `arguments` even when the function sits // in a class field initializer. Cleared here, restored at the end. @@ -1221,6 +1243,26 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul } } } + // #6604: capturing class EXPRESSIONS lowered directly in this body — + // the semver/esbuild `__commonJS` wrapper shape `var Comparator = + // class _Comparator { … }; …; var parseOptions = require_…()` — join + // the same refresh machinery as class declarations, so the snapshot + // tracks captured vars assigned AFTER the class. Recorded by + // `lower_class_expr` under the RESOLVED registration name; see the + // block-body twin (`lower_fn_body_block_stmt`) for why no + // `append_new_args_stmt` pass runs for expressions. + for (cname, ids) in ctx + .body_class_expr_captures + .split_off(body_class_expr_captures_mark) + { + let captures: Vec = ids.iter().map(|id| Expr::LocalGet(*id)).collect(); + let re_reg = Stmt::Expr(Expr::RegisterClassCaptures { + class_name: cname, + captures, + }); + re_reg_capsets.push((re_reg.clone(), ids.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 diff --git a/crates/perry-hir/src/lower/lower_expr/arm_class.rs b/crates/perry-hir/src/lower/lower_expr/arm_class.rs index 623a231a4a..2b8ed8bd1f 100644 --- a/crates/perry-hir/src/lower/lower_expr/arm_class.rs +++ b/crates/perry-hir/src/lower/lower_expr/arm_class.rs @@ -186,6 +186,23 @@ pub(crate) fn lower_class_expr( // expressions inside a function body (factories like effect's // `make()`), which produce a distinct class object per call. let at_module_top = ctx.scope_depth == 0 && ctx.inside_block_scope == 0; + // #6604: register this capturing class EXPRESSION with the enclosing + // body's end-of-body capture-refresh machinery (#6037/#6052), which + // previously scanned class DECLARATION statements only. Without the + // refresh, a captured var assigned AFTER the class expression (semver's + // `var Comparator = class _Comparator { … }; …; var parseOptions = + // require_parse_options()`) stays `undefined` in the decl-site snapshot, + // and dynamic construction of the escaped class value replays that stale + // snapshot. Recording the RESOLVED registration name here (post + // rename/dedup) sidesteps re-deriving it from the AST at body end. Module + // top is skipped — module-level ids are stripped from capture lists by + // `filter_module_level_captures`, so there is nothing to refresh. + if !at_module_top && !captured_args.is_empty() { + if let Some(ids) = ctx.lookup_class_captures(&synthetic_name) { + ctx.body_class_expr_captures + .push((synthetic_name.clone(), ids.to_vec())); + } + } if !at_module_top && (!named_statics.is_empty() || !static_symbol_registrations.is_empty() diff --git a/crates/perry-hir/src/lower/lowering_context.rs b/crates/perry-hir/src/lower/lowering_context.rs index ad12c9cd46..00d39c08fa 100644 --- a/crates/perry-hir/src/lower/lowering_context.rs +++ b/crates/perry-hir/src/lower/lowering_context.rs @@ -777,6 +777,23 @@ pub struct LoweringContext { /// here so the `Expr::New { class_name }` lowering can append /// `LocalGet(id)` for each captured id at every construction site. pub(crate) class_captures: Vec<(String, Vec)>, + /// #6604: capturing class EXPRESSIONS lowered while the CURRENT function + /// body is being lowered — `(registration_name, captured_outer_ids)`, + /// pushed by `lower_class_expr` (skipped at module top, where + /// `filter_module_level_captures` already strips module-level ids). The + /// #6037/#6052 end-of-body capture-refresh machinery previously scanned + /// only `ast::Decl::Class` DECLARATION statements, so `var Comparator = + /// class _Comparator { … }` (semver's shape in every bundled class file) + /// never got refresh statements: a captured var assigned AFTER the class + /// (`var parseOptions = require_parse_options()` at file bottom) stayed + /// `undefined` in the snapshot forever, and dynamic construction of the + /// escaped class value threw "value is not a function" at pi-native init. + /// Both body twins (`lower_fn_body_block_stmt` and `lower_fn_expr`) mark + /// this list's length at entry and drain their own suffix at body end; + /// every other body-lowering path must truncate back to its entry mark so + /// entries (whose ids are only meaningful in THEIR OWN function scope) + /// never leak into an enclosing body's refresh statements. + pub(crate) body_class_expr_captures: Vec<(String, Vec)>, /// Issue #740: `let_name → class_name` for `let/const/var = ` /// initializers. Lets `Expr::New { class_name }` (where `class_name` is /// the source-level identifier of an alias binding) resolve to the diff --git a/crates/perry-hir/src/lower_decl/block.rs b/crates/perry-hir/src/lower_decl/block.rs index fe14061a13..e72b1e1903 100644 --- a/crates/perry-hir/src/lower_decl/block.rs +++ b/crates/perry-hir/src/lower_decl/block.rs @@ -1126,6 +1126,10 @@ pub fn lower_fn_body_block_stmt( // Used by the Phase 1.6 forward `let`/`const` pre-registration so a const // that shadows an outer binding still gets a fresh this-body local. let body_entry_locals_len = ctx.locals.len(); + // #6604: entries pushed while lowering THIS body belong to THIS body's + // capture-refresh pass (their ids are this function's locals); drain the + // suffix at body end, truncate on the error path so nothing leaks upward. + let body_class_expr_captures_mark = ctx.body_class_expr_captures.len(); let hoisted_var_slots = predefine_var_bindings_in_function_body(ctx, block); // Phase 1: pre-define hoisted FnDecl locals so forward references in @@ -1224,6 +1228,8 @@ pub fn lower_fn_body_block_stmt( let mut body = match lower_block_stmt(ctx, block) { Ok(body) => body, Err(err) => { + ctx.body_class_expr_captures + .truncate(body_class_expr_captures_mark); ctx.current_strict = parent_strict; ctx.forward_class_names = saved_forward_class_names; ctx.forward_class_decl_depth = saved_forward_class_decl_depth; @@ -1291,6 +1297,30 @@ pub fn lower_fn_body_block_stmt( } } } + // #6604: capturing class EXPRESSIONS lowered directly in this body + // (`var Comparator = class _Comparator { … }`, argument-position + // `register(class { … })`, …) need the same assignment-tracking + // refresh as class declarations: semver assigns the captured + // `parseOptions`/`debug` vars AFTER the class, so the snapshot (and + // the per-evaluation `__perry_ctor_caps` array, whose stale-undefined + // slots the runtime construct path now backfills from this snapshot) + // must be re-registered with the live values. Entries were recorded + // by `lower_class_expr` under the RESOLVED registration name; no + // `append_new_args_stmt` pass — a class expression's construct sites + // are either static (binding-name `new C()`, live locals appended at + // the site) or dynamic (replayed through the snapshot). + for (cname, ids) in ctx + .body_class_expr_captures + .split_off(body_class_expr_captures_mark) + { + let captures: Vec = ids.iter().map(|id| Expr::LocalGet(*id)).collect(); + let re_reg = Stmt::Expr(Expr::RegisterClassCaptures { + class_name: cname, + captures, + }); + re_reg_capsets.push((re_reg.clone(), ids.iter().copied().collect())); + re_regs.push(re_reg); + } if !re_regs.is_empty() { // Audit P0-B: the decl-site snapshot is authoritative at // construct time, so keep it TRACKING same-body assignments — diff --git a/crates/perry-hir/src/lower_patterns.rs b/crates/perry-hir/src/lower_patterns.rs index 83422bcdc0..3d1a01de5c 100644 --- a/crates/perry-hir/src/lower_patterns.rs +++ b/crates/perry-hir/src/lower_patterns.rs @@ -1454,7 +1454,19 @@ pub(crate) fn get_param_default(ctx: &mut LoweringContext, pat: &ast::Pat) -> Re } } ast::Pat::Assign(assign) => { + // #6604: a capturing class EXPRESSION used as a default value + // (`function f(C = class { … }) {}`) must NOT register with the + // enclosing body's end-of-body capture-refresh machinery: param + // defaults are lowered BEFORE the callee's own body twin takes + // its list mark (fn-decl / ctor / method param sites), so the + // entry would be drained by the WRONG (enclosing) body and its + // ids interpreted in the wrong function's local numbering. + // Truncate whatever this default expression recorded — the + // default is re-evaluated at every call anyway, so its + // evaluation-time snapshot is per-call fresh. + let mark = ctx.body_class_expr_captures.len(); let default_expr = lower_expr(ctx, &assign.right)?; + ctx.body_class_expr_captures.truncate(mark); Ok(Some(default_expr)) } _ => Ok(None), diff --git a/crates/perry-runtime/src/module_require.rs b/crates/perry-runtime/src/module_require.rs index 242a14ba46..f10d86c2c2 100644 --- a/crates/perry-runtime/src/module_require.rs +++ b/crates/perry-runtime/src/module_require.rs @@ -105,7 +105,13 @@ fn supported_require_builtin(specifier: &str) -> Option<&str> { // allowlist (they have runtime registry buckets + dispatch, but // `require('tls')` etc. via createRequire was rejected as "package/file"). | "dgram" | "domain" | "inspector" | "inspector/promises" | "repl" - | "sqlite" => Some(name), + | "sqlite" + // #6644: implemented as a node_submodules spec (real pub/sub channel + // registry in node_submodules/diagnostics.rs) but missing here, so + // `require('node:diagnostics_channel')` through createRequire (the + // esbuild banner shim in any ESM bundle of CJS deps — lru-cache's node + // build in the pi bundle) was rejected as "package/file". + | "diagnostics_channel" => Some(name), _ => None, } } @@ -123,6 +129,19 @@ fn require_builtin_value(module_name: &str) -> f64 { ) }; } + // #6644: diagnostics_channel lives in the node_submodules registry (not a + // native-module dispatch bucket); route it there like timers/promises so + // `require('diagnostics_channel')` / `require('node:diagnostics_channel')` + // return the real channel/subscribe/tracingChannel exports instead of an + // empty native-module namespace. + if module_name == "diagnostics_channel" { + return unsafe { + crate::node_submodules::js_node_submodule_namespace( + b"diagnostics_channel".as_ptr(), + "diagnostics_channel".len() as u32, + ) + }; + } crate::object::native_module_get_builtin_module_value(module_name) } @@ -223,6 +242,24 @@ pub extern "C" fn js_module_create_require(filename_or_url: f64) -> f64 { make_require(undefined()) } +/// Devirt codegen entry for `module.createRequire(...)` (#6644). The require +/// closure it returns resolves builtins from a RUNTIME string, so — exactly like +/// `js_process_get_builtin_module_devirt` — codegen could not emit the precise +/// per-module dispatch installs. Arm both install-all hooks so a dynamically +/// required module's methods (`require('node:diagnostics_channel').channel(...)`, +/// `require('tls').connect(...)`) can dispatch. Codegen targets THIS symbol, so +/// the all-buckets `js_nm_install_all` / `js_node_submod_install_all` are +/// referenced only by programs whose source actually calls `createRequire`; the +/// plain `js_module_create_require` (reachable from the always-pinned ambient +/// require keepalives via the module dispatch bucket) stays free of that +/// reference, preserving per-module stripping. +#[no_mangle] +pub extern "C" fn js_module_create_require_devirt(filename_or_url: f64) -> f64 { + crate::object::js_nm_enable_install_all(); + crate::node_submodules::js_node_submod_enable_install_all(); + js_module_create_require(filename_or_url) +} + /// Next.js wall 54: registry mapping an AOT-compiled CJS module's absolute /// source path to its evaluated `module.exports`, so a RUNTIME /// `require(absolutePath.js)` (Next.js / turbopack load page + chunk modules by diff --git a/crates/perry-runtime/src/process.rs b/crates/perry-runtime/src/process.rs index fa80b0c35f..7edd036b95 100644 --- a/crates/perry-runtime/src/process.rs +++ b/crates/perry-runtime/src/process.rs @@ -102,13 +102,49 @@ pub(crate) fn is_function_value(value: f64) -> bool { pub(crate) fn supported_builtin_module_name(name: &str) -> Option<&str> { match name { - "assert" | "assert/strict" | "async_hooks" | "buffer" | "child_process" | "cluster" - | "console" | "constants" | "crypto" | "dns" | "dns/promises" | "events" | "fs" - | "http" | "http2" | "https" | "module" | "net" | "os" | "path" | "perf_hooks" - | "process" | "punycode" | "querystring" | "readline" | "readline/promises" | "sea" - | "stream" | "stream/promises" | "string_decoder" | "sys" | "test" | "test/reporters" - | "timers" | "timers/promises" | "tty" | "url" | "util" | "util/types" | "vm" - | "worker_threads" | "zlib" => Some(name), + "assert" + | "assert/strict" + | "async_hooks" + | "buffer" + | "child_process" + | "cluster" + | "console" + | "constants" + | "crypto" + | "diagnostics_channel" + | "dns" + | "dns/promises" + | "events" + | "fs" + | "http" + | "http2" + | "https" + | "module" + | "net" + | "os" + | "path" + | "perf_hooks" + | "process" + | "punycode" + | "querystring" + | "readline" + | "readline/promises" + | "sea" + | "stream" + | "stream/promises" + | "string_decoder" + | "sys" + | "test" + | "test/reporters" + | "timers" + | "timers/promises" + | "tty" + | "url" + | "util" + | "util/types" + | "vm" + | "worker_threads" + | "zlib" => Some(name), _ => None, } } diff --git a/crates/perry-runtime/src/process/node_module.rs b/crates/perry-runtime/src/process/node_module.rs index cf7e04b268..4f833ba9f7 100644 --- a/crates/perry-runtime/src/process/node_module.rs +++ b/crates/perry-runtime/src/process/node_module.rs @@ -856,6 +856,17 @@ pub extern "C" fn js_process_get_builtin_module(id: f64) -> f64 { ) }; } + // #6644: diagnostics_channel is a node_submodules spec, not a native-module + // dispatch bucket — route it there (mirrors createRequire's + // require_builtin_value). + if module_name == "diagnostics_channel" { + return unsafe { + crate::node_submodules::js_node_submodule_namespace( + b"diagnostics_channel".as_ptr(), + "diagnostics_channel".len() as u32, + ) + }; + } crate::object::native_module_get_builtin_module_value(module_name) } diff --git a/crates/perry/tests/createrequire_builtin_modules.rs b/crates/perry/tests/createrequire_builtin_modules.rs index 3bb7c2918a..d6c0a5eb01 100644 --- a/crates/perry/tests/createrequire_builtin_modules.rs +++ b/crates/perry/tests/createrequire_builtin_modules.rs @@ -50,6 +50,67 @@ fn compile_and_run(dir: &std::path::Path, source: &str) -> String { String::from_utf8_lossy(&run.stdout).into_owned() } +/// #6644 (pi wall #3): `require('node:diagnostics_channel')` through +/// `createRequire` threw `ERR_PERRY_UNSUPPORTED_CREATE_REQUIRE` — the module is +/// implemented as a node_submodules spec (real pub/sub channel registry) but was +/// missing from the `supported_require_builtin` allowlist and never routed to +/// `js_node_submodule_namespace`. lru-cache's node build requires it through the +/// esbuild createRequire banner shim, so any ESM bundle of CJS deps hit this. +/// Covers both the `node:`-prefixed and bare spellings, real pub/sub between +/// handles from each spelling, the tracingChannel shape, another +/// `node:`-prefixed builtin (`node:path`) through the same require, and the +/// `process.getBuiltinModule` sibling path. +#[test] +fn createrequire_resolves_diagnostics_channel_and_node_prefixed_builtins() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +import { createRequire } from "node:module"; +const require = createRequire(import.meta.url); + +const dc1 = require("node:diagnostics_channel"); +const dc2 = require("diagnostics_channel"); +console.log("shapes:", typeof dc1.channel, typeof dc1.subscribe, typeof dc1.unsubscribe, typeof dc1.hasSubscribers, typeof dc1.tracingChannel); +console.log("fresh:", dc1.hasSubscribers("never-subscribed")); + +const seen: string[] = []; +const onMsg = (message: any, name: string) => { seen.push(`${name}:${JSON.stringify(message)}`); }; +dc1.subscribe("pi.test", onMsg); +console.log("subscribed:", dc2.hasSubscribers("pi.test")); +const ch = dc2.channel("pi.test"); +console.log("channel.hasSubscribers:", ch.hasSubscribers); +ch.publish({ n: 1 }); +dc1.channel("pi.test").publish({ n: 2 }); +console.log("seen:", seen.join(" | ")); +console.log("unsubscribe:", dc2.unsubscribe("pi.test", onMsg)); +console.log("after:", dc1.hasSubscribers("pi.test")); + +const tc = dc1.tracingChannel("pi.trace"); +console.log("tracing:", typeof tc.traceSync, typeof tc.tracePromise, typeof tc.traceCallback, tc.hasSubscribers); + +const path = require("node:path"); +console.log("path:", typeof path.join, path.join("a", "b")); + +const gbm = process.getBuiltinModule("node:diagnostics_channel"); +console.log("getBuiltinModule:", typeof gbm.channel, gbm.hasSubscribers("z")); +"#, + ); + assert_eq!( + stdout, + "shapes: function function function function function\n\ + fresh: false\n\ + subscribed: true\n\ + channel.hasSubscribers: true\n\ + seen: pi.test:{\"n\":1} | pi.test:{\"n\":2}\n\ + unsubscribe: true\n\ + after: false\n\ + tracing: function function function false\n\ + path: function a/b\n\ + getBuiltinModule: function false\n" + ); +} + #[test] fn createrequire_resolves_tls_and_other_implemented_builtins() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/test-parity/node-suite/object/class-expr-capture-refresh.ts b/test-parity/node-suite/object/class-expr-capture-refresh.ts new file mode 100644 index 0000000000..bf414e6687 --- /dev/null +++ b/test-parity/node-suite/object/class-expr-capture-refresh.ts @@ -0,0 +1,155 @@ +// #6604: a class EXPRESSION capturing an enclosing-function var that is +// assigned AFTER the class in source order (semver's ubiquitous +// `var Comparator = class _Comparator { … }; …; var parseOptions = +// require_parse_options()` CJS shape) must see the LIVE value when the class +// value escapes and is constructed DYNAMICALLY. The #6037/#6052 end-of-body +// capture-refresh machinery previously covered class DECLARATIONS only; the +// stale declaration-time snapshot made the captured var read `undefined` +// forever ("TypeError: value is not a function" at pi-native init). + +// Shape 1: var-assigned NAMED class expression. +var wrapVar = function () { + var C = class _C { + constructor(x: string) { + (this as any).v = helper(x); + } + }; + var out = { K: C }; + var helper = function (s: string) { + return "var:" + s; + }; + return out; +}; +console.log("named var:", new (wrapVar().K)("a").v); + +// Shape 2: let / const anonymous class expressions. +var wrapLet = function () { + let C = class { + constructor(x: string) { + (this as any).v = helperL(x); + } + }; + var box = { K: C }; + var helperL = function (s: string) { + return "let:" + s; + }; + return box; +}; +var wrapConst = function () { + const C = class { + constructor(x: string) { + (this as any).v = helperC(x); + } + }; + var box = { K: C }; + var helperC = function (s: string) { + return "const:" + s; + }; + return box; +}; +console.log("let:", new (wrapLet().K)("b").v); +console.log("const:", new (wrapConst().K)("c").v); + +// Shape 3: class expression in ARGUMENT position (no binding statement). +var registry: any = {}; +var register = function (name: string, cls: any) { + registry[name] = cls; +}; +var wrapArg = function () { + register( + "K", + class { + constructor(x: string) { + (this as any).v = helperA(x); + } + } + ); + var helperA = function (s: string) { + return "arg:" + s; + }; +}; +wrapArg(); +console.log("arg:", new registry.K("d").v); + +// Shape 4: the esbuild `__commonJS` semver comparator.js layout — class +// expression + `module.exports` + trailing requires, constructed dynamically +// from another wrapper at init time. +var __commonJS = (cb: any, mod: any = undefined) => + function __require() { + return ( + mod || (0, cb[Object.keys(cb)[0]])((mod = { exports: {} }).exports, mod), + mod.exports + ); + }; +var require_parse_options = __commonJS({ + "parse-options.js"(exports: any, module: any) { + "use strict"; + var emptyOpts = Object.freeze({}); + module.exports = (options: any) => + options && typeof options === "object" ? options : emptyOpts; + }, +}); +var require_comparator = __commonJS({ + "comparator.js"(exports: any, module: any) { + "use strict"; + var ANY = Symbol("SemVer ANY"); + var Comparator = class _Comparator { + static get ANY() { + return ANY; + } + value: string; + loose: boolean; + constructor(comp: any, options?: any) { + options = parseOptions(options); + if (comp instanceof _Comparator) { + comp = (comp as any).value; + } + this.loose = !!options.loose; + this.value = String(comp); + } + toString() { + return this.value; + } + }; + module.exports = Comparator; + var parseOptions = require_parse_options(); + }, +}); +var Comparator = require_comparator(); +var minimum = [new Comparator(">=0.0.0-0")]; +var c2 = new Comparator(">=1.2.3", { loose: true }); +console.log("semver:", String(minimum[0]), c2.value, c2.loose); + +// Refresh must keep tracking REASSIGNMENTS of the captured var, not just its +// first initialization. +var wrapReassign = function () { + var C = class { + constructor() { + (this as any).v = tag(); + } + }; + var out = { K: C }; + var tag = function () { + return "first"; + }; + tag = function () { + return "second"; + }; + return out; +}; +console.log("reassign:", new (wrapReassign().K)().v); + +// Per-evaluation isolation of the NORMAL (assigned-before-class) shape must +// be preserved: two factory calls, two classes, distinct captured values. +var mk = function (t: string) { + var prefix = "p" + t; + var C = class { + constructor() { + (this as any).v = t + ":" + prefix; + } + }; + return C; +}; +var A = mk("x"); +var B = mk("y"); +console.log("multi-eval:", new A().v, new B().v); diff --git a/tests/test_class_expr_capture_refresh_6604.sh b/tests/test_class_expr_capture_refresh_6604.sh new file mode 100755 index 0000000000..c4591c86c8 --- /dev/null +++ b/tests/test_class_expr_capture_refresh_6604.sh @@ -0,0 +1,173 @@ +#!/bin/bash +# Regression (#6604): a class EXPRESSION capturing an enclosing-function var +# assigned AFTER the class in source order — semver's shape in every bundled +# class file: +# +# var Comparator = class _Comparator { +# constructor(comp, options) { options = parseOptions(options); ... } +# }; +# module.exports = Comparator; +# var parseOptions = require_parse_options(); // assigned AFTER the class +# +# Correct JS: the ctor closes over the live binding; by the time anything +# constructs a Comparator the wrapper has completed and the binding holds the +# function. Pre-fix, the #6037/#6052 end-of-body capture-refresh machinery +# scanned only `ast::Decl::Class` DECLARATION statements, so no refresh was +# ever emitted for the expression shape: DYNAMIC construction of the escaped +# class value replayed the stale declaration-time snapshot (captured var = +# undefined forever) and threw "TypeError: value is not a function" at +# pi-native init (wall #2 of the pi coding-agent bring-up, behind #6593). +# +# Covers: var/let/const-assigned named and anonymous class expressions, +# argument-position class expressions, the esbuild `__commonJS` semver layout, +# captured-var reassignment tracking, and multi-evaluation factory isolation +# for the normal (assigned-before-class) shape. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PERRY="$SCRIPT_DIR/../target/release/perry" +[ ! -f "$PERRY" ] && PERRY="$SCRIPT_DIR/../target/debug/perry" +if [ ! -f "$PERRY" ]; then + echo "SKIP: perry binary not found (build with cargo build --release)" + exit 0 +fi +if ! command -v cc >/dev/null 2>&1; then + echo "SKIP: cc not available" + exit 0 +fi + +TMPDIR=$(mktemp -d) +trap "rm -rf $TMPDIR" EXIT + +cat > "$TMPDIR/main.js" << 'EOF' +// Shape 1: var-assigned NAMED class expression, captured var assigned after. +var wrapVar = function () { + var C = class _C { + constructor(x) { this.v = helper(x); } + }; + var out = { K: C }; + var helper = function (s) { return "var:" + s; }; + return out; +}; +console.log(new (wrapVar().K)("a").v); + +// Shape 2: let / const anonymous class expressions. +var wrapLet = function () { + let C = class { + constructor(x) { this.v = helperL(x); } + }; + var box = { K: C }; + var helperL = function (s) { return "let:" + s; }; + return box; +}; +var wrapConst = function () { + const C = class { + constructor(x) { this.v = helperC(x); } + }; + var box = { K: C }; + var helperC = function (s) { return "const:" + s; }; + return box; +}; +console.log(new (wrapLet().K)("b").v); +console.log(new (wrapConst().K)("c").v); + +// Shape 3: class expression in ARGUMENT position (no binding statement). +var registry = {}; +var register = function (name, cls) { registry[name] = cls; }; +var wrapArg = function () { + register("K", class { + constructor(x) { this.v = helperA(x); } + }); + var helperA = function (s) { return "arg:" + s; }; +}; +wrapArg(); +console.log(new registry.K("d").v); + +// Shape 4: the esbuild __commonJS semver comparator.js layout. +var __commonJS = (cb, mod) => function __require() { + return mod || (0, cb[Object.keys(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; +}; +var require_parse_options = __commonJS({ + "parse-options.js"(exports, module) { + "use strict"; + var emptyOpts = Object.freeze({}); + module.exports = (options) => + options && typeof options === "object" ? options : emptyOpts; + }, +}); +var require_comparator = __commonJS({ + "comparator.js"(exports, module) { + "use strict"; + var ANY = Symbol("SemVer ANY"); + var Comparator = class _Comparator { + static get ANY() { return ANY; } + constructor(comp, options) { + options = parseOptions(options); + if (comp instanceof _Comparator) { comp = comp.value; } + this.loose = !!options.loose; + this.value = String(comp); + } + toString() { return this.value; } + }; + module.exports = Comparator; + var parseOptions = require_parse_options(); + }, +}); +var Comparator = require_comparator(); +var minimum = [new Comparator(">=0.0.0-0")]; +var c2 = new Comparator(">=1.2.3", { loose: true }); +console.log(String(minimum[0]), c2.value, c2.loose); + +// Refresh must track REASSIGNMENT of a captured var, not just its init. +var wrapReassign = function () { + var C = class { + constructor() { this.v = tag(); } + }; + var out = { K: C }; + var tag = function () { return "first"; }; + tag = function () { return "second"; }; + return out; +}; +console.log(new (wrapReassign().K)().v); + +// Per-evaluation isolation of the normal (assigned-before-class) shape. +var mk = function (t) { + var prefix = "p" + t; + var C = class { + constructor() { this.v = t + ":" + prefix; } + }; + return C; +}; +var A = mk("x"); +var B = mk("y"); +console.log(new A().v, new B().v); +EOF + +cd "$TMPDIR" +COMPILE_OUTPUT=$(PERRY_NO_AUTO_OPTIMIZE=1 "$PERRY" compile main.js -o test_bin --no-cache 2>&1) || { + echo "FAIL: compile error" + echo "$COMPILE_OUTPUT" | tail -20 + exit 1 +} + +RUN_OUTPUT=$(./test_bin 2>&1) +EXPECTED="var:a +let:b +const:c +arg:d +>=0.0.0-0 >=1.2.3 true +second +x:px y:py" + +if [ "$RUN_OUTPUT" = "$EXPECTED" ]; then + echo "PASS" + exit 0 +fi + +echo "FAIL: class-expression capture snapshot stale (vars assigned after the class)" +echo "Expected:" +echo "$EXPECTED" +echo "Got:" +echo "$RUN_OUTPUT" +exit 1