Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/perry-hir/src/lower/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
42 changes: 42 additions & 0 deletions crates/perry-hir/src/lower/expr_function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<Expr> = 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
Expand Down
17 changes: 17 additions & 0 deletions crates/perry-hir/src/lower/lower_expr/arm_class.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
17 changes: 17 additions & 0 deletions crates/perry-hir/src/lower/lowering_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<LocalId>)>,
/// #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<LocalId>)>,
/// Issue #740: `let_name → class_name` for `let/const/var <name> = <ClassRef>`
/// initializers. Lets `Expr::New { class_name }` (where `class_name` is
/// the source-level identifier of an alias binding) resolve to the
Expand Down
30 changes: 30 additions & 0 deletions crates/perry-hir/src/lower_decl/block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<Expr> = 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 —
Expand Down
12 changes: 12 additions & 0 deletions crates/perry-hir/src/lower_patterns.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
155 changes: 155 additions & 0 deletions test-parity/node-suite/object/class-expr-capture-refresh.ts
Original file line number Diff line number Diff line change
@@ -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);
Loading
Loading