diff --git a/changelog.d/8165-class-typed-parameter-guards.md b/changelog.d/8165-class-typed-parameter-guards.md new file mode 100644 index 0000000000..604cf91f80 --- /dev/null +++ b/changelog.d/8165-class-typed-parameter-guards.md @@ -0,0 +1,42 @@ +### Performance + +- Admit class-typed parameters into the #8094 guarded specialization path. A + class descriptor now carries the class id — giving + `param_type_guard.rs`'s `class_chain_reaches` branch its first caller, which + codegen had never reached — plus every declared field on the inheritance + chain, validated by name. A class-annotated parameter recovers the same + lowering an interface-annotated one already got (`js_dynamic_string_or_ + number_add` becomes a string concat), while a structurally identical object + literal fails the identity check and takes the generic fallback (#8099). + + The refusal this replaces rested on a stale claim that compact class + instances carry no `keys_array`. They do — + `object_alloc_class_inline_keys_impl` installs a per-class array built once + at module init — so the same by-name field validation that serves interfaces + serves classes. The stale note on `ObjectHeader::keys_array` is corrected + too. + + Identity **without** the field types was implemented, measured and reverted: + the emitted clone came out structurally identical to the `$generic` sibling + it routes around (same line count, same call multiset), because a + class-annotated receiver already reaches the class-field guard path without + any parameter evidence. It bought nothing and cost one `js_param_type_guard` + call per invocation — `tree` 1.089 s → 1.646 s (+51%) and `tree_wide` + 1.775 s → 2.304 s (+30%), best-of-5 on the quiet M1 mini. That also answers + the `tree` row #8099 was filed about: the hot recursive walker is refused by + #8094's aliasing rule, not by the class refusal, and the only descriptor + cheap enough to admit there is the one that buys nothing. + + Cost stays bounded by the existing rule rather than a new one: a + field-bearing descriptor claims heap contents, so a reference-typed + parameter carrying one is refused in any body containing a call. A recursive + class (`Tree.left: Tree`) therefore cannot be guarded inside the recursive + walker that would make its validation O(nodes x depth). + + Validated: the 19-program specialization corpus emits **identical LLVM IR** + before and after, so nothing already-specializing moved; the extended + `test_gap_specabi_ordinary_param_guards` fixture is byte-exact against node + `v26.5.1`, including under `PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1` + (40 copying minors, 40 from-space protections — the instrument was live); + and `cargo test -p perry-codegen` adds no failure against a clean-`main` + baseline run. diff --git a/crates/perry-codegen/src/codegen/ordinary_param_guard_tests.rs b/crates/perry-codegen/src/codegen/ordinary_param_guard_tests.rs index f3cb6844aa..1b7a69533d 100644 --- a/crates/perry-codegen/src/codegen/ordinary_param_guard_tests.rs +++ b/crates/perry-codegen/src/codegen/ordinary_param_guard_tests.rs @@ -565,3 +565,182 @@ fn guarded_discriminant_branch_narrows_a_union_parameter_inside_the_clone() { "the recursive walker must stay on the unguarded body:\n{recursive}" ); } + +/// #8099: a CLASS-typed parameter is guarded exactly like an interface-typed +/// one, and the pair of bodies is the subject. +/// +/// The refusal this replaces (`param_guard.rs::build_named`) said compact class +/// instances expose no `keys_array` to validate declared fields against. They +/// do — `object_alloc_class_inline_keys_impl` installs a per-class array built +/// once at module init — so the same by-name field validation that serves +/// interfaces serves classes, plus a `class_chain_reaches` identity check no +/// structural type can satisfy. +/// +/// The `Named("Label")` receiver in the clone is what turns the dynamic add +/// into a string concat. Identity WITHOUT the fields was tried first and +/// reverted: with an empty field list the clone came out structurally +/// identical to the `$generic` sibling it routes around (same line count, same +/// call multiset), because a class-annotated receiver already reaches the +/// class-field guard path without any parameter proof. Asserting the two +/// bodies DIFFER is therefore not decoration — it is the only thing that +/// distinguishes this from a clone that costs a guard call and buys nothing. +#[test] +fn a_class_parameter_is_guarded_by_identity_and_declared_fields() { + let label = perry_hir::Class { + id: 41, + name: "Label".to_string(), + type_params: Vec::new(), + extends: None, + extends_name: None, + native_extends: None, + extends_expr: None, + heritage_lexically_shadowed: false, + fields: vec![ + perry_hir::ClassField { + name: "label".to_string(), + key_expr: None, + ty: Type::String, + init: None, + is_private: false, + is_readonly: false, + decorators: Vec::new(), + }, + perry_hir::ClassField { + name: "count".to_string(), + key_expr: None, + ty: Type::Number, + init: None, + is_private: false, + is_readonly: false, + decorators: Vec::new(), + }, + ], + constructor: None, + methods: Vec::new(), + getters: Vec::new(), + setters: Vec::new(), + static_accessor_names: Vec::new(), + static_accessor_fn_ids: Vec::new(), + static_fields: Vec::new(), + static_methods: Vec::new(), + computed_members: Vec::new(), + decorators: Vec::new(), + is_exported: false, + is_nested: false, + alloc_width_hint: 0, + specialized_from: None, + aliases: Vec::new(), + }; + let render = Function { + id: 42, + name: "renderLabel".to_string(), + type_params: Vec::new(), + params: vec![Param { + id: 420, + name: "payload".to_string(), + ty: Type::Named("Label".to_string()), + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + }], + return_type: Type::Any, + // `result` is `any` and reassigned, so it carries no proof of its own + // and the ADD is what the parameter proof has to reach through. A + // simpler `payload.label + "!"` is measurably vacuous here: the + // class-field typed-feedback path already resolves that one without + // any parameter evidence, and the two bodies come out identical. + body: vec![ + Stmt::Let { + id: 421, + name: "result".to_string(), + ty: Type::Any, + mutable: true, + init: Some(Expr::PropertyGet { + object: Box::new(Expr::LocalGet(420)), + property: "label".to_string(), + byte_offset: 0, + }), + }, + Stmt::Expr(Expr::LocalSet( + 421, + Box::new(Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::LocalGet(421)), + right: Box::new(Expr::String(":".to_string())), + }), + right: Box::new(Expr::PropertyGet { + object: Box::new(Expr::LocalGet(420)), + property: "count".to_string(), + byte_offset: 0, + }), + }), + )), + Stmt::Return(Some(Expr::LocalGet(421))), + ], + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }; + let mut module = Module::new("class_param_guard.ts"); + module.classes.push(label); + module.functions.push(render); + module.init.push(Stmt::Expr(Expr::Call { + callee: Box::new(Expr::FuncRef(42)), + args: vec![Expr::Undefined], + type_args: Vec::new(), + byte_offset: 0, + })); + + let opts = CompileOptions { + emit_ir_only: true, + output_type: "executable".to_string(), + ..Default::default() + }; + let ir = String::from_utf8(compile_module(&module, opts).expect("module compiles")) + .expect("LLVM IR is UTF-8"); + + let public = function_ir(&ir, "@perry_fn_class_param_guard_ts__renderLabel("); + assert!(public.contains("call i32 @js_param_type_guard(")); + assert!(public.contains("renderLabel$spec_b(")); + assert!(public.contains("renderLabel$generic(")); + + // The descriptor must carry a NON-ZERO class id. Codegen emitted a literal + // zero for every object node before this, which left the runtime's + // `class_chain_reaches` branch (`param_type_guard.rs`) with no caller at + // all — the descriptor byte after opcode 11 is that id. + let descriptor = ir + .lines() + .find(|line| line.contains("@perry_param_guard_class_param_guard_ts_42_0 =")) + .expect("the parameter descriptor must be emitted as rodata"); + assert!( + !descriptor.contains("\\0B\\00\\00\\00\\00"), + "the object node's class id must not be zero, or the runtime identity \ + check stays dead:\n{descriptor}" + ); + + let specialized = function_ir(&ir, "renderLabel$spec_b("); + let generic = function_ir(&ir, "renderLabel$generic("); + assert!( + specialized.contains("js_string_concat_value") + || specialized.contains("js_string_concat_box") + || specialized.contains("js_get_string_pointer_unified"), + "the clone must consume the guarded string-field proof:\n{specialized}" + ); + assert!( + !specialized.contains("js_dynamic_string_or_number_add"), + "the clone must not fall back to the dynamic add:\n{specialized}" + ); + assert!( + generic.contains("js_dynamic_string_or_number_add"), + "the unguarded body must keep the dynamic add — if it does not, the \ + clone above is buying nothing and this test is vacuous:\n{generic}" + ); +} diff --git a/crates/perry-codegen/src/codegen/param_guard.rs b/crates/perry-codegen/src/codegen/param_guard.rs index 94ad48efd0..61ca15095d 100644 --- a/crates/perry-codegen/src/codegen/param_guard.rs +++ b/crates/perry-codegen/src/codegen/param_guard.rs @@ -98,6 +98,76 @@ impl<'a> GuardGraphBuilder<'a> { .collect() } + /// Every declared instance field on `name`'s inheritance chain, in + /// root-to-leaf declaration order, with the most-derived declaration + /// winning a shadowed name. + /// + /// Returns `None` — keeping the parameter generic — for any class whose + /// declared field set is not the whole truth about its instances: + /// + /// * generic (unsubstituted `T`-typed fields), + /// * a native, dynamic, or unresolvable base, whose fields HIR cannot see, + /// * a computed-key field, whose `name` is a synthetic placeholder rather + /// than the runtime key, + /// * a private field, which is not an ordinary own key, + /// * an accessor anywhere on the chain that shares a field's name, since + /// the read the proof licenses would then run user code. + /// + /// Cycle-guarded like every other chain walk in this crate: same-named + /// classes pulled across modules into one name-keyed table can form a + /// parent cycle (`type_analysis_class_fields.rs` carries the same note). + fn class_chain_fields(&mut self, name: &str) -> Option> { + let mut chain: Vec<&perry_hir::Class> = Vec::new(); + let mut seen: HashSet = HashSet::new(); + let mut current = Some(name.to_string()); + while let Some(class_name) = current { + if chain.len() > 64 || !seen.insert(class_name.clone()) { + return None; + } + let class = self.classes.get(class_name.as_str()).copied()?; + if !class.type_params.is_empty() + || class.native_extends.is_some() + || class.extends_expr.is_some() + { + return None; + } + chain.push(class); + current = class.extends_name.clone(); + } + // Root first, so a subclass's redeclaration overwrites its parent's. + chain.reverse(); + let mut order: Vec = Vec::new(); + let mut declared: HashMap = HashMap::new(); + let mut accessors: HashSet<&str> = HashSet::new(); + for class in &chain { + for (accessor, _) in class.getters.iter().chain(class.setters.iter()) { + accessors.insert(accessor.as_str()); + } + for field in &class.fields { + if field.key_expr.is_some() || field.is_private { + return None; + } + if declared + .insert(field.name.clone(), field.ty.clone()) + .is_none() + { + order.push(field.name.clone()); + } + } + } + if order.iter().any(|field| accessors.contains(field.as_str())) { + return None; + } + let fields: Vec<(String, Type, bool)> = order + .into_iter() + .map(|field| { + let ty = declared.get(&field).cloned()?; + Some((field, ty, false)) + }) + .collect::>>()?; + self.build_fields(fields) + } + fn build_named(&mut self, name: &str) -> Option { if let Some(id) = self.named.get(name) { return if self.building_named.contains(name) { @@ -132,12 +202,42 @@ impl<'a> GuardGraphBuilder<'a> { class_id: None, fields, } - } else if self.classes.contains_key(name) && self.class_ids.contains_key(name) { - // Class identity alone cannot prove mutable field values, while - // compact instances do not expose the ordinary `keys_array` - // needed for read-only field validation. Keep class parameters on - // the generic path until a layout-aware field guard exists. - return None; + } else if let Some(class_id) = self.class_ids.get(name).copied().filter(|id| *id != 0) { + // (#8099) A class parameter is validated exactly like an interface + // one — every declared field on the inheritance chain, by name — + // plus a `class_chain_reaches` identity check that no structural + // type can supply. The identity half is what gives + // `param_type_guard.rs`'s class branch its first caller. + // + // The refusal this replaces claimed compact class instances have + // no `keys_array` to validate against. They do: + // `object_alloc_class_inline_keys_impl` installs a per-class array + // built once at module init, so `own_data_field` resolves a class + // instance's fields the same way it resolves a literal's. The + // stale claim came from the doc comment on + // `ObjectHeader::keys_array`, corrected alongside this. + // + // Identity ALONE was measured and rejected: with `fields` empty the + // emitted clone comes out structurally identical to the `$generic` + // sibling it routes around — same line count, same call multiset, + // `js_typed_feedback_class_field_get_guard` already present in both + // — so a class-annotated receiver reaches the class-field guard + // path with no parameter evidence at all. It bought nothing and + // cost one guard call per invocation: -51% on `tree`, -30% on + // `tree_wide`. The field VALUE facts are the whole payload, which + // is why they are not optional here. + // + // Cost is bounded by #8094's existing rule rather than a new one: + // a field-bearing descriptor claims heap CONTENTS, so a + // reference-typed parameter carrying one is refused in any body + // that contains a call. A recursive class (`Tree.left: Tree`) + // therefore cannot be guarded in the recursive walker that would + // make its validation O(nodes x depth). + let fields = self.class_chain_fields(name)?; + GuardNode::Object { + class_id: Some(class_id), + fields, + } } else { self.building_named.remove(name); self.named.remove(name); @@ -541,6 +641,195 @@ mod tests { assert!(!body_contains_await(&nested)); } + fn class( + id: u32, + name: &str, + extends: Option<&str>, + fields: Vec<(&str, Type)>, + ) -> perry_hir::Class { + perry_hir::Class { + id, + name: name.to_string(), + type_params: Vec::new(), + extends: None, + extends_name: extends.map(str::to_string), + native_extends: None, + extends_expr: None, + heritage_lexically_shadowed: false, + fields: fields + .into_iter() + .map(|(field, ty)| perry_hir::ClassField { + name: field.to_string(), + key_expr: None, + ty, + init: None, + is_private: false, + is_readonly: false, + decorators: Vec::new(), + }) + .collect(), + constructor: None, + methods: Vec::new(), + getters: Vec::new(), + setters: Vec::new(), + static_accessor_names: Vec::new(), + static_accessor_fn_ids: Vec::new(), + static_fields: Vec::new(), + static_methods: Vec::new(), + computed_members: Vec::new(), + decorators: Vec::new(), + is_exported: false, + is_nested: false, + alloc_width_hint: 0, + specialized_from: None, + aliases: Vec::new(), + } + } + + fn class_descriptor(root: &str, classes: &[perry_hir::Class]) -> Option> { + let table: HashMap = + classes.iter().map(|c| (c.name.clone(), c)).collect(); + let ids: HashMap = classes.iter().map(|c| (c.name.clone(), c.id)).collect(); + descriptor_for_type( + &Type::Named(root.to_string()), + &HashMap::new(), + &HashMap::new(), + &table, + &ids, + ) + } + + /// #8099: a class parameter carries BOTH halves — the non-zero class id + /// that `param_type_guard.rs`'s `class_chain_reaches` branch consumes (its + /// only caller; codegen emitted a literal 0 there until this landed), and + /// the declared field types, which are the half that actually buys a + /// lowering. Identity alone was measured and reverted: the clone came out + /// structurally identical to the `$generic` sibling it routed around. + #[test] + fn a_class_descriptor_carries_its_class_id_and_its_declared_fields() { + let descriptor = class_descriptor( + "Label", + &[class( + 7, + "Label", + None, + vec![("label", Type::String), ("count", Type::Number)], + )], + ) + .expect("a plain class is guardable"); + // OP_OBJECT is opcode 11, then class_id: u32, then field_count: u32. + let object = descriptor + .windows(9) + .find(|window| window[0] == 11) + .expect("an object node"); + assert_eq!( + u32::from_le_bytes(object[1..5].try_into().unwrap()), + 7, + "the class id must reach the descriptor, or the runtime's identity \ + check stays dead: {descriptor:?}" + ); + assert_eq!( + u32::from_le_bytes(object[5..9].try_into().unwrap()), + 2, + "both declared fields must be validated: {descriptor:?}" + ); + assert!( + descriptor.windows(5).any(|w| w == b"label"), + "field names are validated by name against `keys_array`: {descriptor:?}" + ); + } + + /// Inherited fields belong to the instance, so a proof that names only the + /// leaf's own fields would license a parent field's declared type without + /// having validated it. + #[test] + fn a_subclass_descriptor_validates_the_whole_inheritance_chain() { + let descriptor = class_descriptor( + "Derived", + &[ + class(3, "Base", None, vec![("base", Type::String)]), + class(4, "Derived", Some("Base"), vec![("own", Type::Number)]), + ], + ) + .expect("a subclass with a resolvable base is guardable"); + let object = descriptor + .windows(9) + .find(|window| window[0] == 11) + .expect("an object node"); + assert_eq!(u32::from_le_bytes(object[1..5].try_into().unwrap()), 4); + assert_eq!( + u32::from_le_bytes(object[5..9].try_into().unwrap()), + 2, + "the inherited field must be validated too: {descriptor:?}" + ); + } + + /// A base HIR cannot see means the declared field set is not the whole + /// truth about the instance, so the parameter stays generic. + #[test] + fn a_class_whose_base_is_unresolvable_stays_generic() { + assert!( + class_descriptor( + "Orphan", + &[class( + 5, + "Orphan", + Some("SomeImportedThing"), + vec![("x", Type::Number)] + )], + ) + .is_none(), + "an unresolvable parent must refuse the descriptor" + ); + } + + /// An accessor that shares a field's name owns that property for normal JS + /// semantics, so validating it as a data field would license a read that + /// runs user code. + #[test] + fn a_class_with_an_accessor_shadowing_a_field_stays_generic() { + let mut shadowed = class(6, "Shadowed", None, vec![("value", Type::Number)]); + shadowed.getters.push(( + "value".to_string(), + perry_hir::Function { + id: 60, + name: "get_value".to_string(), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Number, + body: Vec::new(), + is_async: false, + is_generator: false, + is_strict: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }, + )); + assert!( + class_descriptor("Shadowed", &[shadowed]).is_none(), + "an accessor shadowing a declared field must refuse the descriptor" + ); + } + + /// A generic class's fields are still `T`, so nothing about them is + /// validatable until monomorphization has substituted them. + #[test] + fn a_generic_class_stays_generic() { + let mut generic = class(8, "Holder", None, vec![("item", Type::Number)]); + generic.type_params.push(perry_hir::types::TypeParam { + name: "T".to_string(), + constraint: None, + default: None, + }); + assert!( + class_descriptor("Holder", &[generic]).is_none(), + "an unsubstituted generic class must refuse the descriptor" + ); + } + #[test] fn collection_generics_serialize_their_complete_element_types() { let descriptor = descriptor_for_type( diff --git a/crates/perry-codegen/tests/native_proof_regressions.rs b/crates/perry-codegen/tests/native_proof_regressions.rs index 98237182aa..b919533d71 100644 --- a/crates/perry-codegen/tests/native_proof_regressions.rs +++ b/crates/perry-codegen/tests/native_proof_regressions.rs @@ -14139,6 +14139,30 @@ fn static_name_spread_method_fallback_uses_method_id_wrapper() { ); } +/// The text of the `define` block whose signature line contains `marker`, +/// through to the start of the next one. A guarded function (#8094) puts its +/// public trampoline, its specialized clone and its generic sibling in one +/// module, and the assertions below differ per body. +fn ir_function_body<'a>(ir: &'a str, marker: &str) -> &'a str { + let start = ir + .match_indices("define ") + .find(|(index, _)| { + let line_end = ir[*index..] + .find('\n') + .map(|offset| index + offset) + .unwrap_or(ir.len()); + ir[*index..line_end].contains(marker) + }) + .map(|(index, _)| index) + .unwrap_or_else(|| panic!("no `define` line containing {marker:?} in:\n{ir}")); + let rest = &ir[start..]; + let end = rest[1..] + .find("\ndefine ") + .map(|offset| offset + 1) + .unwrap_or(rest.len()); + &rest[..end] +} + #[test] fn annotated_class_method_value_uses_generic_lookup() { let mut calc = class(209, "Calc", Vec::new()); @@ -14171,14 +14195,30 @@ fn annotated_class_method_value_uses_generic_lookup() { ); let ir = compile_ir_for_module_with_opts(module, empty_opts()).unwrap(); - assert!( - ir.contains("call double @js_object_get_field_ic_miss"), - "an annotation-only class receiver should preserve generic property lookup:\n{ir}" - ); - assert!( - !ir.contains("call double @js_class_method_bind_by_id") - && !ir.contains("call double @js_class_method_bind(double"), - "an annotation-only class receiver must not select a direct class-method bind ABI:\n{ir}" + // (#8033) An erased annotation is never a proof, so the body reachable + // WITHOUT a validated argument must keep generic lookup. (#8099) A + // class-typed parameter is now additionally admitted into the #8094 + // runtime-guarded clone, where the direct bind ABI is legal because + // `js_param_type_guard` established the receiver's class identity. Assert + // both halves: the interesting failure is the direct ABI appearing in the + // fallback, which is the exact regression #8033 exists to prevent. + let generic = ir_function_body(&ir, "__probe$generic("); + assert!( + generic.contains("call double @js_object_get_field_ic_miss"), + "an annotation-only class receiver must preserve generic property \ + lookup in the unguarded body:\n{generic}" + ); + assert!( + !generic.contains("call double @js_class_method_bind_by_id") + && !generic.contains("call double @js_class_method_bind(double"), + "the unguarded body must not select a direct class-method bind ABI:\n{generic}" + ); + let specialized = ir_function_body(&ir, "__probe$spec_b("); + assert!( + specialized.contains("call double @js_class_method_bind_by_id") + || specialized.contains("call double @js_class_method_bind(double"), + "the guarded clone is what the validated annotation buys — if it stops \ + selecting the direct bind, the assertions above pass vacuously:\n{specialized}" ); } diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 5ea0354eab..f45029528d 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -1695,8 +1695,15 @@ pub struct ObjectHeader { pub parent_class_id: u32, /// Number of fields in this object pub field_count: u32, - /// Pointer to array of key strings (for Object.keys() support) - /// NULL for class instances (keys are defined by the class) + /// Pointer to array of key strings (for Object.keys() support). + /// + /// A class instance HAS one: `object_alloc_class_inline_keys_impl` installs + /// the per-class array that codegen builds once at module init + /// (`js_build_class_keys_array`). The note that used to sit here claiming + /// the opposite outlived the compact-instance layout it described, and cost + /// #8099 a wrong premise — the guard descriptor refused every class-typed + /// parameter on the strength of it. Null means genuinely keyless, not + /// "class instance". pub keys_array: *mut ArrayHeader, /// #6759 Phase B: per-object metadata record — null for ordinary /// objects (the common case). MUST stay the LAST field: codegen reads diff --git a/test-files/test_gap_specabi_ordinary_param_guards.ts b/test-files/test_gap_specabi_ordinary_param_guards.ts index 274b91ddbe..5cf9f286d6 100644 --- a/test-files/test_gap_specabi_ordinary_param_guards.ts +++ b/test-files/test_gap_specabi_ordinary_param_guards.ts @@ -258,3 +258,127 @@ function survivePrimitiveGuardedGc(tag: string, rounds: number): string { console.log("moving-primitive", survivePrimitiveGuardedGc("live", 1200000)); console.log("moving-primitive-lie", survivePrimitiveGuardedGc(7 as any, 1200000)); + +// #8099: CLASS-typed parameters. Two facts drive these rows. +// +// A class instance DOES carry an ordinary `keys_array` (built once per class at +// module init), so its declared fields validate by name exactly like an +// interface's — and the descriptor additionally carries the class id, which is +// the identity check no structural type can satisfy. +// +// Identity WITHOUT the field types was measured and rejected: the clone came +// out structurally identical to the generic body it routes around, so it cost +// a guard call per invocation and bought nothing (-51% on `tree`). The field +// facts are the payload, which is why every row below turns on a field type. +class Sized { + label: string; + count: number; + constructor(label: string, count: number) { + this.label = label; + this.count = count; + } +} + +// `result` is `any` and reassigned, so it carries no proof of its own: the add +// can only be lowered from the parameter's guarded field types. +function describeSized(payload: Sized): any { + let result: any = payload.label; + result = result + ":" + payload.count; + return result; +} + +console.log("class-fields-good", describeSized(new Sized("items", 3))); +// Right class, lying fields — the descriptor rejects it and the generic body +// must produce JavaScript's answer, not the specialized one. +const lyingSized = new Sized("x", 1); +(lyingSized as any).label = 9; +(lyingSized as any).count = "many"; +console.log("class-fields-lie", describeSized(lyingSized)); +// A structurally identical object literal is NOT an instance of the class. +// `class_chain_reaches` is what rejects it, and the fallback still runs. +console.log("class-structural", describeSized({ label: "lit", count: 7 } as any)); + +// A subclass IS admitted: `class_chain_reaches` walks the parent chain, and a +// subclass keeps its parent's field slots. +class SizedPlus extends Sized { + extra: string; + constructor(label: string, count: number, extra: string) { + super(label, count); + this.extra = extra; + } +} + +console.log("class-subclass", describeSized(new SizedPlus("sub", 5, "e"))); + +// A getter that is NOT also a declared field simply is not in the descriptor: +// the class stays guardable on its real fields, and the accessor read still +// runs user code through the ordinary path exactly once. (A class declaring +// BOTH a field and a same-named accessor is refused outright, but TypeScript +// rejects that source, so it is pinned at the HIR level instead — +// `param_guard.rs::a_class_with_an_accessor_shadowing_a_field_stays_generic`.) +let sizedGetterHits = 0; + +class Accessed { + count: number; + constructor(count: number) { + this.count = count; + } + get label(): string { + sizedGetterHits++; + return "from-getter"; + } +} + +function describeAccessed(payload: Accessed): any { + let result: any = payload.label; + result = result + ":" + payload.count; + return result; +} + +console.log("class-accessor", describeAccessed(new Accessed(2)), sizedGetterHits); + +// The `tree` shape from #8099: a RECURSIVE class walked recursively. The body +// contains a call, so #8094's aliasing rule refuses the descriptor — which is +// also what stops the guard from walking the whole reachable graph on every +// one of these calls. It must simply stay correct on the generic path. +class Chain { + next: Chain | null; + weight: number; + constructor(next: Chain | null, weight: number) { + this.next = next; + this.weight = weight; + } +} + +function chainTotal(node: Chain): number { + if (node.next === null) return node.weight; + return node.weight + chainTotal(node.next); +} + +console.log( + "class-recursive", + chainTotal(new Chain(new Chain(new Chain(null, 3), 2), 1)), +); + +// A class parameter reached by unknown code through an alias the caller +// arranged first — the (c) case above, with a class receiver. The descriptor +// claims field CONTENTS, so it is refused here for the same reason the +// interface one is, and the printed answer must be JavaScript's. +let chainStash: any = null; + +function poisonSized(): void { + chainStash.count = "lie"; +} + +function describeThroughGlobal(payload: Sized): string { + const before = payload.count + 1; + poisonSized(); + return ( + "before=" + before + " after=" + (payload.count + 1) + " typeof=" + + typeof payload.count + ); +} + +const stashedSized = new Sized("s", 41); +chainStash = stashedSized; +console.log("class-alias-global", describeThroughGlobal(stashedSized));