From 35ecbb3e02053a65fe87dfccf5c3e280af5dda3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 2 Sep 2026 09:48:27 +0200 Subject: [PATCH 1/3] fix(decorators): instance-member decorators receive Class.prototype; C.constructor === Function (#9467) Two divergences that cancelled, found while fixing #9404 (#9465/#9472): 1. A legacy decorator on an INSTANCE member (property, method, method parameter) received the class itself as `target`; tsc's `__decorate([...], C.prototype, key, desc)` hands it `Class.prototype`. Static members correctly keep the constructor. 2. `C.constructor === C`; node says `Function`. The decl-prototype carries `constructor` as an ordinary data field and the constructor-side chain walk (`resolve_proto_chain_field`) returned it before the class-ref arm's existing `constructor -> Function` tail fallback was reached. NestJS-style `Reflect.defineMetadata(k, v, target.constructor)` only landed on `C` because both were wrong. Both halves fixed together: - perry-hir `lower/decorators.rs`: `member_decorator_target` hands instance members `PropertyGet(ClassRef, "prototype")` (the reflective decl-proto object), statics `ClassRef`; `design:type` / `design:paramtypes` ride the same target. The metadata store's prototype->class fold keeps the historical `getMetadata(..., Class, prop)` reads resolving. - perry-runtime `prototype_objects.rs`: the constructor-side walk also skips the `constructor` key, so `C.constructor` falls through to `Function`; `C.prototype.constructor` and instance reads are untouched. Fixture `test_decorators_target_prototype_9467` (expected output from tsc --experimentalDecorators --emitDecoratorMetadata + reflect-metadata under node) pins: target identity per member kind, `C.constructor === Function`, `p.constructor === C`, and `Reflect.getMetadata` round-trips through both `target` and `target.constructor`, including inheritance. Claude-Session: https://claude.ai/code/session_01MmDfS97fv8TRgyDnj6bgsL --- CLAUDE.md | 2 +- Cargo.lock | 156 +++++++++--------- Cargo.toml | 2 +- crates/perry-hir/src/lower/decorators.rs | 80 ++++++--- .../class_registry/prototype_objects.rs | 40 +++-- docs/src/language/decorators.md | 6 +- .../test_decorators_target_prototype_9467.ts | 108 ++++++++++++ .../test_decorators_target_prototype_9467.txt | 30 ++++ 8 files changed, 306 insertions(+), 118 deletions(-) create mode 100644 test-files/test_decorators_target_prototype_9467.ts create mode 100644 test-parity/expected/test_decorators_target_prototype_9467.txt diff --git a/CLAUDE.md b/CLAUDE.md index 61bf8dcebb..94d8b53bf4 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.1519 +**Current Version:** 0.5.1520 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index e9077d6199..d6c4beedb8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5632,7 +5632,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "anyhow", "base64 0.22.1", @@ -5694,7 +5694,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-dispatch", "serde", @@ -5702,7 +5702,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "cc", "libc", @@ -5711,7 +5711,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "anyhow", "inkwell", @@ -5728,7 +5728,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "anyhow", "perry-hir", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "anyhow", "perry-hir", @@ -5744,7 +5744,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "anyhow", "perry-dispatch", @@ -5753,7 +5753,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "anyhow", "perry-hir", @@ -5761,7 +5761,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "anyhow", "base64 0.22.1", @@ -5773,7 +5773,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "anyhow", "perry-hir", @@ -5781,7 +5781,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "anyhow", "async-trait", @@ -5810,14 +5810,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "serde", "serde_json", @@ -5825,7 +5825,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1519" +version = "0.5.1520" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5836,7 +5836,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "anyhow", "clap", @@ -5851,7 +5851,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "block2", "objc2", @@ -5861,7 +5861,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "argon2", "perry-ffi", @@ -5870,7 +5870,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-ffi", "reqwest", @@ -5879,7 +5879,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "bcrypt", "perry-ffi", @@ -5887,7 +5887,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-ffi", "rusqlite", @@ -5895,7 +5895,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-ffi", "scraper", @@ -5903,7 +5903,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-ffi", "perry-runtime", @@ -5911,7 +5911,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "chrono", "cron", @@ -5921,7 +5921,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "chrono", "perry-ffi", @@ -5929,7 +5929,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-ffi", "rust_decimal", @@ -5937,7 +5937,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-ffi", "serde_json", @@ -5945,7 +5945,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -5953,7 +5953,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-ffi", "perry-runtime", @@ -5961,14 +5961,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "bytes", "http-body-util", @@ -5986,7 +5986,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "bytes", "lazy_static", @@ -5999,7 +5999,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "base64 0.22.1", "bytes", @@ -6031,7 +6031,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "lazy_static", "perry-ffi", @@ -6041,7 +6041,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "base64 0.22.1", "jsonwebtoken", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "lru", "perry-ffi", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "chrono", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "bson", "futures-util", @@ -6081,7 +6081,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "chrono", "perry-ffi", @@ -6093,7 +6093,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "nanoid", "perry-ffi", @@ -6102,7 +6102,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "bytes", "perry-ffi", @@ -6117,7 +6117,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "const-oid 0.10.2", "der 0.8.1", @@ -6136,7 +6136,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "lettre", "perry-ffi", @@ -6146,7 +6146,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "fancy-regex", "notify", @@ -6158,7 +6158,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-ffi", "printpdf", @@ -6166,7 +6166,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-ffi", "sqlx", @@ -6175,7 +6175,7 @@ dependencies = [ [[package]] name = "perry-ext-qs" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-ffi", "perry-runtime", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "governor", "perry-ffi", @@ -6192,7 +6192,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "fast_image_resize", "image", @@ -6203,7 +6203,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "lazy_static", "perry-ffi", @@ -6212,7 +6212,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-ffi", "serde", @@ -6228,7 +6228,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-ffi", "perry-runtime", @@ -6237,7 +6237,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-ffi", "uuid", @@ -6245,7 +6245,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-ffi", "regex", @@ -6255,7 +6255,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "futures-util", "lazy_static", @@ -6268,7 +6268,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "brotli", "flate2", @@ -6278,7 +6278,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "dashmap", "once_cell", @@ -6287,7 +6287,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "anyhow", "perry-api-manifest", @@ -6306,7 +6306,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "anyhow", "perry-diagnostics", @@ -6319,7 +6319,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "anyhow", "base64 0.22.1", @@ -6369,14 +6369,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6471,14 +6471,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "anyhow", "perry-hir", @@ -6487,7 +6487,7 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-ffi", "perry-ui-model", @@ -6495,7 +6495,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "base64 0.22.1", "itoa", @@ -6513,7 +6513,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "rand 0.10.2", "serde", @@ -6523,7 +6523,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.0", @@ -6546,7 +6546,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "base64 0.22.1", "block2", @@ -6563,7 +6563,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "base64 0.22.1", "block2", @@ -6579,7 +6579,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1519" +version = "0.5.1520" [[package]] name = "perry-ui-test" @@ -6590,11 +6590,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1519" +version = "0.5.1520" [[package]] name = "perry-ui-tvos" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "base64 0.22.1", "block2", @@ -6611,7 +6611,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "base64 0.22.1", "block2", @@ -6628,7 +6628,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "block2", "libc", @@ -6642,7 +6642,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "base64 0.22.1", "libc", @@ -6661,7 +6661,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "base64 0.22.1", "libc", @@ -6674,7 +6674,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "anyhow", "base64 0.22.1", @@ -6690,7 +6690,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 08061d929f..7652038146 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -335,7 +335,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1519" +version = "0.5.1520" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/crates/perry-hir/src/lower/decorators.rs b/crates/perry-hir/src/lower/decorators.rs index 37dc8532da..ed41b54d25 100644 --- a/crates/perry-hir/src/lower/decorators.rs +++ b/crates/perry-hir/src/lower/decorators.rs @@ -64,16 +64,16 @@ pub(crate) fn append_legacy_decorator_init_for_class( } for method in &class.methods { - append_method_decorator_init(ctx, init, class, method); + append_method_decorator_init(ctx, init, class, method, false); } for method in &class.static_methods { - append_method_decorator_init(ctx, init, class, method); + append_method_decorator_init(ctx, init, class, method, true); } for field in &class.fields { - append_property_decorator_init(ctx, init, class, field); + append_property_decorator_init(ctx, init, class, field, false); } for field in &class.static_fields { - append_property_decorator_init(ctx, init, class, field); + append_property_decorator_init(ctx, init, class, field, true); } append_class_decorator_invocations( @@ -93,29 +93,60 @@ fn field_has_legacy_decorators(field: &ClassField) -> bool { !field.decorators.is_empty() } +/// The `target` a legacy member decorator receives (#9467). +/// +/// `tsc` emits `__decorate([...], C.prototype, "m", desc)` for an INSTANCE +/// member and `__decorate([...], C, "s", desc)` for a STATIC one, and the +/// `design:*` metadata rides the same target. Perry used to hand every member +/// decorator the class itself; NestJS-style code +/// (`Reflect.defineMetadata(k, v, target.constructor)`) only kept working +/// because the runtime answered `C.constructor === C` — a second divergence +/// that cancelled the first (node: `C.constructor === Function`). Both were +/// fixed together; the runtime half is the `constructor` clause of +/// `resolve_proto_chain_field_inner` in +/// `perry-runtime/src/object/class_registry/prototype_objects.rs`. +/// +/// `Class.prototype` is read as an ordinary `PropertyGet` on the class ref, +/// which the class-ref arm of `js_object_get_field_by_name` materializes as +/// the reflective decl-prototype object (`class_decl_prototype_value`) — the +/// same object `C.prototype` answers with everywhere else, so +/// `target === C.prototype` and `target.constructor === C` both hold. +fn member_decorator_target(class: &Class, is_static: bool) -> Expr { + if is_static { + Expr::ClassRef(class.name.clone()) + } else { + Expr::PropertyGet { + object: Box::new(Expr::ClassRef(class.name.clone())), + property: "prototype".to_string(), + byte_offset: 0, + } + } +} + fn append_property_decorator_init( ctx: &mut LoweringContext, out: &mut Vec, class: &Class, field: &ClassField, + is_static: bool, ) { if field.decorators.is_empty() { return; } - // NOTE: TypeScript's `emitDecoratorMetadata` stores instance-member - // `design:type` on `Class.prototype`, and class-transformer reads it back - // with `Reflect.getMetadata("design:type", SomeClass.prototype, prop)`. - // Perry stores it on the class constructor (`ClassRef`) here; the runtime - // metadata store folds class-prototype lookup targets onto the same class - // key (see `proxy/metadata.rs::normalize_target_bits`), so both the - // historical `getMetadata(..., Class, prop)` reads (locked in by - // test_decorators_legacy_property_metadata.ts) and the library's - // `Class.prototype` reads resolve to this entry. + // `design:type` rides the decorator's own target — `Class.prototype` for + // an instance member, the constructor for a static one (#9467) — which is + // where class-transformer reads it back + // (`Reflect.getMetadata("design:type", SomeClass.prototype, prop)`). The + // runtime metadata store folds prototype targets onto the class key (see + // `proxy/metadata.rs::normalize_target_bits`), so the historical + // `getMetadata(..., Class, prop)` reads locked in by + // test_decorators_legacy_property_metadata.ts keep resolving too. + let target = member_decorator_target(class, is_static); out.push(Stmt::Expr(Expr::ReflectDefineMetadata { key: Box::new(Expr::String("design:type".to_string())), value: Box::new(type_metadata_expr(&field.ty)), - target: Box::new(Expr::ClassRef(class.name.clone())), + target: Box::new(target.clone()), property_key: Some(Box::new(Expr::String(field.name.clone()))), })); @@ -123,10 +154,7 @@ fn append_property_decorator_init( ctx, out, &field.decorators, - vec![ - Expr::ClassRef(class.name.clone()), - Expr::String(field.name.clone()), - ], + vec![target, Expr::String(field.name.clone())], ); } @@ -135,11 +163,17 @@ fn append_method_decorator_init( out: &mut Vec, class: &Class, method: &Function, + is_static: bool, ) { if !method_has_legacy_decorators(method) { return; } + // #9467: `Class.prototype` for an instance method, the constructor for a + // static one — the target of the method decorator, its parameter + // decorators, and their `design:paramtypes` alike (one `__decorate` call + // in `tsc`'s emit carries all three). + let target = member_decorator_target(class, is_static); if method.params.iter().any(|p| !p.decorators.is_empty()) || !method.decorators.is_empty() { let param_types = method .params @@ -149,7 +183,7 @@ fn append_method_decorator_init( out.push(Stmt::Expr(Expr::ReflectDefineMetadata { key: Box::new(Expr::String("design:paramtypes".to_string())), value: Box::new(Expr::Array(param_types)), - target: Box::new(Expr::ClassRef(class.name.clone())), + target: Box::new(target.clone()), property_key: Some(Box::new(Expr::String(method.name.clone()))), })); } @@ -160,7 +194,7 @@ fn append_method_decorator_init( out, ¶m.decorators, vec![ - Expr::ClassRef(class.name.clone()), + target.clone(), Expr::String(method.name.clone()), Expr::Number(index as f64), ], @@ -187,11 +221,7 @@ fn append_method_decorator_init( ctx, out, &method.decorators, - vec![ - Expr::ClassRef(class.name.clone()), - Expr::String(method.name.clone()), - descriptor, - ], + vec![target, Expr::String(method.name.clone()), descriptor], ); } diff --git a/crates/perry-runtime/src/object/class_registry/prototype_objects.rs b/crates/perry-runtime/src/object/class_registry/prototype_objects.rs index 1271493e24..ec638bb7dd 100644 --- a/crates/perry-runtime/src/object/class_registry/prototype_objects.rs +++ b/crates/perry-runtime/src/object/class_registry/prototype_objects.rs @@ -403,18 +403,30 @@ unsafe fn inherited_proto_accessor_value( /// door for #1021/NestJS; this is that door's chain-walk twin. /// /// The exclusion is keyed on `class_instance_has_member` — the exact -/// "is this a prototype method / getter / setter of the chain" predicate — and -/// NOT on "skip the decl-prototype entirely". A blanket skip also removed -/// `C.constructor`, which the decl-prototype carries as an ordinary data field, -/// and that is load-bearing today for a reason outside this issue: perry hands -/// a PROPERTY DECORATOR the class itself where the spec hands it -/// `Class.prototype`, so NestJS-style `Reflect.defineMetadata(k, v, -/// target.constructor)` relies on `C.constructor === C`. Node says -/// `C.constructor === Function`, so perry has two divergences that cancel; -/// removing either one alone breaks decorator metadata +/// "is this a prototype method / getter / setter of the chain" predicate — +/// plus the `constructor` back-edge, and NOT on "skip the decl-prototype +/// entirely": a blanket skip would also hide a user's own +/// `Object.defineProperty(C.prototype, ...)` data field from this walk. +/// +/// #9467: `constructor` is the one decl-prototype data field that is NOT an +/// instance member by `class_instance_has_member`'s definition, and walking +/// into it from the constructor side answered `C.constructor === C`. Node says +/// `C.constructor === Function` — a constructor object's own chain is +/// `C → Function.prototype`, with no back-edge to `C`; only +/// `C.prototype.constructor` and `(new C()).constructor` are `C`. The +/// `Function` answer already existed as the tail fallback of the class-ref arm +/// in `get_field_by_name.rs`; this walk simply returned first. The wrong +/// answer used to be load-bearing: perry handed a decorator on an INSTANCE +/// member the class itself where the spec hands it `Class.prototype`, so +/// NestJS-style `Reflect.defineMetadata(k, v, target.constructor)` landed on +/// `C` only because `C.constructor === C`. Two divergences that cancelled; +/// fixing either alone broke decorator metadata /// (`test_decorators_nest_common_canary`, -/// `test_decorators_legacy_property_metadata`). The decorator-target defect is -/// the one to fix, and it is not this issue. +/// `test_decorators_legacy_property_metadata`). Both halves were fixed +/// together — the decorator target in +/// `perry-hir/src/lower/decorators.rs::member_decorator_target` and the +/// `constructor` clause below; `test_decorators_target_prototype_9467` pins +/// the pair. /// /// The `class_prototype_object` step below is never skipped: for a subclass of /// a class-EXPRESSION value it holds the parent CLASS OBJECT (#1788/#6552), @@ -432,7 +444,11 @@ unsafe fn resolve_proto_chain_field_inner( let key_ptr = crate::string::string_data(key); let key_len = (*key).byte_len as usize; std::str::from_utf8(std::slice::from_raw_parts(key_ptr, key_len)) - .map(|name| crate::object::class_instance_has_member(class_id, name)) + .map(|name| { + // #9467: `C.prototype.constructor` is `C`; `C.constructor` is + // `Function` (the class-ref arm's tail fallback), never `C`. + name == "constructor" || crate::object::class_instance_has_member(class_id, name) + }) .unwrap_or(false) }; let mut cid = class_id; diff --git a/docs/src/language/decorators.md b/docs/src/language/decorators.md index 8034111784..32c8293f95 100644 --- a/docs/src/language/decorators.md +++ b/docs/src/language/decorators.md @@ -36,7 +36,11 @@ supports two paths: `Reflect.getOwnMetadataKeys`, `Reflect.deleteMetadata`, and `@Reflect.metadata(...)` are available. Perry emits `design:paramtypes` for decorated classes/methods and `design:type` - for decorated properties. + for decorated properties. A member decorator receives the same + `target` `tsc` hands it: `Class.prototype` for an instance member and + the constructor for a static one, so the NestJS idiom + `Reflect.defineMetadata(key, value, target.constructor)` lands on the + class, and `Class.constructor === Function` as in node. - **Compile-time-only transforms.** The bundled `@log` transform is the canonical example — it rewrites a decorated method into a wrapper that prints entry/exit at compile time, with zero runtime decorator diff --git a/test-files/test_decorators_target_prototype_9467.ts b/test-files/test_decorators_target_prototype_9467.ts new file mode 100644 index 0000000000..f679f838fd --- /dev/null +++ b/test-files/test_decorators_target_prototype_9467.ts @@ -0,0 +1,108 @@ +// #9467: a legacy decorator on an INSTANCE member receives `Class.prototype` +// as its `target` — what tsc's `__decorate([...], C.prototype, key, desc)` +// hands it — and a decorator on a STATIC member receives the constructor. +// Separately, a class constructor's own `constructor` is inherited from +// Function.prototype: `C.constructor === Function`, never `C`; only +// `C.prototype.constructor` and `(new C()).constructor` are `C`. +// +// Perry used to get both wrong in a way that cancelled (target = C and +// C.constructor = C), so the NestJS idiom +// `Reflect.defineMetadata(k, v, target.constructor)` landed on C by accident. +// Fixing either half alone broke decorator metadata; this fixture pins the +// pair. Decorator application ORDER across member kinds is deliberately not +// under test (records are collected, then printed sorted). +import "reflect-metadata"; + +class Repo {} + +const seen = new Map(); + +function describeTarget(target: any): string { + if (target === Service) return "Service"; + if (target === Service.prototype) return "Service.prototype"; + return "other:" + typeof target; +} + +function Prop(): PropertyDecorator { + return (target: any, key: string | symbol) => { + seen.set( + "prop:" + String(key), + describeTarget(target) + " ctor===Service:" + (target.constructor === Service), + ); + Reflect.defineMetadata("via:target", "T:" + String(key), target, key); + Reflect.defineMetadata("via:ctor", "C:" + String(key), target.constructor, key); + }; +} + +function Method(): MethodDecorator { + return (target: any, key: string | symbol, desc: PropertyDescriptor) => { + seen.set( + "method:" + String(key), + describeTarget(target) + + " ctor===Service:" + + (target.constructor === Service) + + " value:" + + typeof desc.value, + ); + Reflect.defineMetadata("via:ctor", "M:" + String(key), target.constructor, key); + }; +} + +function Param(): ParameterDecorator { + return (target: any, key: string | symbol | undefined, index: number) => { + seen.set( + "param:" + String(key) + ":" + index, + describeTarget(target) + " ctor===Service:" + (target.constructor === Service), + ); + if (key !== undefined) { + Reflect.defineMetadata("param:via:ctor", index, target.constructor, key); + } + }; +} + +class Service { + @Prop() + repo!: Repo; + + @Prop() + static counter: number = 0; + + @Method() + run(@Param() a: Repo) {} + + @Method() + static make(@Param() n: number) {} + + constructor(@Param() r: Repo) {} +} + +class Sub extends Service {} + +for (const k of [...seen.keys()].sort()) { + console.log(k, "->", seen.get(k)); +} + +console.log("Service.constructor === Function", Service.constructor === Function); +console.log("Service.constructor === Service", Service.constructor === Service); +console.log("Sub.constructor === Function", Sub.constructor === Function); +console.log("typeof Service.constructor", typeof Service.constructor); +console.log("Service.constructor.name", Service.constructor.name); +console.log("Service.prototype.constructor === Service", Service.prototype.constructor === Service); +console.log("Sub.prototype.constructor === Sub", Sub.prototype.constructor === Sub); +console.log("instance.constructor === Service", new Service(new Repo()).constructor === Service); +console.log("sub instance.constructor === Sub", new Sub(new Repo()).constructor === Sub); +console.log("'constructor' in Service", "constructor" in Service); +console.log("hasOwn constructor", Object.prototype.hasOwnProperty.call(Service, "constructor")); + +console.log("design:type via prototype", Reflect.getMetadata("design:type", Service.prototype, "repo") === Repo); +console.log("design:type static via class", Reflect.getMetadata("design:type", Service, "counter") === Number); +console.log("design:paramtypes ctor", Reflect.getMetadata("design:paramtypes", Service)[0] === Repo); +console.log("design:paramtypes method via prototype", Reflect.getMetadata("design:paramtypes", Service.prototype, "run")[0] === Repo); +console.log("design:paramtypes static via class", Reflect.getMetadata("design:paramtypes", Service, "make")[0] === Number); +console.log("via:target on prototype", Reflect.getMetadata("via:target", Service.prototype, "repo")); +console.log("via:ctor on class", Reflect.getMetadata("via:ctor", Service, "repo")); +console.log("via:ctor method on class", Reflect.getMetadata("via:ctor", Service, "run")); +console.log("param:via:ctor on class", Reflect.getMetadata("param:via:ctor", Service, "run")); +console.log("via:ctor inherited on Sub", Reflect.getMetadata("via:ctor", Sub, "repo")); +console.log("via:target inherited on Sub.prototype", Reflect.getMetadata("via:target", Sub.prototype, "repo")); +console.log("nothing for repo on Function", Reflect.getMetadata("via:ctor", Function, "repo")); diff --git a/test-parity/expected/test_decorators_target_prototype_9467.txt b/test-parity/expected/test_decorators_target_prototype_9467.txt new file mode 100644 index 0000000000..db9c96067c --- /dev/null +++ b/test-parity/expected/test_decorators_target_prototype_9467.txt @@ -0,0 +1,30 @@ +method:make -> Service ctor===Service:false value:function +method:run -> Service.prototype ctor===Service:true value:function +param:make:0 -> Service ctor===Service:false +param:run:0 -> Service.prototype ctor===Service:true +param:undefined:0 -> Service ctor===Service:false +prop:counter -> Service ctor===Service:false +prop:repo -> Service.prototype ctor===Service:true +Service.constructor === Function true +Service.constructor === Service false +Sub.constructor === Function true +typeof Service.constructor function +Service.constructor.name Function +Service.prototype.constructor === Service true +Sub.prototype.constructor === Sub true +instance.constructor === Service true +sub instance.constructor === Sub true +'constructor' in Service true +hasOwn constructor false +design:type via prototype true +design:type static via class true +design:paramtypes ctor true +design:paramtypes method via prototype true +design:paramtypes static via class true +via:target on prototype T:repo +via:ctor on class C:repo +via:ctor method on class M:run +param:via:ctor on class 0 +via:ctor inherited on Sub C:repo +via:target inherited on Sub.prototype T:repo +nothing for repo on Function undefined From 227388c599d126f5d7969c4c840eae79e16c386a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 2 Sep 2026 09:55:20 +0200 Subject: [PATCH 2/3] changelog: #9496 decorator target / C.constructor fragment --- .../9496-decorator-target-prototype.md | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 changelog.d/9496-decorator-target-prototype.md diff --git a/changelog.d/9496-decorator-target-prototype.md b/changelog.d/9496-decorator-target-prototype.md new file mode 100644 index 0000000000..3b8646c3ef --- /dev/null +++ b/changelog.d/9496-decorator-target-prototype.md @@ -0,0 +1,32 @@ +### Fixed + +- **A legacy decorator on an instance member now receives `Class.prototype` + as its `target`, and `Class.constructor === Function` (#9467).** Two + divergences that cancelled, found while fixing #9404 (#9472). Perry handed + every member decorator — property, method, method parameter — the class + itself, where tsc's `__decorate([...], C.prototype, key, desc)` hands an + instance member's decorator `C.prototype` (a static member's decorator does + get the constructor). And `C.constructor` answered `C`: the reflective + decl-prototype carries `constructor` as an ordinary data field, and the + constructor-side chain walk in `resolve_proto_chain_field` returned it + before the class-ref read's existing `constructor → Function` tail fallback + was reached. NestJS-style `Reflect.defineMetadata(k, v, target.constructor)` + only landed on `C` because both were wrong, so fixing either alone broke + decorator metadata. + + Both halves land together. `lower/decorators.rs::member_decorator_target` + hands instance members `PropertyGet(ClassRef, "prototype")` — the same + decl-prototype object every other `C.prototype` read answers with, so + `target === C.prototype` and `target.constructor === C` — and statics keep + the `ClassRef`; `design:type` / `design:paramtypes` ride the same target. + The metadata store's prototype→class fold is untouched, so the historical + `getMetadata(..., Class, prop)` reads keep resolving. On the runtime side the + constructor-side walk skips the `constructor` key alongside + `class_instance_has_member`, so `C.constructor` falls through to `Function` + while `C.prototype.constructor` and `(new C()).constructor` stay `C`. + + Fixture `test_decorators_target_prototype_9467` (expected output from + `tsc --experimentalDecorators --emitDecoratorMetadata` + `reflect-metadata` + under node) pins the target per member kind, the three `constructor` + identities, `"constructor" in C` / `hasOwnProperty`, and `Reflect.getMetadata` + round-trips through `target`, `target.constructor` and inheritance. From 280dff170a703e1f84faa55209a18a552efa929c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 2 Sep 2026 10:01:55 +0200 Subject: [PATCH 3/3] 9467: `"constructor" in C` agrees with the read; fixture pins user-class metadata types only --- .../src/object/field_get_set/has_property.rs | 7 +++++-- test-files/test_decorators_target_prototype_9467.ts | 12 +++++++----- .../test_decorators_target_prototype_9467.txt | 2 +- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/crates/perry-runtime/src/object/field_get_set/has_property.rs b/crates/perry-runtime/src/object/field_get_set/has_property.rs index 41a4f70437..783b27021d 100644 --- a/crates/perry-runtime/src/object/field_get_set/has_property.rs +++ b/crates/perry-runtime/src/object/field_get_set/has_property.rs @@ -415,7 +415,10 @@ pub extern "C" fn js_object_has_property(obj: f64, key: f64) -> f64 { // getter is never invoked (`in` is [[HasProperty]], not [[Get]]). // Inherited `Function.prototype` methods (`"call" in C`) and // inherited static *data* fields are not covered — the latter mirror - // the get-by-name gap for the same shape. + // the get-by-name gap for the same shape. `constructor` is the one + // `Function.prototype` member that IS covered: #9467 made the + // class-ref read answer `C.constructor === Function` (inherited, + // never own), and `in` must agree with `[[Get]]` on that key. if key_val.is_any_string() { let mut sso = [0u8; crate::value::SHORT_STRING_MAX_LEN]; if let Some(name) = unsafe { crate::string::js_string_key_bytes(key_val, &mut sso) } @@ -430,7 +433,7 @@ pub extern "C" fn js_object_has_property(obj: f64, key: f64) -> f64 { name, ) }; - let present = matches!(name, "prototype" | "name" | "length") + let present = matches!(name, "prototype" | "name" | "length" | "constructor") || (!super::super::class_registry::class_is_key_deleted(class_id, name) && (super::super::class_registry::class_has_own_dynamic_prop( class_id, name, diff --git a/test-files/test_decorators_target_prototype_9467.ts b/test-files/test_decorators_target_prototype_9467.ts index f679f838fd..d4dd94e5af 100644 --- a/test-files/test_decorators_target_prototype_9467.ts +++ b/test-files/test_decorators_target_prototype_9467.ts @@ -10,7 +10,9 @@ // `Reflect.defineMetadata(k, v, target.constructor)` landed on C by accident. // Fixing either half alone broke decorator metadata; this fixture pins the // pair. Decorator application ORDER across member kinds is deliberately not -// under test (records are collected, then printed sorted). +// under test (records are collected, then printed sorted), and every +// `design:*` type is a user class — builtin-constructor identity +// (`=== Number`) is a separate question. import "reflect-metadata"; class Repo {} @@ -65,13 +67,13 @@ class Service { repo!: Repo; @Prop() - static counter: number = 0; + static shared: Repo = new Repo(); @Method() run(@Param() a: Repo) {} @Method() - static make(@Param() n: number) {} + static make(@Param() n: Repo) {} constructor(@Param() r: Repo) {} } @@ -95,10 +97,10 @@ console.log("'constructor' in Service", "constructor" in Service); console.log("hasOwn constructor", Object.prototype.hasOwnProperty.call(Service, "constructor")); console.log("design:type via prototype", Reflect.getMetadata("design:type", Service.prototype, "repo") === Repo); -console.log("design:type static via class", Reflect.getMetadata("design:type", Service, "counter") === Number); +console.log("design:type static via class", Reflect.getMetadata("design:type", Service, "shared") === Repo); console.log("design:paramtypes ctor", Reflect.getMetadata("design:paramtypes", Service)[0] === Repo); console.log("design:paramtypes method via prototype", Reflect.getMetadata("design:paramtypes", Service.prototype, "run")[0] === Repo); -console.log("design:paramtypes static via class", Reflect.getMetadata("design:paramtypes", Service, "make")[0] === Number); +console.log("design:paramtypes static via class", Reflect.getMetadata("design:paramtypes", Service, "make")[0] === Repo); console.log("via:target on prototype", Reflect.getMetadata("via:target", Service.prototype, "repo")); console.log("via:ctor on class", Reflect.getMetadata("via:ctor", Service, "repo")); console.log("via:ctor method on class", Reflect.getMetadata("via:ctor", Service, "run")); diff --git a/test-parity/expected/test_decorators_target_prototype_9467.txt b/test-parity/expected/test_decorators_target_prototype_9467.txt index db9c96067c..81bfe6932c 100644 --- a/test-parity/expected/test_decorators_target_prototype_9467.txt +++ b/test-parity/expected/test_decorators_target_prototype_9467.txt @@ -3,8 +3,8 @@ method:run -> Service.prototype ctor===Service:true value:function param:make:0 -> Service ctor===Service:false param:run:0 -> Service.prototype ctor===Service:true param:undefined:0 -> Service ctor===Service:false -prop:counter -> Service ctor===Service:false prop:repo -> Service.prototype ctor===Service:true +prop:shared -> Service ctor===Service:false Service.constructor === Function true Service.constructor === Service false Sub.constructor === Function true