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
29 changes: 29 additions & 0 deletions crates/perry-hir/src/lower/lower_expr/arm_ident.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,35 @@ pub(crate) fn lower_ident_expr(ctx: &mut LoweringContext, ident: &ast::Ident) ->
{
return Ok(Expr::ClassRef(ctx.resolve_class_name(&name)));
}
// Chained-assignment class self-alias referenced from inside one of the
// class's own method/getter/setter bodies. tsc's decorator-capture form
// `let Logger = Logger_1 = class Logger { get x(){ …Logger_1… } static
// error(){ …Logger_1… } }` declares `Logger_1` as a real outer-scope local
// and binds the class to it. Inside a STATIC method the outer local is not
// in scope (the method compiles to a standalone function), and inside an
// instance method resolving to a `LocalGet` capture forces the whole class
// onto the per-evaluation `ClassExprFresh` path (which drops static methods
// and SIGSEGVs `new`). The alias value IS the class everywhere after
// evaluation, so resolve it to the constructor `ClassRef` directly — exactly
// as JS spec resolves the inner class binding. `record_chained_class_self_
// aliases` populates `class_expr_aliases[alias] = <class lowering name>`
// before the body is lowered; gate on currently lowering that same class so
// an unrelated outer reference to the name is untouched. (`class_expr_
// aliases` is NOT a `register_class`, so this does not trip
// `lower_class_expr`'s collision-rename — `current_class` stays the real
// class name.)
if let Some(cur) = ctx.current_class.clone() {
if let Some(target) = ctx.class_expr_aliases.get(&name) {
// Compare the RESOLVED target name: a collision-renamed class
// expression makes `current_class` the resolved name while the
// recorded `target` is still the source name, so a raw `*target ==
// cur` would miss. Resolving is identity when no rename happened.
let resolved = ctx.resolve_class_name(target);
if resolved == cur {
return Ok(Expr::ClassRef(resolved));
}
}
}
if let Some(id) = ctx.lookup_local(&name) {
// A with-fallback implicit global may still be the HOLE
// sentinel (the with-env took the write) — reading it then
Expand Down
35 changes: 35 additions & 0 deletions crates/perry-hir/src/lower/module_decl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,41 @@ pub(crate) fn lower_module_decl(

if source == "reflect-metadata" {
emit_reflect_metadata_shim_note();
// `import "reflect-metadata"` (no specifiers) is a pure
// side-effect import — Perry provides the `Reflect.*metadata`
// surface natively, so there is nothing to bind. But a CJS
// module that does `require("reflect-metadata")` is wrapped to
// `import _req_N from 'reflect-metadata'` with a default-import
// LOCAL, and the synthesized require shim's
// `if (specifier === 'reflect-metadata') return _req_N;`
// references that local. Without a binding the read throws
// `ReferenceError: _req_N is not defined` at module
// evaluation (NestJS's `@nestjs/common/index.js` —
// `require("reflect-metadata")` — and every other
// reflect-metadata-importing CJS barrel hit this). Bind each
// default/namespace/named local to an empty object: the module
// value is only ever the discarded result of
// `require("reflect-metadata")` (the real effect is the global
// `Reflect` polyfill, which Perry already supplies), so an
// empty object is a faithful, inert stand-in.
for spec in &import_decl.specifiers {
let local = match spec {
ast::ImportSpecifier::Default(d) => d.local.sym.to_string(),
ast::ImportSpecifier::Namespace(n) => n.local.sym.to_string(),
ast::ImportSpecifier::Named(n) => n.local.sym.to_string(),
};
if ctx.lookup_local(&local).is_some() {
continue;
}
let id = ctx.define_local(local.clone(), Type::Any);
module.init.push(Stmt::Let {
id,
name: local,
ty: Type::Any,
mutable: true,
init: Some(Expr::Object(Vec::new())),
});
}
return Ok(());
}

Expand Down
63 changes: 63 additions & 0 deletions crates/perry-hir/src/lower/stmt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,63 @@ fn emit_class_expression_value_binding(
});
}

/// For a `let X = T1 = … = class [Y] {…}` declaration, record the chained
/// assignment targets (`T1`, …) as class self-aliases of the class's lowering
/// name in `ctx.class_expr_aliases`, BEFORE the init is lowered. This does NOT
/// `register_class` the names (which would trip `lower_class_expr`'s
/// collision-rename), it only feeds `synthesize_class_captures`'s self-alias
/// exclusion so a method-body reference to `T1` (e.g. tsc's `Logger_1`) is not
/// counted as an outer-scope capture — keeping the class on the shared-template
/// `ClassRef` path instead of the static-method-dropping `ClassExprFresh` path.
/// The class's own bind/inner name is already covered by `name` in that pass.
pub(crate) fn record_chained_class_self_aliases(
ctx: &mut LoweringContext,
decl: &ast::VarDeclarator,
) {
let (ast::Pat::Ident(ident), Some(init)) = (&decl.name, &decl.init) else {
return;
};
let bind_name = ident.id.sym.to_string();
let mut chained_targets: Vec<String> = Vec::new();
let mut e = init.as_ref();
let inner_class = loop {
match e {
ast::Expr::Paren(p) => e = &p.expr,
ast::Expr::TsAs(a) => e = &a.expr,
ast::Expr::TsNonNull(n) => e = &n.expr,
ast::Expr::TsTypeAssertion(a) => e = &a.expr,
ast::Expr::Assign(assign) if assign.op == ast::AssignOp::Assign => {
if let ast::AssignTarget::Simple(ast::SimpleAssignTarget::Ident(target)) =
&assign.left
{
chained_targets.push(target.id.sym.to_string());
e = &assign.right;
} else {
return;
}
}
ast::Expr::Class(c) => break c,
_ => return,
}
};
if chained_targets.is_empty() {
return;
}
// The class lowers under its inner expression name if present, else the
// binding name (matching `lower_class_expr`'s `ident_name`/synthetic-name
// choice for the common non-colliding case).
let class_name = inner_class
.ident
.as_ref()
.map(|i| i.sym.to_string())
.unwrap_or_else(|| bind_name.clone());
for t in chained_targets {
ctx.class_expr_aliases
.entry(t)
.or_insert(class_name.clone());
}
}

/// Recursively walk a destructuring pattern collecting every leaf identifier
/// (and pre-defining each as a local). Used by the for-of binding pre-pass so
/// the loop body can reference variables introduced by *nested* patterns like
Expand Down Expand Up @@ -818,6 +875,12 @@ pub(crate) fn lower_stmt(
}
}
}
// Record chained-assignment class self-aliases (`let
// Logger = Logger_1 = class …`) so the self-reference
// isn't captured (see `synthesize_class_captures`). This
// top-level path is reached for any `let X = T = class`
// not handled by the direct `class` fast path above.
record_chained_class_self_aliases(ctx, decl);
let stmts = lower_var_decl_with_destructuring(ctx, decl, mutable, is_var)?;
// `var` is function-scoped: mark defined locals so
// `pop_block_scope` preserves them when leaving an inner block.
Expand Down
4 changes: 4 additions & 0 deletions crates/perry-hir/src/lower_decl/body_stmt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result<Ve
.insert(binding.id.sym.as_ref().to_string());
}
}
// Record chained-assignment class self-aliases (`let Logger =
// Logger_1 = class …`) so the self-reference isn't captured (see
// `synthesize_class_captures`). Function / CJS-module body path.
crate::lower::record_chained_class_self_aliases(ctx, decl);
let stmts = lower_var_decl_with_destructuring(ctx, decl, mutable, is_var)?;
// `var` is function-scoped: mark each defined local so
// `pop_block_scope` preserves it when leaving an inner block.
Expand Down
43 changes: 43 additions & 0 deletions crates/perry-runtime/src/object/descriptors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,49 @@ pub extern "C" fn js_object_get_own_property_descriptor(obj_value: f64, key_valu
return crate::proxy::js_reflect_get_own_property_descriptor(obj_value, key_value);
}

// A per-evaluation class object (`ClassExprFresh`, #1772/#1787) is a
// POINTER-tagged heap object, not a `0x7FFE` class ref, so the
// `class_ref_id` branch below never fires for it. Its static METHODS
// live in the class registry keyed by the header class_id (not as own
// properties), so `getOwnPropertyDescriptor(C, "staticMethod")` reported
// `undefined` — which broke NestJS's tslib `__decorate` chain
// (`descriptor.value` on undefined → "reading 'value'") when the Logger
// class took the fresh path (its getter/methods capture module locals
// like `DEFAULT_LOGGER`, forcing `ClassExprFresh`). Mirror the class-ref
// branch's static-method descriptor: a `static m(){}` is a `{ writable,
// enumerable: false, configurable }` own data property of the
// constructor. `is_class_object_value` is pointer-safe (it checks the
// NaN-box tag before any deref). Own per-evaluation static FIELDS fall
// through to the ordinary own-property path (checked here via
// `own_key_present` so a field shadows a same-named template method).
// Skip Symbol keys here: `metadata_key_to_string` / `js_string_coerce`
// would stringify a Symbol and wrongly return a string-named static
// method descriptor instead of letting it reach the symbol descriptor
// path below.
if super::class_registry::is_class_object_value(obj_value)
&& crate::symbol::js_is_symbol(key_value) == 0
{
if let Some(method_name) = metadata_key_to_string(key_value) {
let obj = extract_obj_ptr(obj_value);
let key_str = crate::builtins::js_string_coerce(key_value);
if !obj.is_null() && !key_str.is_null() && !own_key_present(obj, key_str) {
let class_id = super::js_object_get_class_id(obj as *const ObjectHeader);
if class_id != 0
&& !super::class_registry::class_is_key_deleted(class_id, &method_name)
&& super::class_registry::class_has_own_static_method(
class_id,
&method_name,
)
{
let leaked: &'static [u8] = method_name.as_bytes().to_vec().leak();
let value =
super::js_class_method_bind(obj_value, leaked.as_ptr(), leaked.len());
return build_data_descriptor(value, true, false, true);
Comment on lines +152 to +162

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Mirror static accessor descriptors for fresh class objects.

The fresh-class branch only reports static methods. Object.getOwnPropertyDescriptor(C, "x") for static get x() will still fall through to undefined, while the class-ref path below already returns accessor descriptors.

Suggested fix
                     if class_id != 0
                         && !super::class_registry::class_is_key_deleted(class_id, &method_name)
-                        && super::class_registry::class_has_own_static_method(
-                            class_id,
-                            &method_name,
-                        )
                     {
+                        if let Some((g, s)) =
+                            super::class_registry::class_own_static_accessor_ptrs(
+                                class_id,
+                                &method_name,
+                            )
+                        {
+                            return build_accessor_descriptor(
+                                super::class_registry::class_accessor_function_value(
+                                    g,
+                                    false,
+                                    &method_name,
+                                ),
+                                super::class_registry::class_accessor_function_value(
+                                    s,
+                                    true,
+                                    &method_name,
+                                ),
+                                false,
+                                true,
+                            );
+                        }
+                        if !super::class_registry::class_has_own_static_method(
+                            class_id,
+                            &method_name,
+                        ) {
+                            return f64::from_bits(crate::value::TAG_UNDEFINED);
+                        }
                         let leaked: &'static [u8] = method_name.as_bytes().to_vec().leak();
                         let value = super::js_class_method_bind(
                             obj_value,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if class_id != 0
&& !super::class_registry::class_is_key_deleted(class_id, &method_name)
&& super::class_registry::class_has_own_static_method(
class_id,
&method_name,
)
{
let leaked: &'static [u8] = method_name.as_bytes().to_vec().leak();
let value = super::js_class_method_bind(
obj_value,
leaked.as_ptr(),
leaked.len(),
);
return build_data_descriptor(value, true, false, true);
if class_id != 0
&& !super::class_registry::class_is_key_deleted(class_id, &method_name)
{
if let Some((g, s)) =
super::class_registry::class_own_static_accessor_ptrs(
class_id,
&method_name,
)
{
return build_accessor_descriptor(
super::class_registry::class_accessor_function_value(
g,
false,
&method_name,
),
super::class_registry::class_accessor_function_value(
s,
true,
&method_name,
),
false,
true,
);
}
if !super::class_registry::class_has_own_static_method(
class_id,
&method_name,
) {
return f64::from_bits(crate::value::TAG_UNDEFINED);
}
let leaked: &'static [u8] = method_name.as_bytes().to_vec().leak();
let value = super::js_class_method_bind(
obj_value,
leaked.as_ptr(),
leaked.len(),
);
return build_data_descriptor(value, true, false, true);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/object/descriptors.rs` around lines 146 - 159, The
fresh-class handling in the descriptor lookup only checks
class_has_own_static_method, so static accessor properties are missed and
Object.getOwnPropertyDescriptor returns undefined for them. Update the
fresh-class branch in descriptors.rs around the class_id/method_name logic to
also detect own static accessors on the class object, using the same
descriptor-building path as the class-ref branch (or equivalent accessor
descriptor construction) so static get/set members are mirrored for fresh class
objects.

}
}
}
}

// Private elements (`#x`) are stored on the static side / in a class
// instance's keys_array but are never reflectable own properties, so
// their descriptor is always undefined. (Plain `{"#fff": 1}` literals
Expand Down
67 changes: 67 additions & 0 deletions crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,73 @@ pub extern "C" fn js_object_get_field_by_name(
}
}
}
// A per-evaluation class object (`ClassExprFresh`, #1772/#1787) reaches
// here as a RAW heap pointer (a real ObjectHeader, so its top 16 address
// bits are 0 — distinguishing it from a `0x7FFE` class-ref value or any
// NaN-boxed value). Its static METHODS / static ACCESSORS live in the class
// registry keyed by the header class_id, never as own properties, so a read
// like `C.staticMethod` returned `undefined` (the class-ref form resolves
// these via the registry; this pointer-tagged class-object form did not).
// That is NestJS's `Logger.error` when the Logger takes the fresh path
// (captures `DEFAULT_LOGGER`), which the tslib `__decorate` chain then reads
// `.value` off → "reading 'value'". Resolve own fields first (own-property
// precedence), then fall back to the registry. The `(obj >> 48) == 0` guard
// ensures `is_class_object_ptr` only ever sees a real heap pointer (it
// back-reads a GcHeader), never a tagged value — which previously SIGSEGV'd.
if !key.is_null()
&& ((obj as u64) >> 48) == 0
// Must be ABOVE the whole small-handle band (>= 0x100000), not just
// >= 0x10000: native handle ids in [0x10000, 0x100000) (fetch/http/…)
// would otherwise reach `is_class_object_ptr`, which back-reads a
// GcHeader and SIGSEGVs on the non-heap handle id.
&& crate::value::addr_class::is_above_handle_band(obj as usize)
&& crate::object::class_registry::is_class_object_ptr(obj as *const u8)
{
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let own = get_field_by_name_object_tail(obj, key);
if !own.is_undefined() {
return own;
}
Comment on lines +55 to +58

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Use an own-property check before falling back to the class registry.

get_field_by_name_object_tail is not own-only; it can walk prototype/class fallbacks. That can make a fresh class constructor return an instance/prototype method before the static lookup runs, and it also fails to preserve an own property whose value is undefined.

Suggested fix
-        let own = get_field_by_name_object_tail(obj, key);
-        if !own.is_undefined() {
-            return own;
+        if super::super::own_key_present(obj, key) {
+            return get_field_by_name_object_tail(obj, key);
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let own = get_field_by_name_object_tail(obj, key);
if !own.is_undefined() {
return own;
}
if super::super::own_key_present(obj, key) {
return get_field_by_name_object_tail(obj, key);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs` around
lines 51 - 54, The fallback in get_field_by_name.rs should not use
get_field_by_name_object_tail() as the ownership test, because it can resolve
prototype/class values and miss own properties whose value is undefined. Update
the lookup logic in get_field_by_name to perform an own-property check on obj
first, then only fall back to the class registry when no own property exists;
keep using get_field_by_name_object_tail only for non-own resolution cases where
that behavior is intended.

unsafe {
// Re-box the raw class-object pointer as a POINTER-tagged JS value
// so `js_class_method_bind` (which expects a value, like the
// class-ref path) binds the static method to the right receiver.
let class_value = f64::from_bits(crate::value::js_nanbox_pointer(obj as i64).to_bits());
let class_id = super::super::js_object_get_class_id(obj);
if class_id != 0 {
let name_ptr = (key as *const u8).add(std::mem::size_of::<crate::StringHeader>());
let name_len = (*key).byte_len as usize;
let name = std::str::from_utf8(std::slice::from_raw_parts(name_ptr, name_len))
.unwrap_or("");
if !name.is_empty()
&& !super::super::class_registry::class_is_key_deleted(class_id, name)
{
if super::super::class_registry::lookup_static_method_in_chain(class_id, name)
.is_some()
{
let heap_name = {
let layout =
std::alloc::Layout::from_size_align(name_len.max(1), 1).unwrap();
let ptr = std::alloc::alloc(layout);
std::ptr::copy_nonoverlapping(name_ptr, ptr, name_len);
ptr
};
let result = js_class_method_bind(class_value, heap_name, name_len);
return JSValue::from_bits(result.to_bits());
}
if let Some(v) =
super::super::class_registry::class_static_accessor_getter_value(
class_id,
name,
class_value,
)
{
return JSValue::from_bits(v.to_bits());
}
}
}
}
return own;
}
if let Some(addr) =
crate::typedarray_props::typed_array_addr_from_value(f64::from_bits(obj as u64))
{
Expand Down
Loading