From 7ad6506e658a93b688182bcaacd3a19605e0f548 Mon Sep 17 00:00:00 2001 From: Andrew DiZenzo Date: Mon, 15 Jun 2026 19:22:53 -0500 Subject: [PATCH 1/9] fix native class semantics for three-style inheritance --- crates/perry-codegen/src/codegen/mod.rs | 59 ++++++- .../src/type_analysis_class_fields.rs | 35 +++- crates/perry-hir/src/lower/context.rs | 15 ++ crates/perry-hir/src/lower/lower_module_fn.rs | 9 +- crates/perry-hir/src/lower_decl/class_decl.rs | 10 +- .../perry/src/commands/compile/bootstrap.rs | 40 ++++- .../src/commands/compile/collect_modules.rs | 6 + crates/perry/src/commands/compile/types.rs | 6 + .../_helpers/three_like_descriptor_bases.ts | 55 +++++++ ...est_three_like_native_class_descriptors.ts | 68 ++++++++ ...imported_inherited_accessor_datatexture.sh | 151 ++++++++++++++++++ ...est_xmod_imported_multilevel_ctor_state.sh | 124 ++++++++++++++ 12 files changed, 563 insertions(+), 15 deletions(-) create mode 100644 test-files/_helpers/three_like_descriptor_bases.ts create mode 100644 test-files/test_three_like_native_class_descriptors.ts create mode 100755 tests/test_imported_inherited_accessor_datatexture.sh create mode 100755 tests/test_xmod_imported_multilevel_ctor_state.sh diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index e553960ef7..796925e270 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -298,9 +298,50 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> class_ids.insert(ic.name.clone(), class_id); } + let imported_getters: Vec = ic + .getter_names + .iter() + .map(|prop| perry_hir::Function { + id: 0, + name: format!("get_{}", prop), + type_params: Vec::new(), + params: Vec::new(), + return_type: perry_types::Type::Any, + body: Vec::new(), + is_async: false, + is_generator: false, + is_strict: true, + was_plain_async: false, + was_unrolled: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + }) + .collect(); + let imported_setters: Vec = ic + .setter_names + .iter() + .map(|prop| perry_hir::Function { + id: 0, + name: format!("set_{}", prop), + type_params: Vec::new(), + params: Vec::new(), + return_type: perry_types::Type::Any, + body: Vec::new(), + is_async: false, + is_generator: false, + is_strict: true, + was_plain_async: false, + was_unrolled: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + }) + .collect(); + // Build a stub Class with the minimum fields the codegen needs. - // Most fields are empty — only name, extends_name, and methods - // are consulted by dispatch. + // Imported accessor bodies execute from the source module; carrying + // their names here keeps dispatch and field inference conservative. let stub = perry_hir::Class { id: 0, // imported — no local ClassId name: effective_name.to_string(), @@ -353,8 +394,18 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> decorators: Vec::new(), }) .collect(), - getters: Vec::new(), - setters: Vec::new(), + getters: ic + .getter_names + .iter() + .cloned() + .zip(imported_getters) + .collect(), + setters: ic + .setter_names + .iter() + .cloned() + .zip(imported_setters) + .collect(), static_accessor_names: Vec::new(), static_accessor_fn_ids: Vec::new(), static_fields: Vec::new(), diff --git a/crates/perry-codegen/src/type_analysis_class_fields.rs b/crates/perry-codegen/src/type_analysis_class_fields.rs index 96a9c98a4c..81dd0f4f7b 100644 --- a/crates/perry-codegen/src/type_analysis_class_fields.rs +++ b/crates/perry-codegen/src/type_analysis_class_fields.rs @@ -79,6 +79,34 @@ pub(crate) fn class_field_global_index( fn count_keyable(fields: &[perry_hir::ClassField]) -> u32 { fields.iter().filter(|f| f.key_expr.is_none()).count() as u32 } + fn accessor_in_chain(ctx: &FnCtx<'_>, class_name: &str, property: &str) -> bool { + let mut current = Some(class_name.to_string()); + let mut seen_class_names: std::collections::HashSet = + std::collections::HashSet::new(); + let mut depth = 0usize; + while let Some(name) = current { + depth += 1; + if depth > 64 || !seen_class_names.insert(name.clone()) { + return true; + } + let Some(class) = ctx.classes.get(&name) else { + return true; + }; + if class.getters.iter().any(|(n, _)| n == property) + || class.setters.iter().any(|(n, _)| n == property) + { + return true; + } + current = class.extends_name.clone(); + } + false + } + // A getter/setter anywhere on the prototype chain owns this property name + // for normal JS semantics. Do not emit direct packed-field access even if + // HIR inferred a same-named field on a subclass assignment. + if accessor_in_chain(ctx, class_name, property) { + return None; + } fn walk( ctx: &FnCtx<'_>, class_name: &str, @@ -96,13 +124,6 @@ pub(crate) fn class_field_global_index( return None; } let class = ctx.classes.get(class_name)?; - // Bail if a getter/setter shadows the field — those need real - // method dispatch, not a direct memory access. - if class.getters.iter().any(|(n, _)| n == property) - || class.setters.iter().any(|(n, _)| n == property) - { - return None; - } // Compute the byte-offset contribution from this class's parent. let parent_count = if let Some(parent_name) = class.extends_name.as_deref() { let mut p_count = 0u32; diff --git a/crates/perry-hir/src/lower/context.rs b/crates/perry-hir/src/lower/context.rs index a6bf3a5d3b..3494d4c5e9 100644 --- a/crates/perry-hir/src/lower/context.rs +++ b/crates/perry-hir/src/lower/context.rs @@ -518,6 +518,21 @@ impl LoweringContext { } } + /// Pre-seed class accessor names with cross-module class info collected + /// from already-lowered dependencies. This lets constructor-field + /// inference avoid creating data slots for inherited imported accessors. + pub fn seed_imported_class_accessors( + &mut self, + seeds: &std::collections::HashMap>, + ) { + for (name, accessors) in seeds { + if !self.class_accessor_names.iter().any(|(n, _)| n == name) { + self.class_accessor_names + .push((name.clone(), accessors.clone())); + } + } + } + /// Issue #302: look up the declared type of a single instance field on a /// class. Returns `None` if the class isn't registered or the field /// name doesn't appear in the class's declared field list. diff --git a/crates/perry-hir/src/lower/lower_module_fn.rs b/crates/perry-hir/src/lower/lower_module_fn.rs index 1376f8164e..c1465c31c7 100644 --- a/crates/perry-hir/src/lower/lower_module_fn.rs +++ b/crates/perry-hir/src/lower/lower_module_fn.rs @@ -269,6 +269,7 @@ pub fn lower_module_with_class_id_types_and_seed( start_class_id, resolved_types, imported_class_fields, + None, false, ) } @@ -284,6 +285,7 @@ pub fn lower_module_with_class_id_types_seed_and_entry( start_class_id: ClassId, resolved_types: Option>, imported_class_fields: Option<&std::collections::HashMap>>, + imported_class_accessors: Option<&std::collections::HashMap>>, is_entry_module: bool, ) -> Result<(Module, ClassId)> { lower_module_full( @@ -293,6 +295,7 @@ pub fn lower_module_with_class_id_types_seed_and_entry( start_class_id, resolved_types, imported_class_fields, + imported_class_accessors, is_entry_module, false, ) @@ -335,6 +338,7 @@ pub fn lower_module_full( start_class_id: ClassId, resolved_types: Option>, imported_class_fields: Option<&std::collections::HashMap>>, + imported_class_accessors: Option<&std::collections::HashMap>>, is_entry_module: bool, is_external_module: bool, ) -> Result<(Module, ClassId)> { @@ -347,6 +351,9 @@ pub fn lower_module_full( if let Some(seed) = imported_class_fields { ctx.seed_imported_class_fields(seed); } + if let Some(seed) = imported_class_accessors { + ctx.seed_imported_class_accessors(seed); + } let mut module = Module::new(name); // Pre-scan for `new Function` / `Function(...)` constant-argument @@ -661,7 +668,7 @@ pub fn lower_module_full( { static_method_names.push(format!("#{}", method.key.name)); } - ast::ClassMember::ClassProp(prop) if prop.is_static => { + ast::ClassMember::ClassProp(prop) if prop.is_static && !prop.declare => { if let ast::PropName::Ident(ident) = &prop.key { static_field_names.push(ident.sym.to_string()); } diff --git a/crates/perry-hir/src/lower_decl/class_decl.rs b/crates/perry-hir/src/lower_decl/class_decl.rs index 70ea36c7d0..500a74e01e 100644 --- a/crates/perry-hir/src/lower_decl/class_decl.rs +++ b/crates/perry-hir/src/lower_decl/class_decl.rs @@ -461,7 +461,7 @@ pub fn lower_class_decl( // call-site lookup via has_static_method() succeeds. static_method_names.push(format!("#{}", method.key.name)); } - ast::ClassMember::ClassProp(prop) if prop.is_static => { + ast::ClassMember::ClassProp(prop) if prop.is_static && !prop.declare => { if let ast::PropName::Ident(ident) = &prop.key { static_field_names.push(ident.sym.to_string()); } @@ -815,6 +815,9 @@ pub fn lower_class_decl( } } ast::ClassMember::ClassProp(prop) => { + if prop.declare { + continue; + } // Computed-key fields (`[Symbol.for("k")] = init`) flow through // here for both instance AND static positions. // `lower_class_prop` captures the key expression in @@ -1390,7 +1393,7 @@ pub fn lower_class_from_ast( { static_method_names.push(format!("#{}", method.key.name)); } - ast::ClassMember::ClassProp(prop) if prop.is_static => { + ast::ClassMember::ClassProp(prop) if prop.is_static && !prop.declare => { if let ast::PropName::Ident(ident) = &prop.key { static_field_names.push(ident.sym.to_string()); } @@ -1513,6 +1516,9 @@ pub fn lower_class_from_ast( } } ast::ClassMember::ClassProp(prop) => { + if prop.declare { + continue; + } // Computed-key fields (`[Symbol.for("k")] = init`) flow through // here for both instance AND static positions. // `lower_class_prop` captures the key expression in diff --git a/crates/perry/src/commands/compile/bootstrap.rs b/crates/perry/src/commands/compile/bootstrap.rs index e06e18d383..def9e92f7d 100644 --- a/crates/perry/src/commands/compile/bootstrap.rs +++ b/crates/perry/src/commands/compile/bootstrap.rs @@ -164,6 +164,8 @@ pub(super) fn rerun_collect_with_class_field_types( return Ok(()); } let mut field_map: HashMap> = HashMap::new(); + let mut accessor_map: HashMap> = HashMap::new(); + let mut parent_map: HashMap = HashMap::new(); for hir_module in ctx.native_modules.values() { for class in &hir_module.classes { let fields: Vec<(String, perry_types::Type)> = class @@ -172,12 +174,48 @@ pub(super) fn rerun_collect_with_class_field_types( .map(|f| (f.name.clone(), f.ty.clone())) .collect(); field_map.entry(class.name.clone()).or_insert(fields); + let mut accessors = Vec::new(); + let mut seen = HashSet::new(); + for name in class + .getters + .iter() + .map(|(n, _)| n) + .chain(class.setters.iter().map(|(n, _)| n)) + { + if seen.insert(name.clone()) { + accessors.push(name.clone()); + } + } + accessor_map.entry(class.name.clone()).or_insert(accessors); + if let Some(parent_name) = &class.extends_name { + parent_map + .entry(class.name.clone()) + .or_insert_with(|| parent_name.clone()); + } + } + } + let mut changed = true; + while changed { + changed = false; + for (class_name, parent_name) in parent_map.clone() { + let parent_accessors = accessor_map.get(&parent_name).cloned().unwrap_or_default(); + if parent_accessors.is_empty() { + continue; + } + let entry = accessor_map.entry(class_name).or_default(); + for accessor in parent_accessors { + if !entry.contains(&accessor) { + entry.push(accessor); + changed = true; + } + } } } - if field_map.is_empty() { + if field_map.is_empty() && accessor_map.is_empty() { return Ok(()); } ctx.cross_module_class_field_types = field_map; + ctx.cross_module_class_accessors = accessor_map; ctx.native_modules.clear(); visited.clear(); *next_class_id = 1; diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index a442fbe132..755b47817e 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -676,6 +676,11 @@ fn collect_module_one( } else { Some(&ctx.cross_module_class_field_types) }; + let imported_class_accessors = if ctx.cross_module_class_accessors.is_empty() { + None + } else { + Some(&ctx.cross_module_class_accessors) + }; // Issue #444: this module is the user-supplied entry iff its canonical // path matches the one stashed by `compile.rs::run_with_parse_cache` // before the first `collect_modules` invocation. Bundle-extension @@ -763,6 +768,7 @@ fn collect_module_one( *next_class_id, resolved_types, imported_class_fields, + imported_class_accessors, is_entry_module, is_external_module, ); diff --git a/crates/perry/src/commands/compile/types.rs b/crates/perry/src/commands/compile/types.rs index 7512081755..33d07466f2 100644 --- a/crates/perry/src/commands/compile/types.rs +++ b/crates/perry/src/commands/compile/types.rs @@ -620,6 +620,11 @@ pub struct CompilationContext { /// type is unknown and the `SetValues`/`MapEntries` wrap is skipped at /// `lower_decl.rs:3737-3747`. See ECS demo-simple repro / #412. pub cross_module_class_field_types: HashMap>, + /// Cross-module class accessor names collected alongside field types. + /// HIR lowering uses this to avoid inferring subclass `this.x = ...` + /// constructor writes as data fields when `x` is an inherited accessor + /// from an imported superclass. + pub cross_module_class_accessors: HashMap>, /// Minimum Windows version for `--target windows` builds. One of `"7"`, /// `"8"`, `"10"`. `"10"` (default) means "no subsystem version suffix"; /// `"7"` and `"8"` emit `,5.1` / `,6.02` on the linker `/SUBSYSTEM:` flag @@ -867,6 +872,7 @@ impl CompilationContext { uses_dgram: false, needs_thread: false, cross_module_class_field_types: HashMap::new(), + cross_module_class_accessors: HashMap::new(), min_windows_version: "10".to_string(), windows_subsystem: "auto".to_string(), entry_canonical: None, diff --git a/test-files/_helpers/three_like_descriptor_bases.ts b/test-files/_helpers/three_like_descriptor_bases.ts new file mode 100644 index 0000000000..fc37f752be --- /dev/null +++ b/test-files/_helpers/three_like_descriptor_bases.ts @@ -0,0 +1,55 @@ +export class Object3D { + public visible = true; + + constructor() { + Object.defineProperties(this, { + matrixWorld: { + value: { source: "Object3D.matrixWorld", elements: 16 }, + writable: true, + enumerable: true, + configurable: true, + }, + matrixWorldNeedsUpdate: { + value: false, + writable: true, + enumerable: true, + configurable: true, + }, + }); + Object.defineProperty(this, "matrixAutoUpdate", { + value: true, + writable: true, + enumerable: true, + configurable: true, + }); + } +} + +export class Camera extends Object3D { + constructor() { + super(); + (this as any).isCamera = true; + } +} + +export class Texture { + public textureField = "base-field"; + private _image: any; + + constructor(image: any = { data: "default", width: 1, height: 1 }) { + this.image = image; + } + + get image(): any { + return this._image; + } + + set image(value: any) { + this._image = { + data: value && value.data, + width: value && value.width, + height: value && value.height, + assignedBy: "Texture.image", + }; + } +} diff --git a/test-files/test_three_like_native_class_descriptors.ts b/test-files/test_three_like_native_class_descriptors.ts new file mode 100644 index 0000000000..2f3519c9ae --- /dev/null +++ b/test-files/test_three_like_native_class_descriptors.ts @@ -0,0 +1,68 @@ +import { Camera, Texture } from "./_helpers/three_like_descriptor_bases"; + +function assert(condition: any, label: string): void { + if (!condition) { + throw new Error(label); + } +} + +class PerspectiveCamera extends Camera { + public fov: number; + public aspect: number; + public near: number; + public far: number; + + constructor(fov = 50, aspect = 1, near = 0.1, far = 2000) { + super(); + this.fov = fov; + this.aspect = aspect; + this.near = near; + this.far = far; + (this as any).isPerspectiveCamera = true; + } +} + +class DataTexture extends Texture { + public isDataTexture = true; + + constructor(data: any = null, width = 1, height = 1) { + super(); + this.image = { data, width, height }; + } +} + +const camera = new PerspectiveCamera(); +assert((camera as any).matrixWorld.source === "Object3D.matrixWorld", "inherited constructor defineProperties state"); +assert((camera as any).matrixWorld.elements === 16, "descriptor value object survived"); +assert((camera as any).matrixWorldNeedsUpdate === false, "defineProperties boolean state"); +assert((camera as any).matrixAutoUpdate === true, "defineProperty state"); +assert((camera as any).visible === true, "imported class field initializer"); +assert((camera as any).isCamera === true, "imported superclass constructor body"); +assert((camera as any).isPerspectiveCamera === true, "subclass constructor body"); +assert(camera.fov === 50 && camera.aspect === 1 && camera.near === 0.1 && camera.far === 2000, "subclass default args"); + +const cameraKeys = Object.keys(camera as any); +assert(cameraKeys.includes("matrixWorld"), "defineProperties enumerable key"); +assert(cameraKeys.includes("matrixAutoUpdate"), "defineProperty enumerable key"); + +const explicitTexture = new DataTexture("pixels", 4, 2); +assert((explicitTexture as any)._image !== undefined, "subclass write invoked inherited setter"); +const explicitImage = explicitTexture.image; +assert(explicitImage !== undefined, "subclass read invoked inherited getter"); +assert(explicitImage.data === "pixels", "subclass write reached inherited setter data"); +assert(explicitImage.width === 4, "subclass write reached inherited setter width"); +assert(explicitImage.height === 2, "subclass write reached inherited setter height"); +assert(explicitImage.assignedBy === "Texture.image", "inherited setter dispatch"); +assert(!(Object.keys(explicitTexture as any).includes("image")), "subclass setter write did not create own data image"); +assert(Object.keys(explicitTexture as any).includes("_image"), "setter created backing image slot"); +assert((explicitTexture as any).textureField === "base-field", "imported base class field"); +assert(explicitTexture.isDataTexture === true, "subclass field initializer after super"); + +const defaultTexture = new DataTexture(); +const defaultImage = defaultTexture.image; +assert(defaultImage !== undefined, "default subclass read invoked inherited getter"); +assert(defaultImage.data === null, "default data propagated through subclass"); +assert(defaultImage.width === 1, "default width propagated through subclass"); +assert(defaultImage.height === 1, "default height propagated through subclass"); + +console.log("OK"); diff --git a/tests/test_imported_inherited_accessor_datatexture.sh b/tests/test_imported_inherited_accessor_datatexture.sh new file mode 100755 index 0000000000..7a91f5b5b0 --- /dev/null +++ b/tests/test_imported_inherited_accessor_datatexture.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Regression for a Three.js/DataTexture-shaped native compile edge: +# constructor field inference in a subclass must not allocate an own data slot +# for `this.image = ...` when `image` is an inherited accessor from an imported +# superclass. Type-only `declare` refinements must not shadow inherited fields. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi +if [[ "$PERRY" != /* ]]; then + PERRY="$(cd "$(dirname "$PERRY")" && pwd)/$(basename "$PERRY")" +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat >"$TMPDIR/base_texture.ts" <<'TS' +export const LinearFilter = 1006; + +export class BaseTexture { + public source: any; + public version = 0; + public magFilter: number; + public minFilter: number; + public generateMipmaps = true; + public flipY = true; + public unpackAlignment = 4; + + constructor( + image: any = null, + mapping: any = undefined, + wrapS: any = undefined, + wrapT: any = undefined, + magFilter: number = LinearFilter, + minFilter: number = LinearFilter, + format: any = undefined, + type: any = undefined, + anisotropy: any = undefined, + colorSpace: any = undefined, + ) { + this.source = { data: image, version: 0 }; + this.magFilter = magFilter; + this.minFilter = minFilter; + } + + get image(): any { + return this.source.data; + } + + set image(value: any) { + this.source.data = value; + } + + set needsUpdate(value: boolean) { + if (value === true) { + this.version++; + this.source.version++; + } + } +} +TS + +cat >"$TMPDIR/main.ts" <<'TS' +import { BaseTexture } from "./base_texture"; + +const NearestFilter = 1003; + +function assert(condition: any, label: string): void { + if (!condition) throw new Error(label); +} + +class DataTextureLike extends BaseTexture { + declare magFilter: number; + declare minFilter: number; + declare generateMipmaps: boolean; + declare flipY: boolean; + declare unpackAlignment: number; + + public isDataTexture = true; + + constructor( + data: any = null, + width = 1, + height = 1, + format: any = undefined, + type: any = undefined, + mapping: any = undefined, + wrapS: any = undefined, + wrapT: any = undefined, + magFilter: number = NearestFilter, + minFilter: number = NearestFilter, + anisotropy: any = undefined, + colorSpace: any = undefined, + ) { + super(null, mapping, wrapS, wrapT, magFilter, minFilter, format, type, anisotropy, colorSpace); + this.image = { data, width, height }; + this.generateMipmaps = false; + this.flipY = false; + this.unpackAlignment = 1; + } +} + +const payload = new Uint8Array(8); +const texture: any = new DataTextureLike(payload, 2, 1); + +assert(texture.isDataTexture === true, "subclass field initialized"); +assert(texture instanceof BaseTexture, "instanceof imported base"); +assert(texture.version === 0, "initial version"); +assert(texture.image !== undefined, "inherited getter returned image"); +assert(texture.image.data === payload, "payload identity preserved"); +assert(texture.image.width === 2, "width preserved"); +assert(texture.image.height === 1, "height preserved"); +assert(texture.magFilter === NearestFilter, "magFilter subclass default"); +assert(texture.minFilter === NearestFilter, "minFilter subclass default"); +assert(texture.generateMipmaps === false, "subclass generateMipmaps override"); +assert(texture.flipY === false, "subclass flipY override"); +assert(texture.unpackAlignment === 1, "subclass unpackAlignment override"); +assert(!Object.keys(texture).includes("image"), "accessor write did not create own image slot"); + +const replacement = { data: payload, width: 4, height: 3 }; +texture.image = replacement; +assert(texture.image === replacement, "inherited setter assignment round-trips"); + +texture.needsUpdate = true; +assert(texture.version === 1, "needsUpdate increments texture version"); +assert(texture.source.version === 1, "needsUpdate increments source version"); + +console.log("OK"); +TS + +cd "$TMPDIR" +"$PERRY" compile --no-cache --no-auto-optimize main.ts --output test_bin >/dev/null +RUN_OUTPUT="$(./test_bin 2>&1)" + +if [[ "$RUN_OUTPUT" == "OK" ]]; then + echo "PASS" + exit 0 +fi + +echo "FAIL: imported inherited accessor DataTexture semantics regressed" +echo "$RUN_OUTPUT" +exit 1 diff --git a/tests/test_xmod_imported_multilevel_ctor_state.sh b/tests/test_xmod_imported_multilevel_ctor_state.sh new file mode 100755 index 0000000000..c2edef1099 --- /dev/null +++ b/tests/test_xmod_imported_multilevel_ctor_state.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Regression: imported multi-level native classes must replay inherited +# constructor state all the way up the chain. This mirrors the Three.js shape +# PerspectiveCamera -> Camera -> Object3D, where Object3D assigns object-valued +# instance state and Camera redeclares some inherited fields with `declare`. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi +if [[ "$PERRY" != /* ]]; then + PERRY="$(cd "$(dirname "$PERRY")" && pwd)/$(basename "$PERRY")" +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat >"$TMPDIR/base_object.ts" <<'TS' +export class MatrixLike { + label: string; + + constructor(label: string) { + this.label = label; + } + + decompose() { + return "decompose:" + this.label; + } +} + +export class BaseObject { + constructor() { + Object.defineProperties(this, { + hiddenMarker: { value: "hidden", configurable: true } + }); + (this as any).plainBase = "base"; + (this as any).matrixWorld = new MatrixLike("world"); + } + + updateMatrixWorld(force?: boolean) { + return (this as any).matrixWorld.decompose() + ":" + String(force); + } +} +TS + +cat >"$TMPDIR/camera_base.ts" <<'TS' +import { BaseObject, MatrixLike } from "./base_object.ts"; + +export class CameraBase extends BaseObject { + declare matrixWorld: MatrixLike; + declare matrixWorldInverse: MatrixLike; + + constructor() { + super(); + (this as any).matrixWorldInverse = new MatrixLike("inverse"); + (this as any).cameraReady = true; + } +} +TS + +cat >"$TMPDIR/perspective_like.ts" <<'TS' +import { CameraBase } from "./camera_base.ts"; +import { MatrixLike } from "./base_object.ts"; + +export class PerspectiveLike extends CameraBase { + constructor(fov: number, aspect: number, near: number, far: number) { + super(); + (this as any).isPerspectiveLike = true; + (this as any).projectionMatrix = new MatrixLike( + "projection:" + fov + ":" + aspect + ":" + near + ":" + far + ); + } +} +TS + +cat >"$TMPDIR/main.ts" <<'TS' +import { BaseObject } from "./base_object.ts"; +import { CameraBase } from "./camera_base.ts"; +import { PerspectiveLike } from "./perspective_like.ts"; + +const camera: any = new PerspectiveLike(50, 1, 0.1, 100); +console.log("isPerspective", camera.isPerspectiveLike === true); +console.log("instanceofCamera", camera instanceof CameraBase); +console.log("instanceofBase", camera instanceof BaseObject); +console.log("projection", camera.projectionMatrix.decompose()); +console.log("plainBase", camera.plainBase); +console.log("matrixWorld", camera.matrixWorld.decompose()); +console.log("update", camera.updateMatrixWorld(false)); +console.log("inverse", camera.matrixWorldInverse.decompose()); +TS + +cd "$TMPDIR" +"$PERRY" compile --no-cache --no-auto-optimize main.ts --output test_bin >/dev/null +RUN_OUTPUT="$(./test_bin 2>&1)" + +EXPECTED="isPerspective true +instanceofCamera true +instanceofBase true +projection decompose:projection:50:1:0.1:100 +plainBase base +matrixWorld decompose:world +update decompose:world:false +inverse decompose:inverse" + +if [[ "$RUN_OUTPUT" == "$EXPECTED" ]]; then + echo "PASS" + exit 0 +fi + +echo "FAIL: imported multi-level inherited constructor state was not preserved" +echo "Expected:" +echo "$EXPECTED" +echo "" +echo "Got:" +echo "$RUN_OUTPUT" +exit 1 From 8a30448ce69f751ad647546a4aa6412dc20b3d60 Mon Sep 17 00:00:00 2001 From: Andrew DiZenzo Date: Mon, 15 Jun 2026 22:42:04 -0500 Subject: [PATCH 2/9] preserve imported accessor kind metadata --- crates/perry-hir/src/class_accessors.rs | 44 +++++++++++++++ crates/perry-hir/src/lib.rs | 2 + crates/perry-hir/src/lower/context.rs | 15 +++--- crates/perry-hir/src/lower/expr_assign.rs | 6 +-- crates/perry-hir/src/lower/lower_module_fn.rs | 4 +- .../perry-hir/src/lower/lowering_context.rs | 5 +- crates/perry-hir/src/lower_decl/class_decl.rs | 53 ++++++++++++------- .../perry/src/commands/compile/bootstrap.rs | 26 +++------ crates/perry/src/commands/compile/types.rs | 5 +- ...imported_inherited_accessor_datatexture.sh | 16 ++++++ 10 files changed, 123 insertions(+), 53 deletions(-) create mode 100644 crates/perry-hir/src/class_accessors.rs diff --git a/crates/perry-hir/src/class_accessors.rs b/crates/perry-hir/src/class_accessors.rs new file mode 100644 index 0000000000..54f191a4cc --- /dev/null +++ b/crates/perry-hir/src/class_accessors.rs @@ -0,0 +1,44 @@ +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ClassAccessorNames { + pub getter_names: Vec, + pub setter_names: Vec, +} + +impl ClassAccessorNames { + pub fn is_empty(&self) -> bool { + self.getter_names.is_empty() && self.setter_names.is_empty() + } + + pub fn contains_any(&self, name: &str) -> bool { + self.getter_names.iter().any(|n| n == name) || self.setter_names.iter().any(|n| n == name) + } + + pub fn insert_getter(&mut self, name: String) -> bool { + if self.getter_names.iter().any(|n| n == &name) { + false + } else { + self.getter_names.push(name); + true + } + } + + pub fn insert_setter(&mut self, name: String) -> bool { + if self.setter_names.iter().any(|n| n == &name) { + false + } else { + self.setter_names.push(name); + true + } + } + + pub fn extend_from(&mut self, other: &Self) -> bool { + let mut changed = false; + for name in &other.getter_names { + changed |= self.insert_getter(name.clone()); + } + for name in &other.setter_names { + changed |= self.insert_setter(name.clone()); + } + changed + } +} diff --git a/crates/perry-hir/src/lib.rs b/crates/perry-hir/src/lib.rs index 91dc6e07dc..7fd4f56293 100644 --- a/crates/perry-hir/src/lib.rs +++ b/crates/perry-hir/src/lib.rs @@ -6,6 +6,7 @@ pub mod analysis; pub mod audit; pub mod capability; +mod class_accessors; pub mod deferral; pub(crate) mod destructuring; pub mod dynamic_import; @@ -28,6 +29,7 @@ pub mod walker; pub use analysis::{collect_local_refs_expr, collect_local_refs_stmt}; pub use audit::{audit_module, AuditManifest, ModuleAudit}; pub use capability::{audit_module_capabilities, CapabilityPolicy, CapabilityViolation}; +pub use class_accessors::ClassAccessorNames; pub use deferral::{arm_deferral_sink, disarm_deferral_sink, try_defer_refusal, DeferredRefusal}; pub use dynamic_import::{ collect_dynamic_import_local_candidate_literals, collect_dynamic_import_param_literals, diff --git a/crates/perry-hir/src/lower/context.rs b/crates/perry-hir/src/lower/context.rs index 3494d4c5e9..6c5ffb7511 100644 --- a/crates/perry-hir/src/lower/context.rs +++ b/crates/perry-hir/src/lower/context.rs @@ -441,14 +441,14 @@ impl LoweringContext { .map(|(_, f)| f.as_slice()) } - /// Issue #665: register the getter+setter property names for a class. + /// Issue #665: register getter and setter property names for a class. /// Mirrors `register_class_field_names`; consumed by the ctor-body /// field-detection pass to skip names that are accessors. Stored as the /// own+inherited union so a child lookup sees the full chain in one hop. pub(crate) fn register_class_accessor_names( &mut self, class_name: String, - accessor_names: Vec, + accessor_names: crate::ClassAccessorNames, ) { if let Some(entry) = self .class_accessor_names @@ -461,15 +461,18 @@ impl LoweringContext { } } - /// Look up the accessor (getter+setter) property names registered for a + /// Look up the accessor property names registered for a /// class. The stored list includes inherited accessors (mirroring how /// `class_field_names` stores the own+inherited union), so callers do /// not need to walk the parent chain themselves. - pub(crate) fn lookup_class_accessor_names(&self, class_name: &str) -> Option<&[String]> { + pub(crate) fn lookup_class_accessor_names( + &self, + class_name: &str, + ) -> Option<&crate::ClassAccessorNames> { self.class_accessor_names .iter() .find(|(n, _)| n == class_name) - .map(|(_, f)| f.as_slice()) + .map(|(_, f)| f) } /// Issue #302: register declared field types for a class (parallel to @@ -523,7 +526,7 @@ impl LoweringContext { /// inference avoid creating data slots for inherited imported accessors. pub fn seed_imported_class_accessors( &mut self, - seeds: &std::collections::HashMap>, + seeds: &std::collections::HashMap, ) { for (name, accessors) in seeds { if !self.class_accessor_names.iter().any(|(n, _)| n == name) { diff --git a/crates/perry-hir/src/lower/expr_assign.rs b/crates/perry-hir/src/lower/expr_assign.rs index fb7f1d1a9f..ae98859cf0 100644 --- a/crates/perry-hir/src/lower/expr_assign.rs +++ b/crates/perry-hir/src/lower/expr_assign.rs @@ -643,9 +643,9 @@ fn lower_assignment_target( { None } else if ctx.lookup_class(&cls_name).is_some() - && ctx.lookup_class_accessor_names(&cls_name).is_some_and( - |names| names.iter().any(|n| n == &method_name), - ) + && ctx + .lookup_class_accessor_names(&cls_name) + .is_some_and(|names| names.contains_any(&method_name)) { // `C.prototype. = v` where `` // is a `set`/`get` declared on the class is an diff --git a/crates/perry-hir/src/lower/lower_module_fn.rs b/crates/perry-hir/src/lower/lower_module_fn.rs index c1465c31c7..b1b7393dc5 100644 --- a/crates/perry-hir/src/lower/lower_module_fn.rs +++ b/crates/perry-hir/src/lower/lower_module_fn.rs @@ -285,7 +285,7 @@ pub fn lower_module_with_class_id_types_seed_and_entry( start_class_id: ClassId, resolved_types: Option>, imported_class_fields: Option<&std::collections::HashMap>>, - imported_class_accessors: Option<&std::collections::HashMap>>, + imported_class_accessors: Option<&std::collections::HashMap>, is_entry_module: bool, ) -> Result<(Module, ClassId)> { lower_module_full( @@ -338,7 +338,7 @@ pub fn lower_module_full( start_class_id: ClassId, resolved_types: Option>, imported_class_fields: Option<&std::collections::HashMap>>, - imported_class_accessors: Option<&std::collections::HashMap>>, + imported_class_accessors: Option<&std::collections::HashMap>, is_entry_module: bool, is_external_module: bool, ) -> Result<(Module, ClassId)> { diff --git a/crates/perry-hir/src/lower/lowering_context.rs b/crates/perry-hir/src/lower/lowering_context.rs index 8f76971f15..e79dd56973 100644 --- a/crates/perry-hir/src/lower/lowering_context.rs +++ b/crates/perry-hir/src/lower/lowering_context.rs @@ -10,6 +10,7 @@ use perry_types::{FuncId, GlobalId, LocalId, Type, TypeParam}; use std::collections::{HashMap, HashSet}; use crate::ir::*; +use crate::ClassAccessorNames; #[derive(Debug, Clone, Copy)] pub(crate) struct WithEnvFrame { @@ -106,7 +107,7 @@ pub struct LoweringContext { /// avoiding the creation of shadow fields that cause later index shift bugs after /// inheritance resolution in codegen. pub(crate) class_field_names: Vec<(String, Vec)>, - /// Issue #665 (sixth pass): per-class set of getter+setter property names. + /// Issue #665 (sixth pass): per-class set of getter and setter property names. /// Used by the "infer fields from ctor body `this.x = ...`" pass to avoid /// mis-categorising a setter assignment as an own data field — the /// rate-limiter-flexible `set points(v)` / `this.points = opts.points` @@ -116,7 +117,7 @@ pub struct LoweringContext { /// Populated alongside `register_class_field_names`; looked up via /// `lookup_class_accessor_names` and walked across the parent chain when /// processing a subclass's ctor body. - pub(crate) class_accessor_names: Vec<(String, Vec)>, + pub(crate) class_accessor_names: Vec<(String, ClassAccessorNames)>, /// Issue #562: class name → `(module, class)` tuple from /// `native_extends`. Populated when lowering each class, consumed by /// `destructuring.rs` to register `let x = new SubclassOfStream()` diff --git a/crates/perry-hir/src/lower_decl/class_decl.rs b/crates/perry-hir/src/lower_decl/class_decl.rs index 500a74e01e..afcc750a66 100644 --- a/crates/perry-hir/src/lower_decl/class_decl.rs +++ b/crates/perry-hir/src/lower_decl/class_decl.rs @@ -997,8 +997,7 @@ pub fn lower_class_decl( // accessor when a subclass instance's `.points` was read across // modules (the runtime's setter dispatch walks the class vtable // chain correctly, but the spurious own-data slot wins lookup). - let mut accessor_names: std::collections::HashSet = - std::collections::HashSet::new(); + let mut accessor_names = crate::ClassAccessorNames::default(); for member in &class_decl.class.body { match member { ast::ClassMember::Method(m) @@ -1010,12 +1009,29 @@ pub fn lower_class_decl( ast::PropName::Num(n) => crate::lower::number_to_js_key(n.value), _ => continue, }; - accessor_names.insert(key); + match m.kind { + ast::MethodKind::Getter => { + accessor_names.insert_getter(key); + } + ast::MethodKind::Setter => { + accessor_names.insert_setter(key); + } + _ => {} + } } ast::ClassMember::PrivateMethod(m) if matches!(m.kind, ast::MethodKind::Getter | ast::MethodKind::Setter) => { - accessor_names.insert(format!("#{}", m.key.name)); + let key = format!("#{}", m.key.name); + match m.kind { + ast::MethodKind::Getter => { + accessor_names.insert_getter(key); + } + ast::MethodKind::Setter => { + accessor_names.insert_setter(key); + } + _ => {} + } } _ => {} } @@ -1025,9 +1041,7 @@ pub fn lower_class_decl( // on the direct parent suffices. if let Some(ref parent_name) = extends_name { if let Some(parent_accessors) = ctx.lookup_class_accessor_names(parent_name) { - for a in parent_accessors { - accessor_names.insert(a.clone()); - } + accessor_names.extend_from(parent_accessors); } } @@ -1074,7 +1088,7 @@ pub fn lower_class_decl( let fname = prop_ident.sym.to_string(); if !declared_field_names.contains(&fname) && !inherited_field_names.contains(&fname) - && !accessor_names.contains(&fname) + && !accessor_names.contains_any(&fname) && !method_names.contains(&fname) { fields.push(ClassField { @@ -1112,10 +1126,9 @@ pub fn lower_class_decl( // Issue #665: register own+inherited accessor names so subclasses // lowered after this one can also skip them when scanning ctor - // bodies. `accessor_names` already contains the union from the - // parent-chain lookup above. - let accessor_list: Vec = accessor_names.into_iter().collect(); - ctx.register_class_accessor_names(name.clone(), accessor_list); + // bodies. `accessor_names` already contains the getter/setter names + // from the parent-chain lookup above. + ctx.register_class_accessor_names(name.clone(), accessor_names); // Issue #302: also register field TYPES so the for-of arm can // detect `for (... of this.someMap)` patterns. Only own fields are @@ -1662,19 +1675,19 @@ pub fn lower_class_from_ast( // `var C = class { set ''(p){…} }; C.prototype[''] = v`) were silently // dropped to `RegisterPrototypeMethod`. Test262 accessor-name-inst setters. { - let mut accessor_names: std::collections::HashSet = - std::collections::HashSet::new(); - for (prop_name, _) in getters.iter().chain(setters.iter()) { - accessor_names.insert(prop_name.clone()); + let mut accessor_names = crate::ClassAccessorNames::default(); + for (prop_name, _) in &getters { + accessor_names.insert_getter(prop_name.clone()); + } + for (prop_name, _) in &setters { + accessor_names.insert_setter(prop_name.clone()); } if let Some(ref parent_name) = extends_name { if let Some(parent_accessors) = ctx.lookup_class_accessor_names(parent_name) { - for a in parent_accessors { - accessor_names.insert(a.clone()); - } + accessor_names.extend_from(parent_accessors); } } - ctx.register_class_accessor_names(name.to_string(), accessor_names.into_iter().collect()); + ctx.register_class_accessor_names(name.to_string(), accessor_names); } // Issue #740: synthesize __perry_cap_* capture machinery for class diff --git a/crates/perry/src/commands/compile/bootstrap.rs b/crates/perry/src/commands/compile/bootstrap.rs index def9e92f7d..f118fc86be 100644 --- a/crates/perry/src/commands/compile/bootstrap.rs +++ b/crates/perry/src/commands/compile/bootstrap.rs @@ -164,7 +164,7 @@ pub(super) fn rerun_collect_with_class_field_types( return Ok(()); } let mut field_map: HashMap> = HashMap::new(); - let mut accessor_map: HashMap> = HashMap::new(); + let mut accessor_map: HashMap = HashMap::new(); let mut parent_map: HashMap = HashMap::new(); for hir_module in ctx.native_modules.values() { for class in &hir_module.classes { @@ -174,17 +174,12 @@ pub(super) fn rerun_collect_with_class_field_types( .map(|f| (f.name.clone(), f.ty.clone())) .collect(); field_map.entry(class.name.clone()).or_insert(fields); - let mut accessors = Vec::new(); - let mut seen = HashSet::new(); - for name in class - .getters - .iter() - .map(|(n, _)| n) - .chain(class.setters.iter().map(|(n, _)| n)) - { - if seen.insert(name.clone()) { - accessors.push(name.clone()); - } + let mut accessors = perry_hir::ClassAccessorNames::default(); + for (name, _) in &class.getters { + accessors.insert_getter(name.clone()); + } + for (name, _) in &class.setters { + accessors.insert_setter(name.clone()); } accessor_map.entry(class.name.clone()).or_insert(accessors); if let Some(parent_name) = &class.extends_name { @@ -203,12 +198,7 @@ pub(super) fn rerun_collect_with_class_field_types( continue; } let entry = accessor_map.entry(class_name).or_default(); - for accessor in parent_accessors { - if !entry.contains(&accessor) { - entry.push(accessor); - changed = true; - } - } + changed |= entry.extend_from(&parent_accessors); } } if field_map.is_empty() && accessor_map.is_empty() { diff --git a/crates/perry/src/commands/compile/types.rs b/crates/perry/src/commands/compile/types.rs index 33d07466f2..c921356fe2 100644 --- a/crates/perry/src/commands/compile/types.rs +++ b/crates/perry/src/commands/compile/types.rs @@ -623,8 +623,9 @@ pub struct CompilationContext { /// Cross-module class accessor names collected alongside field types. /// HIR lowering uses this to avoid inferring subclass `this.x = ...` /// constructor writes as data fields when `x` is an inherited accessor - /// from an imported superclass. - pub cross_module_class_accessors: HashMap>, + /// from an imported superclass. Getter and setter names stay separate so + /// imported accessors preserve their JavaScript descriptor capabilities. + pub cross_module_class_accessors: HashMap, /// Minimum Windows version for `--target windows` builds. One of `"7"`, /// `"8"`, `"10"`. `"10"` (default) means "no subsystem version suffix"; /// `"7"` and `"8"` emit `,5.1` / `,6.02` on the linker `/SUBSYSTEM:` flag diff --git a/tests/test_imported_inherited_accessor_datatexture.sh b/tests/test_imported_inherited_accessor_datatexture.sh index 7a91f5b5b0..466f791d27 100755 --- a/tests/test_imported_inherited_accessor_datatexture.sh +++ b/tests/test_imported_inherited_accessor_datatexture.sh @@ -60,6 +60,14 @@ export class BaseTexture { this.source.data = value; } + get readOnlyTag(): string { + return "base-read-only"; + } + + set writeOnlyTag(value: string) { + this.source.writeOnlyTag = value; + } + set needsUpdate(value: boolean) { if (value === true) { this.version++; @@ -106,6 +114,7 @@ class DataTextureLike extends BaseTexture { this.generateMipmaps = false; this.flipY = false; this.unpackAlignment = 1; + this.writeOnlyTag = "ctor-setter"; } } @@ -119,17 +128,24 @@ assert(texture.image !== undefined, "inherited getter returned image"); assert(texture.image.data === payload, "payload identity preserved"); assert(texture.image.width === 2, "width preserved"); assert(texture.image.height === 1, "height preserved"); +assert(texture.readOnlyTag === "base-read-only", "getter-only inherited accessor read"); +assert(texture.source.writeOnlyTag === "ctor-setter", "setter-only inherited accessor constructor write"); assert(texture.magFilter === NearestFilter, "magFilter subclass default"); assert(texture.minFilter === NearestFilter, "minFilter subclass default"); assert(texture.generateMipmaps === false, "subclass generateMipmaps override"); assert(texture.flipY === false, "subclass flipY override"); assert(texture.unpackAlignment === 1, "subclass unpackAlignment override"); assert(!Object.keys(texture).includes("image"), "accessor write did not create own image slot"); +assert(!Object.keys(texture).includes("readOnlyTag"), "getter-only inherited accessor did not create own slot"); +assert(!Object.keys(texture).includes("writeOnlyTag"), "setter-only inherited accessor did not create own slot"); const replacement = { data: payload, width: 4, height: 3 }; texture.image = replacement; assert(texture.image === replacement, "inherited setter assignment round-trips"); +texture.writeOnlyTag = "post-constructor"; +assert(texture.source.writeOnlyTag === "post-constructor", "setter-only inherited accessor post-constructor write"); + texture.needsUpdate = true; assert(texture.version === 1, "needsUpdate increments texture version"); assert(texture.source.version === 1, "needsUpdate increments source version"); From cc39533bdb4be3ad2bfe2a72b9f525785eb1c9b9 Mon Sep 17 00:00:00 2001 From: Andrew DiZenzo Date: Mon, 15 Jun 2026 23:54:39 -0500 Subject: [PATCH 3/9] tighten native accessor metadata guards --- crates/perry-hir/src/lower/expr_assign.rs | 18 +++- crates/perry-hir/src/lower_decl/class_decl.rs | 97 ++++++++++--------- ...imported_inherited_accessor_datatexture.sh | 43 +++++++- 3 files changed, 107 insertions(+), 51 deletions(-) diff --git a/crates/perry-hir/src/lower/expr_assign.rs b/crates/perry-hir/src/lower/expr_assign.rs index ae98859cf0..c51bbca28b 100644 --- a/crates/perry-hir/src/lower/expr_assign.rs +++ b/crates/perry-hir/src/lower/expr_assign.rs @@ -623,6 +623,14 @@ fn lower_assignment_target( Class(String), Func(Expr), } + fn class_has_accessor( + ctx: &LoweringContext, + class_name: &str, + method_name: &str, + ) -> bool { + ctx.lookup_class_accessor_names(class_name) + .is_some_and(|names| names.contains_any(method_name)) + } let resolved: Option = match obj_unwrapped { // (a) .prototype. // .prototype. @@ -643,9 +651,7 @@ fn lower_assignment_target( { None } else if ctx.lookup_class(&cls_name).is_some() - && ctx - .lookup_class_accessor_names(&cls_name) - .is_some_and(|names| names.contains_any(&method_name)) + && class_has_accessor(ctx, &cls_name, &method_name) { // `C.prototype. = v` where `` // is a `set`/`get` declared on the class is an @@ -705,7 +711,11 @@ fn lower_assignment_target( let local_id = ctx.lookup_local(obj_ident.sym.as_ref()); if let Some(id) = local_id { if let Some(class_name) = ctx.prototype_aliases.get(&id).cloned() { - Some(ProtoOwner::Class(class_name)) + if class_has_accessor(ctx, &class_name, &method_name) { + None + } else { + Some(ProtoOwner::Class(class_name)) + } } else if let Some(func_id) = ctx.prototype_function_aliases.get(&id).copied() { diff --git a/crates/perry-hir/src/lower_decl/class_decl.rs b/crates/perry-hir/src/lower_decl/class_decl.rs index afcc750a66..b1331e2077 100644 --- a/crates/perry-hir/src/lower_decl/class_decl.rs +++ b/crates/perry-hir/src/lower_decl/class_decl.rs @@ -50,6 +50,55 @@ fn with_static_member_context( result } +fn runtime_instance_accessor_names(members: &[ast::ClassMember]) -> crate::ClassAccessorNames { + let mut accessor_names = crate::ClassAccessorNames::default(); + + for member in members { + match member { + ast::ClassMember::Method(m) + if !m.is_static + && m.function.body.is_some() + && matches!(m.kind, ast::MethodKind::Getter | ast::MethodKind::Setter) => + { + let key = match &m.key { + ast::PropName::Ident(i) => i.sym.to_string(), + ast::PropName::Str(s) => s.value.as_str().unwrap_or("").to_string(), + ast::PropName::Num(n) => crate::lower::number_to_js_key(n.value), + _ => continue, + }; + match m.kind { + ast::MethodKind::Getter => { + accessor_names.insert_getter(key); + } + ast::MethodKind::Setter => { + accessor_names.insert_setter(key); + } + _ => {} + } + } + ast::ClassMember::PrivateMethod(m) + if !m.is_static + && m.function.body.is_some() + && matches!(m.kind, ast::MethodKind::Getter | ast::MethodKind::Setter) => + { + let key = format!("#{}", m.key.name); + match m.kind { + ast::MethodKind::Getter => { + accessor_names.insert_getter(key); + } + ast::MethodKind::Setter => { + accessor_names.insert_setter(key); + } + _ => {} + } + } + _ => {} + } + } + + accessor_names +} + fn lower_generic_computed_class_member( ctx: &mut LoweringContext, method: &ast::ClassMethod, @@ -997,45 +1046,7 @@ pub fn lower_class_decl( // accessor when a subclass instance's `.points` was read across // modules (the runtime's setter dispatch walks the class vtable // chain correctly, but the spurious own-data slot wins lookup). - let mut accessor_names = crate::ClassAccessorNames::default(); - for member in &class_decl.class.body { - match member { - ast::ClassMember::Method(m) - if matches!(m.kind, ast::MethodKind::Getter | ast::MethodKind::Setter) => - { - let key = match &m.key { - ast::PropName::Ident(i) => i.sym.to_string(), - ast::PropName::Str(s) => s.value.as_str().unwrap_or("").to_string(), - ast::PropName::Num(n) => crate::lower::number_to_js_key(n.value), - _ => continue, - }; - match m.kind { - ast::MethodKind::Getter => { - accessor_names.insert_getter(key); - } - ast::MethodKind::Setter => { - accessor_names.insert_setter(key); - } - _ => {} - } - } - ast::ClassMember::PrivateMethod(m) - if matches!(m.kind, ast::MethodKind::Getter | ast::MethodKind::Setter) => - { - let key = format!("#{}", m.key.name); - match m.kind { - ast::MethodKind::Getter => { - accessor_names.insert_getter(key); - } - ast::MethodKind::Setter => { - accessor_names.insert_setter(key); - } - _ => {} - } - } - _ => {} - } - } + let mut accessor_names = runtime_instance_accessor_names(&class_decl.class.body); // Pull in accessor names from the parent chain. The parent's // registration stored the own+inherited union, so a single lookup // on the direct parent suffices. @@ -1675,13 +1686,7 @@ pub fn lower_class_from_ast( // `var C = class { set ''(p){…} }; C.prototype[''] = v`) were silently // dropped to `RegisterPrototypeMethod`. Test262 accessor-name-inst setters. { - let mut accessor_names = crate::ClassAccessorNames::default(); - for (prop_name, _) in &getters { - accessor_names.insert_getter(prop_name.clone()); - } - for (prop_name, _) in &setters { - accessor_names.insert_setter(prop_name.clone()); - } + let mut accessor_names = runtime_instance_accessor_names(&class.body); if let Some(ref parent_name) = extends_name { if let Some(parent_accessors) = ctx.lookup_class_accessor_names(parent_name) { accessor_names.extend_from(parent_accessors); diff --git a/tests/test_imported_inherited_accessor_datatexture.sh b/tests/test_imported_inherited_accessor_datatexture.sh index 466f791d27..cc86c35a15 100755 --- a/tests/test_imported_inherited_accessor_datatexture.sh +++ b/tests/test_imported_inherited_accessor_datatexture.sh @@ -75,10 +75,22 @@ export class BaseTexture { } } } + +export class AliasAccessorBase { + public marker: any = "unset"; + + get aliasedValue(): any { + return this.marker; + } + + set aliasedValue(value: any) { + this.marker = value; + } +} TS cat >"$TMPDIR/main.ts" <<'TS' -import { BaseTexture } from "./base_texture"; +import { AliasAccessorBase, BaseTexture } from "./base_texture"; const NearestFilter = 1003; @@ -139,6 +151,35 @@ assert(!Object.keys(texture).includes("image"), "accessor write did not create o assert(!Object.keys(texture).includes("readOnlyTag"), "getter-only inherited accessor did not create own slot"); assert(!Object.keys(texture).includes("writeOnlyTag"), "setter-only inherited accessor did not create own slot"); +class AliasAccessorChild extends AliasAccessorBase {} + +const aliasProto: any = AliasAccessorChild.prototype; +aliasProto.aliasedValue = function badAliasPatch() { + return "bad"; +}; + +const aliasInstance: any = new AliasAccessorChild(); +aliasInstance.aliasedValue = "instance-setter"; +assert(aliasInstance.aliasedValue === "instance-setter", "prototype alias write preserved accessor dispatch"); +assert(!Object.keys(aliasInstance).includes("aliasedValue"), "prototype alias accessor write did not create own slot"); + +class StaticAccessorPollution { + static get constructorSlot(): string { + return "static-slot"; + } + + static set constructorSlot(_value: string) {} + + constructor() { + (this as any).constructorSlot = "instance-slot"; + } +} + +const staticPollution: any = new StaticAccessorPollution(); +assert(StaticAccessorPollution.constructorSlot === "static-slot", "static accessor still works"); +assert(staticPollution.constructorSlot === "instance-slot", "static accessor did not suppress instance field"); +assert(Object.keys(staticPollution).includes("constructorSlot"), "instance constructor assignment remained an own field"); + const replacement = { data: payload, width: 4, height: 3 }; texture.image = replacement; assert(texture.image === replacement, "inherited setter assignment round-trips"); From 20c317c17b4235e22c566ac92a3e941af192e1af Mon Sep 17 00:00:00 2001 From: Andrew DiZenzo Date: Tue, 16 Jun 2026 10:54:49 -0500 Subject: [PATCH 4/9] fix cluster default emitter lowering --- crates/perry-codegen-arkts/src/tests.rs | 3 + .../tests/phase2_full_app_smoke.rs | 3 + .../src/lower_call/native_table/node_misc.rs | 99 +++++++++++++++++++ .../src/lower/expr_call/native_module.rs | 28 ++++++ 4 files changed, 133 insertions(+) diff --git a/crates/perry-codegen-arkts/src/tests.rs b/crates/perry-codegen-arkts/src/tests.rs index 3bdbd643eb..cd3ca4c380 100644 --- a/crates/perry-codegen-arkts/src/tests.rs +++ b/crates/perry-codegen-arkts/src/tests.rs @@ -960,6 +960,7 @@ fn state_method_call(state_id: u32, method: &str, args: Vec) -> Expr { }), args, type_args: vec![], + byte_offset: 0, } } @@ -2531,6 +2532,7 @@ fn issue_410_serialize_condition_fallback_has_no_block_comment_close() { callee: Box::new(Expr::LocalGet(99)), args: vec![], type_args: vec![], + byte_offset: 0, }; let s = serialize_condition(&unrecognized, &bindings, &consts); assert!( @@ -2762,6 +2764,7 @@ fn issue_410_conditional_modifier_chain_has_no_nested_block_comments() { callee: Box::new(Expr::LocalGet(999)), args: vec![], type_args: vec![], + byte_offset: 0, }, )); m.init.push(let_widget( diff --git a/crates/perry-codegen-arkts/tests/phase2_full_app_smoke.rs b/crates/perry-codegen-arkts/tests/phase2_full_app_smoke.rs index aa3f77d5ae..8cfd601ca1 100644 --- a/crates/perry-codegen-arkts/tests/phase2_full_app_smoke.rs +++ b/crates/perry-codegen-arkts/tests/phase2_full_app_smoke.rs @@ -149,6 +149,7 @@ fn full_phase2_app_emits_canonical_arkui() { }), args: vec![], type_args: vec![], + byte_offset: 0, }; // --- Phase 2 v6 + v5: Button with state.set + inline style --- @@ -160,6 +161,7 @@ fn full_phase2_app_emits_canonical_arkui() { }), args: vec![Expr::Number(1.0)], type_args: vec![], + byte_offset: 0, })]; let inc_button = nmc( "Button", @@ -523,6 +525,7 @@ fn minimal_counter_app_emits_clean_page() { }), args: vec![], type_args: vec![], + byte_offset: 0, }, nmc( "Button", diff --git a/crates/perry-codegen/src/lower_call/native_table/node_misc.rs b/crates/perry-codegen/src/lower_call/native_table/node_misc.rs index 5b0ee769ff..9363d80127 100644 --- a/crates/perry-codegen/src/lower_call/native_table/node_misc.rs +++ b/crates/perry-codegen/src/lower_call/native_table/node_misc.rs @@ -41,6 +41,105 @@ pub(super) const NODE_MISC_ROWS: &[NativeModSig] = &[ args: &[NA_F64], ret: NR_F64, }, + NativeModSig { + module: "cluster", + has_receiver: false, + method: "on", + class_filter: None, + runtime: "js_cluster_on", + args: &[NA_F64, NA_F64], + ret: NR_F64, + }, + NativeModSig { + module: "cluster", + has_receiver: false, + method: "addListener", + class_filter: None, + runtime: "js_cluster_on", + args: &[NA_F64, NA_F64], + ret: NR_F64, + }, + NativeModSig { + module: "cluster", + has_receiver: false, + method: "once", + class_filter: None, + runtime: "js_cluster_once", + args: &[NA_F64, NA_F64], + ret: NR_F64, + }, + NativeModSig { + module: "cluster", + has_receiver: false, + method: "prependListener", + class_filter: None, + runtime: "js_cluster_prepend_listener", + args: &[NA_F64, NA_F64], + ret: NR_F64, + }, + NativeModSig { + module: "cluster", + has_receiver: false, + method: "prependOnceListener", + class_filter: None, + runtime: "js_cluster_prepend_once_listener", + args: &[NA_F64, NA_F64], + ret: NR_F64, + }, + NativeModSig { + module: "cluster", + has_receiver: false, + method: "emit", + class_filter: None, + runtime: "js_cluster_emit", + args: &[NA_F64, NA_VARARGS], + ret: NR_F64, + }, + NativeModSig { + module: "cluster", + has_receiver: false, + method: "eventNames", + class_filter: None, + runtime: "js_cluster_event_names", + args: &[], + ret: NR_F64, + }, + NativeModSig { + module: "cluster", + has_receiver: false, + method: "listenerCount", + class_filter: None, + runtime: "js_cluster_listener_count", + args: &[NA_F64], + ret: NR_F64, + }, + NativeModSig { + module: "cluster", + has_receiver: false, + method: "removeListener", + class_filter: None, + runtime: "js_cluster_remove_listener", + args: &[NA_F64, NA_F64], + ret: NR_F64, + }, + NativeModSig { + module: "cluster", + has_receiver: false, + method: "off", + class_filter: None, + runtime: "js_cluster_remove_listener", + args: &[NA_F64, NA_F64], + ret: NR_F64, + }, + NativeModSig { + module: "cluster", + has_receiver: false, + method: "removeAllListeners", + class_filter: None, + runtime: "js_cluster_remove_all_listeners", + args: &[NA_F64], + ret: NR_F64, + }, // ========== node:vm ========== // Minimal contextification surface for APIs that require a vm context // object but do not execute code inside it yet. diff --git a/crates/perry-hir/src/lower/expr_call/native_module.rs b/crates/perry-hir/src/lower/expr_call/native_module.rs index aa972309c0..77e6540011 100644 --- a/crates/perry-hir/src/lower/expr_call/native_module.rs +++ b/crates/perry-hir/src/lower/expr_call/native_module.rs @@ -26,6 +26,22 @@ fn path_submodule_name(module_name: &str) -> Option<&'static str> { } } +fn is_cluster_default_event_emitter_method(method_name: &str) -> bool { + matches!( + method_name, + "on" | "addListener" + | "once" + | "prependListener" + | "prependOnceListener" + | "emit" + | "eventNames" + | "listenerCount" + | "removeListener" + | "off" + | "removeAllListeners" + ) +} + /// Peel runtime-transparent TypeScript wrappers (`as`, `as const`, `!`, /// `satisfies`, angle-bracket assertions, parens) off an expression so a /// cast receiver like `(Readable as any).toWeb(...)` still matches the @@ -1479,6 +1495,18 @@ pub(super) fn try_native_module_methods( } let normalized_module = module_name.strip_prefix("node:").unwrap_or(module_name); + if normalized_module == "cluster" + && matches!(imported_method, Some("default")) + && is_cluster_default_event_emitter_method(&method_name) + { + return Ok(Ok(Expr::NativeMethodCall { + module: module_name.to_string(), + class_name: None, + object: None, + method: method_name, + args, + })); + } if method_name == "call" { if normalized_module == "stream" && matches!(imported_method, None | Some("Stream")) From 6816c5cddd4df5c35dbd4ceb01dafa5f03bd4b35 Mon Sep 17 00:00:00 2001 From: Andrew DiZenzo Date: Tue, 16 Jun 2026 11:54:08 -0500 Subject: [PATCH 5/9] add reduced native class semantics regression --- tests/test_native_class_semantics_reduced.sh | 91 ++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100755 tests/test_native_class_semantics_reduced.sh diff --git a/tests/test_native_class_semantics_reduced.sh b/tests/test_native_class_semantics_reduced.sh new file mode 100755 index 0000000000..9dafc401a5 --- /dev/null +++ b/tests/test_native_class_semantics_reduced.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Reduced Three-style native class semantics coverage: +# - TypeScript `declare` class properties are type-only and must not create +# instance fields or static slots +# - an inherited setter-only `needsUpdate` accessor on a DataTexture-shaped +# module boundary increments source.version, while reads remain undefined + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" +if [[ ! -x "$PERRY" ]]; then PERRY="$REPO_ROOT/target/debug/perry"; fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat >"$TMPDIR/source.ts" <<'TS' +export class SourceLike { + version = 0; + + set needsUpdate(value: boolean) { + if (value === true) { + this.version++; + } + } +} +TS + +cat >"$TMPDIR/texture.ts" <<'TS' +import { SourceLike } from "./source.js"; + +export class TextureLike { + source = new SourceLike(); + + set needsUpdate(value: boolean) { + this.source.needsUpdate = value; + } +} +TS + +cat >"$TMPDIR/data_texture.ts" <<'TS' +import { TextureLike } from "./texture.js"; + +export class DataTextureLike extends TextureLike { + declare readonly isDataTexture: true; + declare static readonly DEFAULT_IMAGE: unknown; + + dataTag = "data-texture"; +} +TS + +cat >"$TMPDIR/main.ts" <<'TS' +import { DataTextureLike } from "./data_texture.js"; + +function hasOwn(value: any, key: string): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + +const texture: any = new DataTextureLike(); +if (texture.dataTag !== "data-texture") throw new Error("dataTag: " + texture.dataTag); +if (texture.source.version !== 0) throw new Error("initial version: " + texture.source.version); +if (texture.needsUpdate !== undefined) throw new Error("setter-only read should be undefined"); +texture.needsUpdate = true; +if (texture.source.version !== 1) throw new Error("version after true: " + texture.source.version); +texture.needsUpdate = false; +if (texture.source.version !== 1) throw new Error("version after false: " + texture.source.version); +texture.needsUpdate = true; +if (texture.source.version !== 2) throw new Error("version after second true: " + texture.source.version); +if (hasOwn(texture, "needsUpdate")) throw new Error("setter write created own data field"); +if (hasOwn(texture, "isDataTexture")) throw new Error("DataTexture declare field leaked"); +if (hasOwn(DataTextureLike, "DEFAULT_IMAGE")) throw new Error("DataTexture declare static leaked"); + +console.log("OK"); +TS + +OUT="$(PERRY_NO_AUTO_OPTIMIZE=1 "$PERRY" run "$TMPDIR/main.ts" 2>&1)" || { + echo "FAIL: perry run errored" + echo "$OUT" + exit 1 +} +if ! grep -q "^OK$" <<<"$OUT"; then + echo "FAIL: expected OK, got:" + echo "$OUT" + exit 1 +fi +echo "PASS: reduced native class semantics" From 99a47559b02e1cffd1baa8967f123bb7ea743d61 Mon Sep 17 00:00:00 2001 From: Andrew DiZenzo Date: Tue, 16 Jun 2026 14:37:08 -0500 Subject: [PATCH 6/9] fix inline require native os method lowering --- .../src/lower/expr_call/native_module.rs | 109 +++++++++++++----- 1 file changed, 79 insertions(+), 30 deletions(-) diff --git a/crates/perry-hir/src/lower/expr_call/native_module.rs b/crates/perry-hir/src/lower/expr_call/native_module.rs index 77e6540011..cd60061cd9 100644 --- a/crates/perry-hir/src/lower/expr_call/native_module.rs +++ b/crates/perry-hir/src/lower/expr_call/native_module.rs @@ -61,6 +61,32 @@ fn unwrap_ts_wrappers(e: &ast::Expr) -> &ast::Expr { } } +fn require_literal_native_module(ctx: &LoweringContext, expr: &ast::Expr) -> Option { + let ast::Expr::Call(call) = unwrap_ts_wrappers(expr) else { + return None; + }; + let ast::Callee::Expr(callee_expr) = &call.callee else { + return None; + }; + let ast::Expr::Ident(ident) = callee_expr.as_ref() else { + return None; + }; + if ident.sym.as_ref() != "require" + || ctx.lookup_local("require").is_some() + || ctx.lookup_func("require").is_some() + || ctx.lookup_imported_func("require").is_some() + || call.args.len() != 1 + || call.args[0].spread.is_some() + { + return None; + } + let ast::Expr::Lit(ast::Lit::Str(s)) = call.args[0].expr.as_ref() else { + return None; + }; + let spec = s.value.as_str().unwrap_or(""); + crate::destructuring::resolvable_native_module_for_spec(spec) +} + fn is_node_stream_class_name(name: &str) -> bool { matches!( name, @@ -97,6 +123,41 @@ fn event_emitter_constructor_call(args: Vec) -> Expr { Expr::Sequence(exprs) } +fn lower_os_module_method_call( + call: &ast::CallExpr, + method_name: &str, + args: &[Expr], +) -> Option { + match method_name { + "availableParallelism" => Some(Expr::OsAvailableParallelism), + "platform" => Some(Expr::OsPlatform), + "arch" => Some(Expr::OsArch), + "endianness" => Some(Expr::OsEndianness), + "hostname" => Some(Expr::OsHostname), + "homedir" => Some(Expr::OsHomedir), + "tmpdir" => Some(Expr::OsTmpdir), + "loadavg" => Some(Expr::OsLoadavg), + "machine" => Some(Expr::OsMachine), + "totalmem" => Some(Expr::OsTotalmem), + "freemem" => Some(Expr::OsFreemem), + "uptime" => Some(Expr::OsUptime), + "type" => Some(Expr::OsType), + "release" => Some(Expr::OsRelease), + "version" => Some(Expr::OsVersion), + "cpus" => Some(Expr::OsCpus), + "networkInterfaces" => Some(Expr::OsNetworkInterfaces), + "userInfo" => Some(user_info_expr_for_call(call, args.to_vec())), + "getPriority" | "setPriority" => Some(Expr::NativeMethodCall { + module: "os".to_string(), + class_name: None, + object: None, + method: method_name.to_string(), + args: args.to_vec(), + }), + _ => None, + } +} + pub(super) fn try_native_module_methods( ctx: &mut LoweringContext, call: &ast::CallExpr, @@ -105,6 +166,20 @@ pub(super) fn try_native_module_methods( ) -> Result>> { // Check for native module method calls (e.g., mysql.createConnection()) if let ast::Expr::Member(member) = expr { + // Inline `require("node:os").platform()` reaches this outer member + // call before the inner bare `require(...)` lowering can produce a + // NativeModuleRef. Recognize the same literal-native namespace shape + // here so it dispatches like `import * as os from "node:os"`. + if require_literal_native_module(ctx, member.obj.as_ref()).as_deref() == Some("os") { + if let ast::MemberProp::Ident(method_ident) = &member.prop { + if let Some(expr) = + lower_os_module_method_call(call, method_ident.sym.as_ref(), &args) + { + return Ok(Ok(expr)); + } + } + } + // #1534/#1540/#1541: the stream acceptance tests deliberately cast // the class / namespace before a static call — // `(Readable as any).isErrored(r)`, `(Readable as any).toWeb(r)`, @@ -516,36 +591,10 @@ pub(super) fn try_native_module_methods( obj_name == "os" || ctx.lookup_builtin_module_alias(&obj_name) == Some("os"); if is_os_module { if let ast::MemberProp::Ident(method_ident) = &member.prop { - let method_name = method_ident.sym.as_ref(); - match method_name { - "availableParallelism" => return Ok(Ok(Expr::OsAvailableParallelism)), - "platform" => return Ok(Ok(Expr::OsPlatform)), - "arch" => return Ok(Ok(Expr::OsArch)), - "endianness" => return Ok(Ok(Expr::OsEndianness)), - "hostname" => return Ok(Ok(Expr::OsHostname)), - "homedir" => return Ok(Ok(Expr::OsHomedir)), - "tmpdir" => return Ok(Ok(Expr::OsTmpdir)), - "loadavg" => return Ok(Ok(Expr::OsLoadavg)), - "machine" => return Ok(Ok(Expr::OsMachine)), - "totalmem" => return Ok(Ok(Expr::OsTotalmem)), - "freemem" => return Ok(Ok(Expr::OsFreemem)), - "uptime" => return Ok(Ok(Expr::OsUptime)), - "type" => return Ok(Ok(Expr::OsType)), - "release" => return Ok(Ok(Expr::OsRelease)), - "version" => return Ok(Ok(Expr::OsVersion)), - "cpus" => return Ok(Ok(Expr::OsCpus)), - "networkInterfaces" => return Ok(Ok(Expr::OsNetworkInterfaces)), - "userInfo" => return Ok(Ok(user_info_expr_for_call(call, args))), - "getPriority" | "setPriority" => { - return Ok(Ok(Expr::NativeMethodCall { - module: "os".to_string(), - class_name: None, - object: None, - method: method_name.to_string(), - args, - })); - } - _ => {} // Fall through to generic handling + if let Some(expr) = + lower_os_module_method_call(call, method_ident.sym.as_ref(), &args) + { + return Ok(Ok(expr)); } } } From ca075ce339934aa217b917dadf5708e8cc013a34 Mon Sep 17 00:00:00 2001 From: Andrew DiZenzo Date: Tue, 16 Jun 2026 15:32:34 -0500 Subject: [PATCH 7/9] fix cluster event emitter manifest drift --- crates/perry-api-manifest/src/entries.rs | 25 ++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/crates/perry-api-manifest/src/entries.rs b/crates/perry-api-manifest/src/entries.rs index 16f1ac838c..4ef4ec37d8 100644 --- a/crates/perry-api-manifest/src/entries.rs +++ b/crates/perry-api-manifest/src/entries.rs @@ -3777,18 +3777,23 @@ pub static API_MANIFEST: &[ApiEntry] = &[ property("cluster", "schedulingPolicy"), property("cluster", "SCHED_RR"), property("cluster", "SCHED_NONE"), - // `cluster.on` / `cluster.addListener` exist as EventEmitter - // prototype methods on the cluster module ITSELF in Node, but - // `import * as cluster from "node:cluster"` reads them as named - // exports — and there is no `on` / `addListener` named export. - // Node's parity fixture prints "undefined" for both. Register them - // as properties so the #463 strict gate doesn't bail out at compile - // time; `get_native_module_constant` returns `undefined` at - // runtime. // #3687: the EventEmitter method surface. On the `import * as` namespace // these all read `undefined` (they are not named exports); on the default - // import they resolve to bound methods. Registered so the #463 strict gate - // accepts reads/calls at compile time. + // import they resolve to bound methods through `NATIVE_MODULE_TABLE`. + internal_method("cluster", "on", false, None), + internal_method("cluster", "addListener", false, None), + internal_method("cluster", "once", false, None), + internal_method("cluster", "prependListener", false, None), + internal_method("cluster", "prependOnceListener", false, None), + internal_method("cluster", "emit", false, None), + internal_method("cluster", "eventNames", false, None), + internal_method("cluster", "listenerCount", false, None), + internal_method("cluster", "removeListener", false, None), + internal_method("cluster", "off", false, None), + internal_method("cluster", "removeAllListeners", false, None), + // Keep property reads registered so the #463 strict gate accepts the + // namespace-export shape; `get_native_module_constant` returns undefined + // for these names at runtime. internal_property("cluster", "on"), internal_property("cluster", "addListener"), internal_property("cluster", "once"), From 170a159376bddae9d4a19f8e60fee637950a85a8 Mon Sep 17 00:00:00 2001 From: Andrew DiZenzo Date: Tue, 16 Jun 2026 16:05:48 -0500 Subject: [PATCH 8/9] fix deep expression lowering stack guard --- crates/perry-hir/src/lower/lower_expr.rs | 10 +++++----- crates/perry-hir/src/lower/tests.rs | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/perry-hir/src/lower/lower_expr.rs b/crates/perry-hir/src/lower/lower_expr.rs index 5ae2de1ffe..2358d674b9 100644 --- a/crates/perry-hir/src/lower/lower_expr.rs +++ b/crates/perry-hir/src/lower/lower_expr.rs @@ -32,13 +32,13 @@ use crate::lower_types::extract_ts_type_with_ctx; /// thousands of nodes deep; left unguarded these overflow the stack and /// SIGABRT (exit 134) with no diagnostic at all. The compiler runs its /// collect/lower walk on a 128 MB stack (`perry-main`, see `crates/perry/ -/// src/main.rs`), and the heaviest shape (member chains) consumes on the -/// order of ~16 KB of stack per level, so this ceiling keeps worst-case -/// lowering depth well under ~32 MB — far below the stack limit — while still -/// sitting far above anything hand-written code or a reasonable build emits. +/// src/main.rs`). Keep the ceiling conservative so even high-overhead debug +/// and test builds trip the guard before the native stack is close to +/// exhaustion, while still sitting far above anything hand-written code or a +/// reasonable build emits. /// The only inputs it rejects are the degenerate ones that would otherwise /// crash, and they now get a clean "nested too deeply" diagnostic instead. -pub(crate) const MAX_EXPR_LOWER_DEPTH: u32 = 2000; +pub(crate) const MAX_EXPR_LOWER_DEPTH: u32 = 512; fn class_computed_member_registration_expr(class_name: &str, member: &ClassComputedMember) -> Expr { match member.kind { diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index 6df1695650..0b9d045cbb 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -294,20 +294,20 @@ fn assert_too_deep(source: String) { #[test] fn test_lower_rejects_deep_binary_chain() { - let n = (super::lower_expr::MAX_EXPR_LOWER_DEPTH as usize) * 3; + let n = (super::lower_expr::MAX_EXPR_LOWER_DEPTH as usize) + 1; let chain: Vec<&str> = vec!["1"; n]; assert_too_deep(format!("var x = {};\n", chain.join("+"))); } #[test] fn test_lower_rejects_deep_member_chain() { - let n = (super::lower_expr::MAX_EXPR_LOWER_DEPTH as usize) * 3; + let n = (super::lower_expr::MAX_EXPR_LOWER_DEPTH as usize) + 1; assert_too_deep(format!("var o = {{}};\nvar x = o{};\n", ".a".repeat(n))); } #[test] fn test_lower_rejects_deep_logical_chain() { - let n = (super::lower_expr::MAX_EXPR_LOWER_DEPTH as usize) * 3; + let n = (super::lower_expr::MAX_EXPR_LOWER_DEPTH as usize) + 1; let chain: Vec<&str> = vec!["a"; n]; assert_too_deep(format!("var a = 0;\nvar x = {};\n", chain.join("||"))); } From 2e7711d42c1303e79bde1fcb254e0ef65f69fe95 Mon Sep 17 00:00:00 2001 From: Andrew DiZenzo Date: Tue, 16 Jun 2026 16:56:20 -0500 Subject: [PATCH 9/9] fix deep lowering stack growth for shape inference --- Cargo.lock | 1 + Cargo.toml | 1 + crates/perry-hir/Cargo.toml | 1 + crates/perry-hir/src/lower/lower_expr.rs | 46 ++++++++++++------- .../perry-hir/src/lower/lowering_context.rs | 9 ++-- crates/perry-hir/src/lower/tests.rs | 14 +++--- crates/perry-hir/src/lower_types.rs | 8 ++++ 7 files changed, 52 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a3a5261d09..2a390d9788 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5873,6 +5873,7 @@ dependencies = [ "perry-ui-model", "serde", "serde_json", + "stacker", "swc_common", "swc_ecma_ast", "thiserror 1.0.69", diff --git a/Cargo.toml b/Cargo.toml index 8847c6979b..2230e1dce0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -241,6 +241,7 @@ clap = { version = "4.5", features = ["derive"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" toml = "1.1" +stacker = "0.1.24" # JWT — jsonwebtoken 10.x requires explicitly selecting a crypto backend # (`rust_crypto` or `aws_lc_rs`) and opting into PEM parsing for diff --git a/crates/perry-hir/Cargo.toml b/crates/perry-hir/Cargo.toml index b9c1c87d80..4c7bcb133a 100644 --- a/crates/perry-hir/Cargo.toml +++ b/crates/perry-hir/Cargo.toml @@ -22,3 +22,4 @@ thiserror.workspace = true anyhow.workspace = true serde = { workspace = true } serde_json = { workspace = true } +stacker.workspace = true diff --git a/crates/perry-hir/src/lower/lower_expr.rs b/crates/perry-hir/src/lower/lower_expr.rs index 2358d674b9..3421423efb 100644 --- a/crates/perry-hir/src/lower/lower_expr.rs +++ b/crates/perry-hir/src/lower/lower_expr.rs @@ -23,22 +23,23 @@ use super::*; use crate::ir::*; use crate::lower_types::extract_ts_type_with_ctx; -/// Maximum `lower_expr` recursion depth before lowering bails with a +/// Maximum overall `lower_expr` recursion depth before lowering bails with a /// diagnostic instead of overflowing the native stack (#5259). /// -/// Expression lowering is recursive: a left-nested `a+b+c+…` chain, an -/// `o.a.a.…a` member chain, or an `a||a||…` logical chain each recurses once -/// per operator/segment. Bundler/minifier output occasionally emits chains -/// thousands of nodes deep; left unguarded these overflow the stack and -/// SIGABRT (exit 134) with no diagnostic at all. The compiler runs its -/// collect/lower walk on a 128 MB stack (`perry-main`, see `crates/perry/ -/// src/main.rs`). Keep the ceiling conservative so even high-overhead debug -/// and test builds trip the guard before the native stack is close to -/// exhaustion, while still sitting far above anything hand-written code or a -/// reasonable build emits. -/// The only inputs it rejects are the degenerate ones that would otherwise -/// crash, and they now get a clean "nested too deeply" diagnostic instead. -pub(crate) const MAX_EXPR_LOWER_DEPTH: u32 = 512; +/// Object-literal lowering intentionally supports very deep nested shapes +/// (see `nested_object_literal_lowers_in_linear_time`). Keep this broad cap +/// high enough for those fixtures; stack-heavy chain forms are guarded by the +/// lower `MAX_EXPR_CHAIN_LOWER_DEPTH` limit below. +pub(crate) const MAX_EXPR_LOWER_DEPTH: u32 = 8192; + +/// Stack-heavy expression chains (`1+1+…`, `o.a.a.…`, `a||a||…`) recurse with +/// larger lowerer frames than object literals. This lower shape-specific cap +/// converts degenerate chain input into a diagnostic before debug/CI stacks can +/// overflow, without rejecting supported deep object-literal fixtures. +pub(crate) const MAX_EXPR_CHAIN_LOWER_DEPTH: u32 = 512; + +const EXPR_LOWER_STACK_RED_ZONE: usize = 256 * 1024; +const EXPR_LOWER_STACK_SEGMENT: usize = 2 * 1024 * 1024; fn class_computed_member_registration_expr(class_name: &str, member: &ClassComputedMember) -> Expr { match member.kind { @@ -490,6 +491,10 @@ pub(crate) fn native_module_binding_value(ctx: &LoweringContext, name: &str) -> Expr::NativeModuleRef(module_name.to_string()) } +fn expr_uses_stack_heavy_chain_lowering(expr: &ast::Expr) -> bool { + matches!(expr, ast::Expr::Bin(_) | ast::Expr::Member(_)) +} + pub(crate) fn lower_expr(ctx: &mut LoweringContext, expr: &ast::Expr) -> Result { // #5259: guard the recursive descent. Without this, a pathologically // nested expression (`1+1+…`, `o.a.a.…`, `a||a||…`) overflows the native @@ -498,16 +503,23 @@ pub(crate) fn lower_expr(ctx: &mut LoweringContext, expr: &ast::Expr) -> Result< // including the error returns inside `lower_expr_impl`, so a recoverable // lowering error elsewhere doesn't leave the depth permanently inflated. ctx.expr_lower_depth += 1; - if ctx.expr_lower_depth > MAX_EXPR_LOWER_DEPTH { + let max_depth = if expr_uses_stack_heavy_chain_lowering(expr) { + MAX_EXPR_CHAIN_LOWER_DEPTH + } else { + MAX_EXPR_LOWER_DEPTH + }; + if ctx.expr_lower_depth > max_depth { ctx.expr_lower_depth -= 1; crate::lower_bail!( expr.span(), "expression nested too deeply (exceeded {} levels); split the \ chain across statements or intermediate variables", - MAX_EXPR_LOWER_DEPTH + max_depth ); } - let result = lower_expr_impl(ctx, expr); + let result = stacker::maybe_grow(EXPR_LOWER_STACK_RED_ZONE, EXPR_LOWER_STACK_SEGMENT, || { + lower_expr_impl(ctx, expr) + }); ctx.expr_lower_depth -= 1; result } diff --git a/crates/perry-hir/src/lower/lowering_context.rs b/crates/perry-hir/src/lower/lowering_context.rs index 1fd0f430f6..c68fe34d60 100644 --- a/crates/perry-hir/src/lower/lowering_context.rs +++ b/crates/perry-hir/src/lower/lowering_context.rs @@ -612,9 +612,10 @@ pub struct LoweringContext { /// `lower_module_full`; consumed by `const_fold_fn`. pub(crate) fn_ctor_env: super::fn_ctor_env::FnCtorEnv, /// Current recursion depth of `lower_expr` (#5259). Incremented on entry, - /// decremented on exit. Once it exceeds `MAX_EXPR_LOWER_DEPTH`, lowering - /// bails with a clean "nested too deeply" diagnostic instead of letting a - /// pathologically-nested expression chain (bundler/minifier output like - /// `1+1+…+1` or `o.a.a.…a`) overflow the native stack and SIGABRT. + /// decremented on exit. Once it exceeds either the broad + /// `MAX_EXPR_LOWER_DEPTH` ceiling or the lower stack-heavy chain ceiling, + /// lowering bails with a clean "nested too deeply" diagnostic instead of + /// letting pathologically-nested expressions overflow the native stack and + /// SIGABRT. pub(crate) expr_lower_depth: u32, } diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index 0b9d045cbb..95dd8c0f6e 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -275,9 +275,9 @@ fn run_with_large_stack(f: F) { /// #5259: deeply-nested expression chains must surface a diagnostic instead /// of overflowing the native stack and SIGABRT-ing the whole process. Each -/// shape (binary `1+1+…`, member `o.a.a.…`, logical `a||a||…`) recurses once -/// per node in `lower_expr`; past `MAX_EXPR_LOWER_DEPTH` lowering bails with a -/// "nested too deeply" error rather than recursing further. +/// shape (binary `1+1+...`, member `o.a.a....`, logical `a||a||...`) recurses +/// once per node in `lower_expr`; past `MAX_EXPR_CHAIN_LOWER_DEPTH` lowering +/// bails with a "nested too deeply" error rather than recursing further. fn assert_too_deep(source: String) { run_with_large_stack(move || { let module = @@ -294,20 +294,20 @@ fn assert_too_deep(source: String) { #[test] fn test_lower_rejects_deep_binary_chain() { - let n = (super::lower_expr::MAX_EXPR_LOWER_DEPTH as usize) + 1; + let n = (super::lower_expr::MAX_EXPR_CHAIN_LOWER_DEPTH as usize) + 2; let chain: Vec<&str> = vec!["1"; n]; assert_too_deep(format!("var x = {};\n", chain.join("+"))); } #[test] fn test_lower_rejects_deep_member_chain() { - let n = (super::lower_expr::MAX_EXPR_LOWER_DEPTH as usize) + 1; + let n = (super::lower_expr::MAX_EXPR_CHAIN_LOWER_DEPTH as usize) + 1; assert_too_deep(format!("var o = {{}};\nvar x = o{};\n", ".a".repeat(n))); } #[test] fn test_lower_rejects_deep_logical_chain() { - let n = (super::lower_expr::MAX_EXPR_LOWER_DEPTH as usize) + 1; + let n = (super::lower_expr::MAX_EXPR_CHAIN_LOWER_DEPTH as usize) + 2; let chain: Vec<&str> = vec!["a"; n]; assert_too_deep(format!("var a = 0;\nvar x = {};\n", chain.join("||"))); } @@ -317,7 +317,7 @@ fn test_lower_rejects_deep_logical_chain() { #[test] fn test_lower_accepts_chain_under_limit() { run_with_large_stack(|| { - let n = (super::lower_expr::MAX_EXPR_LOWER_DEPTH as usize) / 2; + let n = (super::lower_expr::MAX_EXPR_CHAIN_LOWER_DEPTH as usize) / 2; let chain: Vec<&str> = vec!["1"; n]; let source = format!("var x = {};\n", chain.join("+")); let module = perry_parser::parse_typescript(&source, "ok.ts").expect("parses"); diff --git a/crates/perry-hir/src/lower_types.rs b/crates/perry-hir/src/lower_types.rs index 8e95ee3b30..b7686d7c2b 100644 --- a/crates/perry-hir/src/lower_types.rs +++ b/crates/perry-hir/src/lower_types.rs @@ -273,6 +273,8 @@ fn url_encoding_constructor_type(ctx: &LoweringContext, callee: &ast::Expr) -> O /// source never nests literals this deep, so the cap loses no practical /// precision while keeping pathological/minified inputs tractable. const INFER_TYPE_RECURSION_CAP: u32 = 48; +const INFER_TYPE_STACK_RED_ZONE: usize = 256 * 1024; +const INFER_TYPE_STACK_SEGMENT: usize = 2 * 1024 * 1024; pub(crate) fn infer_type_from_expr(expr: &ast::Expr, ctx: &LoweringContext) -> Type { thread_local! { @@ -294,6 +296,12 @@ pub(crate) fn infer_type_from_expr(expr: &ast::Expr, ctx: &LoweringContext) -> T return Type::Any; } + stacker::maybe_grow(INFER_TYPE_STACK_RED_ZONE, INFER_TYPE_STACK_SEGMENT, || { + infer_type_from_expr_inner(expr, ctx) + }) +} + +fn infer_type_from_expr_inner(expr: &ast::Expr, ctx: &LoweringContext) -> Type { match expr { // Literals ast::Expr::Lit(lit) => match lit {