-
-
Notifications
You must be signed in to change notification settings - Fork 161
fix(hir): exclude inherited method names from ctor-body field detection #10626
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| ### Fixed | ||
|
|
||
| - **A subclass constructor assigning `this.m = ...` over an inherited method | ||
| hid that method from the moment `super()` returned, not just before the | ||
| assignment ran.** The constructor-body field-detection pass excluded a | ||
| class's OWN methods from being turned into shadow data slots (so | ||
| `this.parse = this.parse.bind(this)` self-binding kept working), but never | ||
| excluded methods inherited from the `extends` chain — so | ||
| `this.close = () => ...` in a subclass constructor, where `close` is | ||
| declared only on a parent class, allocated an own `close` field that | ||
| existed (as `undefined`) as soon as `super()` returned and shadowed the | ||
| inherited method in every by-name lookup until the assignment statement | ||
| executed. Instance method names are now tracked as an own+inherited union | ||
| per class (mirroring the existing accessor-name tracking) and consulted the | ||
| same way. This was blocking `undici`'s `MockPool`/`MockClient` | ||
| (`this[kOriginalClose] = this.close.bind(this)` over `DispatcherBase`'s | ||
| `close`), which threw `TypeError: Bind must be called on a function`. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| //! #10487: a subclass constructor assignment `this.m = …` must NOT become an | ||
| //! own inline field slot when `m` is a method INHERITED from a parent class | ||
| //! — that shadowed the inherited method with an own `undefined` field from | ||
| //! the moment `super()` returned, until the assignment statement ran. Split | ||
| //! from `tests.rs` for the 2000-line file cap. | ||
|
|
||
| /// `this.close = …` in a subclass constructor, where `close` is a method | ||
| /// declared only on the parent, must not allocate an own `close` field. | ||
| #[test] | ||
| fn subclass_ctor_assignment_to_inherited_method_name_is_not_a_field() { | ||
| let source = r#" | ||
| class Base { | ||
| close() { return "closed"; } | ||
| } | ||
| class Sub extends Base { | ||
| constructor() { | ||
| super(); | ||
| this.seen = typeof this.close; | ||
| this.close = () => "replaced"; | ||
| } | ||
| } | ||
| "#; | ||
| let module = perry_parser::parse_typescript(source, "t.ts").expect("source parses"); | ||
| let hir = super::lower_module(&module, "t", "t.ts").expect("source lowers"); | ||
| let sub = hir | ||
| .classes | ||
| .iter() | ||
| .find(|c| c.name == "Sub") | ||
| .expect("fixture declares class Sub"); | ||
| assert!( | ||
| !sub.fields.iter().any(|f| f.name == "close"), | ||
| "this.close = … must not become an own field on Sub when `close` is \ | ||
| inherited from Base; fields: {:?}", | ||
| sub.fields.iter().map(|f| &f.name).collect::<Vec<_>>() | ||
| ); | ||
| assert!( | ||
| sub.fields.iter().any(|f| f.name == "seen"), | ||
| "this.seen = … has no parent-declared counterpart and must still \ | ||
| become an own field; fields: {:?}", | ||
| sub.fields.iter().map(|f| &f.name).collect::<Vec<_>>() | ||
| ); | ||
| } | ||
|
|
||
| /// Same requirement across TWO levels of inheritance (the method is | ||
| /// declared on a grandparent, not the immediate parent). | ||
| #[test] | ||
| fn grandparent_method_name_is_excluded_across_two_levels() { | ||
| let source = r#" | ||
| class Base { | ||
| close() { return "closed"; } | ||
| } | ||
| class Mid extends Base {} | ||
| class Grand extends Mid { | ||
| constructor() { | ||
| super(); | ||
| this.close = () => "replaced"; | ||
| } | ||
| } | ||
| "#; | ||
| let module = perry_parser::parse_typescript(source, "t.ts").expect("source parses"); | ||
| let hir = super::lower_module(&module, "t", "t.ts").expect("source lowers"); | ||
| let grand = hir | ||
| .classes | ||
| .iter() | ||
| .find(|c| c.name == "Grand") | ||
| .expect("fixture declares class Grand"); | ||
| assert!( | ||
| !grand.fields.iter().any(|f| f.name == "close"), | ||
| "this.close = … must not become an own field on Grand when `close` \ | ||
| is inherited from Base via Mid; fields: {:?}", | ||
| grand.fields.iter().map(|f| &f.name).collect::<Vec<_>>() | ||
| ); | ||
| } | ||
|
|
||
| /// Control: a class's OWN method being self-bound in its OWN constructor | ||
| /// (the pre-existing #665-adjacent zod fix) must keep working — this | ||
| /// exclusion is orthogonal to the inherited-method one added here. | ||
| #[test] | ||
| fn own_class_method_self_assignment_is_still_not_a_field() { | ||
| let source = r#" | ||
| class Own { | ||
| close() { return "closed"; } | ||
| constructor() { | ||
| this.close = this.close.bind(this); | ||
| } | ||
| } | ||
| "#; | ||
| let module = perry_parser::parse_typescript(source, "t.ts").expect("source parses"); | ||
| let hir = super::lower_module(&module, "t", "t.ts").expect("source lowers"); | ||
| let own = hir | ||
| .classes | ||
| .iter() | ||
| .find(|c| c.name == "Own") | ||
| .expect("fixture declares class Own"); | ||
| assert!( | ||
| !own.fields.iter().any(|f| f.name == "close"), | ||
| "an own-method self-assignment must not become a field either; \ | ||
| fields: {:?}", | ||
| own.fields.iter().map(|f| &f.name).collect::<Vec<_>>() | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -974,6 +974,20 @@ pub fn lower_class_decl( | |
| _ => {} | ||
| } | ||
| } | ||
| // Issue #10487: pull in the parent chain's own+inherited method | ||
| // names too, mirroring the accessor union just above. A subclass | ||
| // constructor's `this.close = …` overriding a PARENT method (not | ||
| // redeclared on this class) must be recognized as a method | ||
| // override, not a new own data field, or the field wins the | ||
| // dynamic-dispatch lookup and instance reads see `undefined` | ||
| // until the assignment statement runs. | ||
| if let Some(ref parent_name) = extends_name { | ||
| if let Some(parent_methods) = ctx.lookup_class_method_names(parent_name) { | ||
| for m in parent_methods { | ||
| method_names.insert(m.clone()); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| let declared_field_names: std::collections::HashSet<String> = | ||
| fields.iter().map(|f| f.name.clone()).collect(); | ||
|
|
@@ -1066,6 +1080,12 @@ pub fn lower_class_decl( | |
| // from the parent-chain lookup above. | ||
| ctx.register_class_accessor_names(name.clone(), accessor_names); | ||
|
|
||
| // Issue #10487: register this class's complete (own + inherited) | ||
| // method-name set, mirroring the accessor registration just above, | ||
| // so a further subclass lowered after this one sees the full | ||
| // chain in one lookup. | ||
| ctx.register_class_method_names(name.clone(), method_names.into_iter().collect()); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '940,1005p' crates/perry-hir/src/lower_decl/class_decl.rs
sed -n '1060,1100p' crates/perry-hir/src/lower_decl/class_decl.rs
rg -n 'enum PropName|PropName::Computed|Computed\(' crates/perry-hir crates/perry-parser | head -160
rg -n 'computed.*method|method.*computed|PropName::Computed' crates/perry-hir/src/lower/tests crates/perry-hir/src/lower_declRepository: PerryTS/perry Length of output: 26498 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- class_decl computed/key handling ---'
sed -n '540,730p' crates/perry-hir/src/lower_decl/class_decl.rs
printf '%s\n' '--- class_decl field detection and method lookup ---'
sed -n '1000,1088p' crates/perry-hir/src/lower_decl/class_decl.rs
printf '%s\n' '--- method/accessor registry definitions and lookups ---'
rg -n -C 5 'lookup_class_method_names|register_class_method_names|lookup_class_accessor_names|this\.[A-Za-z_].*=|packed_keys|method_names' crates/perry-hir/src/lower_decl crates/perry-hir/src/lower crates/perry-hir/src | head -260
printf '%s\n' '--- computed key helpers ---'
sed -n '640,720p' crates/perry-hir/src/lower_decl/helpers.rs
sed -n '1,130p' crates/perry-hir/src/lower_decl/class_decl/member_helpers.rs
printf '%s\n' '--- relevant tests ---'
rg -n -C 4 'inherited method|shadow|computed.*method|\[.close.|this\.close|register_class_method_names' crates/perry-hir crates/perry-runtime tests 2>/dev/null | head -260Repository: PerryTS/perry Length of output: 50369 🤖 get_repo_knowledge executed:
Length of output: 29094 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- computed string/numeric key normalization ---'
rg -n -C 8 'Expr::Lit\(ast::Lit::Str|Lit::Str|number_to_js_key|strip_transparent_expr|ComputedPropName' crates/perry-hir/src/lower_decl crates/perry-hir/src/lower | head -260
printf '%s\n' '--- class field layout and initialization ---'
rg -n -C 8 'js_object_alloc_class_with_keys|class.*field|field_count|ClassField|inline.*slot|undefined' crates/perry-runtime/src crates/perry-codegen/src crates/perry-hir/src | head -360
printf '%s\n' '--- property lookup precedence ---'
rg -n -C 8 'method.*field|field.*method|lookup.*method|vtable|PropertyGet|property_get|js_object_get_field_by_name|field.*wins|shadow' crates/perry-runtime/src crates/perry-codegen/src | head -360
printf '%s\n' '--- focused tests and changelog reference ---'
rg -n -C 8 'Issue `#10487`|10487|computed.*close|this\.close|inherited.*method|shadow.*method' crates/perry-hir crates/perry-runtime crates/perry-codegen changelog.d test-parity 2>/dev/null | head -260Repository: PerryTS/perry Length of output: 50370 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- inherited names and field inference ---'
sed -n '920,1045p' crates/perry-hir/src/lower_decl/class_decl.rs
printf '%s\n' '--- class allocation definitions ---'
files=$(rg -l 'js_object_alloc_class_with_keys|js_object_alloc_class' crates/perry-runtime crates/perry-codegen crates/perry-hir | head -20)
for f in $files; do
echo "--- $f ---"
rg -n -C 12 'js_object_alloc_class_with_keys|js_object_alloc_class' "$f"
done
printf '%s\n' '--- class method/field property lookup ---'
files=$(rg -l 'js_object_get_field_by_name|field wins|method.*shadow' crates/perry-runtime/src crates/perry-codegen/src | head -20)
for f in $files; do
echo "--- $f ---"
rg -n -C 10 'js_object_get_field_by_name|field wins|method.*shadow|vtable' "$f"
doneRepository: PerryTS/perry Length of output: 50369 Register statically known computed instance method names.
Add statically known computed string keys to 🤖 Prompt for AI Agents |
||
|
|
||
| // Issue #302: also register field TYPES so the for-of arm can | ||
| // detect `for (... of this.someMap)` patterns. Only own fields are | ||
| // registered here; inherited field types fall through to whichever | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| // #10487: a subclass constructor that assigns `this.m = ...` where `m` is a | ||
| // method inherited from a PARENT class hid the inherited method from the | ||
| // moment `super()` returned, instead of only once the assignment ran. | ||
| // | ||
| // `lower_class_decl` (crates/perry-hir/src/lower_decl/class_decl.rs) scans | ||
| // each constructor's `this.<name> = ...` assignments to decide whether | ||
| // `<name>` needs a synthesized inline field slot, excluding names that are | ||
| // declared fields, inherited fields, or accessors — and the class's OWN | ||
| // methods (the #665-adjacent zod `this.parse.bind(this)` fix: an own-method | ||
| // override must not get a shadowing data slot). It did not exclude METHODS | ||
| // INHERITED FROM THE EXTENDS CHAIN, so `this.close = ...` in a subclass | ||
| // constructor (where `close` is declared only on the parent) allocated an | ||
| // own `close` field. That field exists (as `undefined`) as soon as `super()` | ||
| // returns, so it shadows the inherited method in every by-name lookup until | ||
| // the assignment statement actually runs. | ||
|
|
||
| class Base { | ||
| close() { | ||
| return "closed"; | ||
| } | ||
| } | ||
| class Sub extends Base { | ||
| seen: string; | ||
| constructor() { | ||
| super(); | ||
| this.seen = typeof this.close; // inherited method, read BEFORE the own assignment below | ||
| this.close = () => "replaced"; | ||
| } | ||
| } | ||
| class Own { | ||
| seen: string; | ||
| close() { | ||
| return "closed"; | ||
| } | ||
| constructor() { | ||
| this.seen = typeof this.close; | ||
| this.close = () => "replaced"; | ||
| } | ||
| } | ||
| class Sub2 extends Base { | ||
| seen: string; | ||
| constructor() { | ||
| super(); | ||
| this.seen = typeof this.close; // control: no own assignment to `close` | ||
| } | ||
| } | ||
| class Sub3 extends Base { | ||
| original: any; | ||
| constructor() { | ||
| super(); | ||
| this.original = this.close.bind(this); // undici MockPool / MockClient shape | ||
| this.close = () => "replaced"; | ||
| } | ||
| } | ||
| // Grandparent-distance: the method is declared two levels up. | ||
| class Mid extends Base {} | ||
| class Grand extends Mid { | ||
| seen: string; | ||
| constructor() { | ||
| super(); | ||
| this.seen = typeof this.close; | ||
| this.close = () => "replaced"; | ||
| } | ||
| } | ||
| // Alias read: `const self = this; self.close` must see the same result. | ||
| class SubAlias extends Base { | ||
| seen: string; | ||
| constructor() { | ||
| super(); | ||
| const self = this; | ||
| this.seen = typeof self.close; | ||
| this.close = () => "replaced"; | ||
| } | ||
| } | ||
| // Assignment made in a regular method (not the constructor): documented as | ||
| // already working, must keep working. | ||
| class SubMethodAssign extends Base { | ||
| seen: string = ""; | ||
| replace() { | ||
| this.seen = typeof this.close; | ||
| this.close = () => "replaced"; | ||
| } | ||
| } | ||
|
|
||
| console.log("Sub ", new Sub().seen); | ||
| console.log("Own ", new Own().seen); | ||
| console.log("Sub2 ", new Sub2().seen); | ||
| console.log("Grand ", new Grand().seen); | ||
| console.log("SubAlias ", new SubAlias().seen); | ||
| const sma = new SubMethodAssign(); | ||
| sma.replace(); | ||
| console.log("SubMethod", sma.seen); | ||
| try { | ||
| console.log("Sub3", new Sub3().original()); | ||
| } catch (e: any) { | ||
| console.log("Sub3 threw", e.constructor.name, e.message); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: PerryTS/perry
Length of output: 19188
🏁 Script executed:
Repository: PerryTS/perry
Length of output: 14226
Seed imported class method names.
lower_class_decluseslookup_class_method_names(parent_name)to exclude inherited methods from inferred constructor fields. Cross-module lowering seeds only fields and accessors, so an importedBasehas no method-name entry whenSubis lowered. An assignment such asthis.close = ...can therefore allocate an ownclosefield and shadow the inherited method before the assignment runs. Add own-plus-inherited method-name metadata to the cross-module seed and pass it through module loading.🤖 Prompt for AI Agents