From 7f8147c665bc949564b0c56b9342a7f5c62fb379 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 21:55:11 +0200 Subject: [PATCH 1/2] fix(hir): a class receiver behind a Union still folds to Array.prototype (#10796) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `is_user_class_instance` (local_array_methods.rs), `class_typed`, and the push-specific `is_user_class_receiver` (array_only_methods.rs) all decide whether recv.method(...) should fold to the dense Array fast path (Expr::ArrayFind/ArrayMap/ArrayPush/...) by matching Type::Named/ Type::Generic directly. A receiver typed as a Union containing a class (`Foo | undefined`, cheerio's `Cheerio | undefined`) fell through to `_ => false` in all three places and read as "not a class instance", so a method name shared with Array.prototype (find, map, filter, forEach, reduce, push, ...) folded to the array intrinsic and called the user's argument as a callback/misread the object header as an ArrayHeader. cheerio hit this on its single most common operation: `load.ts`'s `searchContext.find(search)`, where `searchContext: Cheerio | undefined` and `find` is cheerio's own CSS-selector method (mixed onto `Cheerio.prototype` at runtime) — not `Array.prototype.find`. Every `cheerio.load(html)("selector")` call threw `TypeError: string "..." is not a function`. Fix: each guard now recurses through Type::Union (including nested unions, which type_alias_resolve.rs's resolve_type_inner can produce) using the same per-variant test it already applied to a bare receiver. Real cheerio 1.2.0 now compiles and runs end-to-end, byte-identical to Node 26.5.1. A targeted sweep of the 130 existing gap tests touching array/class/collection dispatch shows no regressions (129 pass, 1 pre-existing node_fail unrelated to this change). --- .../src/lower/expr_call/array_only_methods.rs | 152 ++++++++--- .../lower/expr_call/local_array_methods.rs | 250 ++++++++++++++---- ...p_10796_union_class_find_not_array_fold.ts | 35 +++ ...ion_generic_class_array_overlap_methods.ts | 65 +++++ 4 files changed, 424 insertions(+), 78 deletions(-) create mode 100644 test-files/test_gap_10796_union_class_find_not_array_fold.ts create mode 100644 test-files/test_gap_10796_union_generic_class_array_overlap_methods.ts diff --git a/crates/perry-hir/src/lower/expr_call/array_only_methods.rs b/crates/perry-hir/src/lower/expr_call/array_only_methods.rs index 09e4ee8597..0c9aa57a76 100644 --- a/crates/perry-hir/src/lower/expr_call/array_only_methods.rs +++ b/crates/perry-hir/src/lower/expr_call/array_only_methods.rs @@ -28,6 +28,48 @@ fn unwrap_transparent_expr(expr: &ast::Expr) -> &ast::Expr { } } +/// #10796: is `ty` a `Named`/`Generic` (i.e. class-shaped, non-`Array`) type +/// — looking *through* `Union`, at any nesting depth, the way `class_typed` +/// below wants. Before this recursed, a receiver typed as a `Union` +/// containing a class (`Foo | undefined`, cheerio's `Cheerio | +/// undefined`) fell through `class_typed`'s plain `matches!(t, Type::Named(_) +/// | Type::Generic { .. })` and read as "not class-typed", so a method name +/// shared with `Array.prototype` (`find`, `map`, `filter`, …) folded to the +/// array fast path even for a genuine class instance. This is the same +/// defect as `local_array_methods.rs`'s `is_user_class_instance` (#10796) — +/// present here too because this file keeps its own, independent +/// class-vs-array classification rather than sharing that one. +fn is_named_or_generic_non_array(ty: &Type) -> bool { + match ty { + Type::Named(_) | Type::Generic { .. } => !matches!(ty, Type::Array(_)), + Type::Union(variants) => variants.iter().any(is_named_or_generic_non_array), + _ => false, + } +} + +/// #10796: does `ty` denote a receiver that may own its own `push` (a class +/// instance, an interface-typed value, or an object type literal) — looking +/// *through* `Union`, at any nesting depth, the way the `"push"` arm's +/// `is_user_class_receiver` below wants. Same defect and same fix shape as +/// `is_named_or_generic_non_array` just above: a `Foo | undefined` receiver +/// fell through the plain `match ty { Type::Named(_) => …, Type::Generic { +/// .. } => …, _ => false }` and read as "not class-typed", so `.push(x)` on +/// it folded to the array fast path (`js_array_push`), which reads the +/// class instance's `ObjectHeader` as an `ArrayHeader` and never runs the +/// user's `push` method. +fn is_push_owning_class_type(ty: &Type, ctx: &LoweringContext) -> bool { + match ty { + Type::Named(name) => ctx.lookup_class(name).is_some() || ctx.is_interface_type(name), + Type::Generic { base, .. } => { + let builtin = ["Map", "Set", "WeakMap", "WeakSet", "Promise"]; + !builtin.contains(&base.as_str()) && ctx.lookup_class(base).is_some() + } + Type::Object(_) => true, // object type literal with push property + Type::Union(variants) => variants.iter().any(|v| is_push_owning_class_type(v, ctx)), + _ => false, + } +} + fn is_stream_class_ref(expr: &ast::Expr) -> bool { let expr = unwrap_transparent_expr(expr); let name = match expr { @@ -441,10 +483,7 @@ pub(super) fn try_array_only_methods( } let class_typed = ty .as_ref() - .map(|t| { - matches!(t, Type::Named(_) | Type::Generic { .. }) - && !matches!(t, Type::Array(_)) - }) + .map(|t| is_named_or_generic_non_array(t)) .unwrap_or(false); let unknown_recv = matches!(ty, None | Some(Type::Any) | Some(Type::Unknown)); @@ -1275,36 +1314,22 @@ pub(super) fn try_array_only_methods( // GUARD: Skip if the receiver is a user-defined class instance // (e.g. Stack.push()), or an object type literal (e.g. // { push: (v) => void, ... }), so its method dispatches correctly. + // A class instance OR an interface-typed value is the + // receiver's OWN object and may own a `push` method, so + // never fold to the array intrinsic. Interfaces aren't + // classes (`lookup_class` misses them), so the previous + // `lookup_class(name).is_some()` folded an interface + // receiver's `push` to the array fast path — reading the + // object header as an ArrayHeader and dropping the call + // (follow-up to #5139, which fixed only `any` receivers). + // `is_push_owning_class_type` also looks through `Union` + // (#10796), so `Foo | undefined` is caught the same way. let is_user_class_receiver = match member.obj.as_ref() { ast::Expr::This(_) => true, - ast::Expr::Ident(ident) => { - ctx.lookup_local_type(ident.sym.as_ref()) - .map(|ty| { - match ty { - // A class instance OR an interface-typed value is the - // receiver's OWN object and may own a `push` method, so - // never fold to the array intrinsic. Interfaces aren't - // classes (`lookup_class` misses them), so the previous - // `lookup_class(name).is_some()` folded an interface - // receiver's `push` to the array fast path — reading the - // object header as an ArrayHeader and dropping the call - // (follow-up to #5139, which fixed only `any` receivers). - Type::Named(name) => { - ctx.lookup_class(name).is_some() - || ctx.is_interface_type(name) - } - Type::Generic { base, .. } => { - let builtin = - ["Map", "Set", "WeakMap", "WeakSet", "Promise"]; - !builtin.contains(&base.as_str()) - && ctx.lookup_class(base).is_some() - } - Type::Object(_) => true, // object type literal with push property - _ => false, - } - }) - .unwrap_or(false) - } + ast::Expr::Ident(ident) => ctx + .lookup_local_type(ident.sym.as_ref()) + .map(|ty| is_push_owning_class_type(ty, ctx)) + .unwrap_or(false), ast::Expr::New(_) => true, // new ClassName().push() _ => false, }; @@ -1381,3 +1406,66 @@ pub(super) fn try_array_only_methods( Ok(Err(args)) } + +#[cfg(test)] +mod tests { + use super::*; + + // #10796: both class-vs-array guards in this file must see a class + // *through* a `Union` — `Foo | undefined` is exactly as class-shaped as + // a bare `Foo` for the purpose of declining the array fast path. + + #[test] + fn named_or_generic_non_array_sees_through_union() { + assert!(is_named_or_generic_non_array(&Type::Named( + "Foo".to_string() + ))); + assert!(is_named_or_generic_non_array(&Type::Generic { + base: "Cheerio".to_string(), + type_args: vec![Type::Named("AnyNode".to_string())], + })); + // `Foo | undefined` — before the fix this fell through to `false`. + assert!(is_named_or_generic_non_array(&Type::Union(vec![ + Type::Named("Foo".to_string()), + Type::Void, + ]))); + // Nested union: `type_alias_resolve.rs` can produce these. + assert!(is_named_or_generic_non_array(&Type::Union(vec![ + Type::Union(vec![Type::Named("Foo".to_string()), Type::Number]), + Type::Void, + ]))); + // Negative controls: real arrays and non-class unions stay `false`. + assert!(!is_named_or_generic_non_array(&Type::Array(Box::new( + Type::Number + )))); + assert!(!is_named_or_generic_non_array(&Type::Union(vec![ + Type::String, + Type::Number, + ]))); + } + + #[test] + fn push_owning_class_type_sees_through_union() { + let mut ctx = LoweringContext::new("array-only-methods-union-test.ts"); + let id = ctx.fresh_class(); + ctx.register_class("Foo".to_string(), id); + + assert!(is_push_owning_class_type( + &Type::Named("Foo".to_string()), + &ctx + )); + // `Foo | undefined` — before the fix this fell through to `false`, + // so `f.push(x)` on an optional-typed `Foo` folded to the array + // intrinsic instead of dispatching to `Foo`'s own `push`. + assert!(is_push_owning_class_type( + &Type::Union(vec![Type::Named("Foo".to_string()), Type::Void]), + &ctx + )); + // An unregistered name behind a union must still decline (`false`), + // same as a bare unregistered `Named` would. + assert!(!is_push_owning_class_type( + &Type::Union(vec![Type::Named("NotAClass".to_string()), Type::Void]), + &ctx + )); + } +} diff --git a/crates/perry-hir/src/lower/expr_call/local_array_methods.rs b/crates/perry-hir/src/lower/expr_call/local_array_methods.rs index 529c2e8a1d..a9c29ec867 100644 --- a/crates/perry-hir/src/lower/expr_call/local_array_methods.rs +++ b/crates/perry-hir/src/lower/expr_call/local_array_methods.rs @@ -57,6 +57,100 @@ fn receiver_is_non_array_builtin_wrapper(recv_ty: Option<&Type>) -> bool { ) } +/// #10796: is `ty` a statically-known user or imported class/interface +/// instance — the test `is_user_class_instance` (below, in +/// `try_local_array_methods`) applies, extended to look *through* +/// `Type::Union`. +/// +/// A receiver typed as a union that includes a class (`Foo | undefined`, +/// `Cheerio | undefined`, …) is exactly as class-shaped as a bare +/// `Foo`/`Cheerio` receiver: if ANY member is class-shaped, a +/// method call on it must still be able to reach that member's own method +/// rather than being folded to the array fast path. Before this existed, +/// the `Named`/`Generic` match arms had no `Union` arm, so `Union` fell to +/// `_ => false` — a receiver typed `Cheerio | undefined` (cheerio's +/// `searchContext` in `load.ts`) read as "not a user class instance", +/// `is_known_not_string` then read the union as array-ish, and +/// `searchContext.find(selector)` (a CSS-selector method mixed onto +/// `Cheerio.prototype` at runtime, sharing a name with `Array.prototype`) +/// folded to `Expr::ArrayFind`, which calls its argument as a *callback* — +/// `TypeError: string "..." is not a function`. +/// +/// Recurses into nested `Union`s too: `type_alias_resolve.rs`'s +/// `resolve_type_inner` can produce `Union([Union([...]), ...])` when one +/// union member is itself an alias to a union type (a resolved member is +/// pushed as-is, not flattened into the parent's variant list), so a single +/// `.any()` over the top-level variants is not enough — the `Union` arm +/// below calls back into this function for each variant, so nesting at any +/// depth is handled rather than assumed away. +fn type_is_class_instance( + ty: &Type, + ctx: &LoweringContext, + builtin_generic_bases: &[&str], +) -> bool { + // Imported classes don't show up in `lookup_class`; treat any + // uppercase imported identifier as a candidate class so the array + // fast-path doesn't swallow `coll.find(filter)` etc. + let is_imported_class_name = |n: &str| -> bool { + if let Some(c) = n.chars().next() { + if c.is_uppercase() && ctx.lookup_imported_func(n).is_some() { + return true; + } + } + false + }; + match ty { + // A class instance OR an interface-typed value is the receiver's + // own object — its method must be dispatched, not the array fast + // path. Interfaces aren't classes (so `lookup_class` misses them); + // without `is_interface_type`, an interface-typed receiver with + // e.g. an own `push` folded to `Expr::ArrayPush`, read the object + // header as an ArrayHeader, and silently dropped the call + // (follow-up to #5139, which fixed only `any`-typed receivers). + Type::Named(name) => { + ctx.lookup_class(name).is_some() + || ctx.is_interface_type(name) + || is_imported_class_name(name) + // A `function Q() {…}` used as a constructor (`new Q()`) + // types its instances `Named("Q")`, but it is not a class + // decl, so `lookup_class` misses it. Its methods live on + // `Q.prototype` (registered via + // `Expr::RegisterFunctionPrototypeMethod`), and when one of + // them shares an Array name — `Q.prototype.push`, the shape + // denque uses for mysql2's command queue — the array fast + // path folded `q.push(x)` to `Expr::ArrayPush`, read the + // instance's ObjectHeader as an ArrayHeader (silently + // corrupting it) and never ran the method. + || ctx.functions_index.contains_key(name.as_str()) + } + Type::Generic { base, .. } => { + !builtin_generic_bases.contains(&base.as_str()) + && (ctx.lookup_class(base).is_some() || is_imported_class_name(base)) + } + Type::Union(variants) => variants + .iter() + .any(|v| type_is_class_instance(v, ctx, builtin_generic_bases)), + _ => false, + } +} + +/// #10796: is `ty` a `Named`/`Generic` (i.e. class-shaped, non-`Array`) type +/// — looking *through* `Union`, at any nesting depth. A narrower, `ctx`-free +/// sibling of `type_is_class_instance` above: this one doesn't consult the +/// class registry, it just asks "does this look like a class rather than an +/// array", which is what the per-method-name match below (inside the array +/// block) wants as its own belt-and-suspenders check. Duplicated in +/// `array_only_methods.rs` as `is_named_or_generic_non_array` — both are +/// six lines and `ctx`-free, so a shared home would cost more in +/// cross-module plumbing than it saves. +fn is_named_or_generic_non_array(ty: &Type) -> bool { + match ty { + Type::Named(_) | Type::Generic { .. } => !matches!(ty, Type::Array(_)), + Type::Union(variants) => variants.iter().any(is_named_or_generic_non_array), + _ => false, + } +} + pub(super) fn try_local_array_methods( ctx: &mut LoweringContext, call: &ast::CallExpr, @@ -141,48 +235,20 @@ pub(super) fn try_local_array_methods( // to the class method, not runtime js_array_push. Map/Set/Promise are // handled by explicit checks within the array block below. let builtin_generic_bases = ["Map", "Set", "WeakMap", "WeakSet", "Promise"]; - // Imported classes don't show up in `lookup_class`; treat any - // uppercase imported identifier as a candidate class so the - // array fast-path doesn't swallow `coll.find(filter)` etc. - let is_imported_class_name = |n: &str| -> bool { - if let Some(c) = n.chars().next() { - if c.is_uppercase() && ctx.lookup_imported_func(n).is_some() { - return true; - } - } - false - }; - let is_user_class_instance = match type_info { - // A class instance OR an interface-typed value is the - // receiver's own object — its method must be dispatched, not - // the array fast path. Interfaces aren't classes (so - // `lookup_class` misses them); without `is_interface_type`, - // an interface-typed receiver with e.g. an own `push` folded - // to `Expr::ArrayPush`, read the object header as an - // ArrayHeader, and silently dropped the call (follow-up to - // #5139, which fixed only `any`-typed receivers). - Some(Type::Named(name)) => { - ctx.lookup_class(name).is_some() - || ctx.is_interface_type(name) - || is_imported_class_name(name) - // A `function Q() {…}` used as a constructor (`new Q()`) - // types its instances `Named("Q")`, but it is not a class - // decl, so `lookup_class` misses it. Its methods live on - // `Q.prototype` (registered via - // `Expr::RegisterFunctionPrototypeMethod`), and when one of - // them shares an Array name — `Q.prototype.push`, the shape - // denque uses for mysql2's command queue — the array fast - // path folded `q.push(x)` to `Expr::ArrayPush`, read the - // instance's ObjectHeader as an ArrayHeader (silently - // corrupting it) and never ran the method. - || ctx.functions_index.contains_key(name.as_str()) - } - Some(Type::Generic { base, .. }) => { - !builtin_generic_bases.contains(&base.as_str()) - && (ctx.lookup_class(base).is_some() || is_imported_class_name(base)) - } - _ => false, - }; + // #10796: `type_is_class_instance` carries the `Named`/ + // `Generic` checks (plus a `Union` arm, recursed so nested + // unions are covered too — see its doc comment) that used to + // live inline here as a `match type_info { ... _ => false }`. + // A bare `match` on `type_info: Option<&Type>` only ever saw + // `Named`/`Generic` directly; a receiver typed as a `Union` + // containing a class (`Foo | undefined`, cheerio's + // `Cheerio | undefined`) fell to `_ => false` and + // was treated as "not a class instance", letting a method + // name shared with `Array.prototype` (`find`, `map`, …) fold + // to the array fast path on a real class instance. + let is_user_class_instance = type_info + .map(|ty| type_is_class_instance(ty, ctx, &builtin_generic_bases)) + .unwrap_or(false); // When the receiver type is Any and the method name is one // commonly defined on user classes too (e.g. mongo's // `Collection.find(filter)`), skip the array fast-path so the @@ -606,10 +672,7 @@ pub(super) fn try_local_array_methods( let is_class_instance = !is_typed_array && recv_ty .as_ref() - .map(|ty| { - matches!(ty, Type::Named(_) | Type::Generic { .. }) - && !matches!(ty, Type::Array(_)) - }) + .map(|ty| is_named_or_generic_non_array(ty)) .unwrap_or(false); // Issue #514: gate `.at()` ArrayAt // emission on a statically-known @@ -1186,4 +1249,99 @@ mod tests { named("NumberLike").as_ref() )); } + + // #10796: `type_is_class_instance` must see a class *through* a `Union`, + // at any nesting depth — the guard this backs (`is_user_class_instance` + // in `try_local_array_methods`) is what stops a class's own + // `find`/`map`/`filter`/… method from folding to the `Array.prototype` + // fast path. A `LoweringContext` with a registered class stands in for + // a real module lowering; `builtin_generic_bases` mirrors the literal + // used at the real call site. + fn test_ctx_with_class(name: &str) -> LoweringContext { + let mut ctx = LoweringContext::new("union-class-instance-test.ts"); + let id = ctx.fresh_class(); + ctx.register_class(name.to_string(), id); + ctx + } + + const NO_BUILTIN_GENERIC_BASES: &[&str] = &["Map", "Set", "WeakMap", "WeakSet", "Promise"]; + + #[test] + fn bare_named_class_is_class_instance() { + let ctx = test_ctx_with_class("Foo"); + assert!(type_is_class_instance( + &Type::Named("Foo".to_string()), + &ctx, + NO_BUILTIN_GENERIC_BASES, + )); + } + + #[test] + fn bare_generic_class_is_class_instance() { + // The real-world trigger: `Cheerio` — a generic instance of + // an imported/registered class. + let ctx = test_ctx_with_class("Cheerio"); + assert!(type_is_class_instance( + &Type::Generic { + base: "Cheerio".to_string(), + type_args: vec![Type::Named("AnyNode".to_string())], + }, + &ctx, + NO_BUILTIN_GENERIC_BASES, + )); + } + + #[test] + fn union_with_named_class_member_is_class_instance() { + // `Foo | undefined` — e.g. `function make(): Foo | undefined`. + // Before #10796's fix, `Type::Union` fell through the match's + // `_ => false` arm and this returned `false`. + let ctx = test_ctx_with_class("Foo"); + let ty = Type::Union(vec![Type::Named("Foo".to_string()), Type::Void]); + assert!(type_is_class_instance(&ty, &ctx, NO_BUILTIN_GENERIC_BASES)); + } + + #[test] + fn union_with_generic_class_member_is_class_instance() { + // cheerio's real shape: `searchContext: Cheerio | undefined` + // in `load.ts`, whose `.find(selector)` call is a CSS-selector + // method mixed onto `Cheerio.prototype` at runtime — not + // `Array.prototype.find`. A `Named`-only fix would miss this arm + // and leave cheerio broken. + let ctx = test_ctx_with_class("Cheerio"); + let ty = Type::Union(vec![ + Type::Generic { + base: "Cheerio".to_string(), + type_args: vec![Type::Named("AnyNode".to_string())], + }, + Type::Void, + ]); + assert!(type_is_class_instance(&ty, &ctx, NO_BUILTIN_GENERIC_BASES)); + } + + #[test] + fn nested_union_with_class_member_is_class_instance() { + // `type_alias_resolve.rs`'s `resolve_type_inner` can push a resolved + // union member as-is (not flattened) when that member is itself an + // alias to a union type, producing `Union([Union([...]), ...])`. The + // `Union` arm must recurse, not just `.any()` one level deep. + let ctx = test_ctx_with_class("Foo"); + let inner = Type::Union(vec![Type::Named("Foo".to_string()), Type::Number]); + let outer = Type::Union(vec![inner, Type::Void]); + assert!(type_is_class_instance( + &outer, + &ctx, + NO_BUILTIN_GENERIC_BASES + )); + } + + #[test] + fn union_without_a_class_member_is_not_a_class_instance() { + // Negative control: a union of genuinely non-class types must stay + // `false`, so e.g. `string | number` doesn't spuriously skip the + // array fast path. + let ctx = test_ctx_with_class("Foo"); + let ty = Type::Union(vec![Type::String, Type::Number]); + assert!(!type_is_class_instance(&ty, &ctx, NO_BUILTIN_GENERIC_BASES)); + } } diff --git a/test-files/test_gap_10796_union_class_find_not_array_fold.ts b/test-files/test_gap_10796_union_class_find_not_array_fold.ts new file mode 100644 index 0000000000..5c9af1958f --- /dev/null +++ b/test-files/test_gap_10796_union_class_find_not_array_fold.ts @@ -0,0 +1,35 @@ +// #10796: a method call on a receiver typed as a `Union` containing a class +// (e.g. `Foo | undefined`) must dispatch to the class's own method when the +// method name collides with an `Array.prototype` name — not fold to the +// array fast path. +// +// Root cause: `crates/perry-hir/src/lower/expr_call/local_array_methods.rs`'s +// `is_user_class_instance` guard (the thing that stops a user class's own +// `find`/`map`/`filter`/… method from being rewritten to `Expr::ArrayFind` +// et al., since the runtime dispatch of the array fast path calls its +// argument as a *callback*) only matched `Type::Named`/`Type::Generic` +// directly. A receiver whose static type is `Type::Union([Type::Named(...), +// Type::Void])` fell through the match's `_ => false` arm, so the union type +// read as "not a user class instance", and `f.find(x)` below folded to +// `Expr::ArrayFind`, which called the string argument `x` as a per-element +// predicate — `TypeError: string "ul#fruits" is not a function`. +// +// This is exactly the shape cheerio's `load.ts` hits: `searchContext: +// Cheerio | undefined`, whose `.find(selector)` is a CSS-selector +// method mixed onto `Cheerio.prototype` at runtime (`Object.assign( +// Cheerio.prototype, ..., Traversing, ...)`), not `Array.prototype.find`. +class Foo { + find(x: string): string { + return "custom-find:" + x; + } +} + +function make(flag: boolean): Foo | undefined { + return flag ? new Foo() : undefined; +} + +const f: Foo | undefined = make(true); +if (!f) { + throw new Error("unreachable"); +} +console.log(f.find("ul#fruits")); diff --git a/test-files/test_gap_10796_union_generic_class_array_overlap_methods.ts b/test-files/test_gap_10796_union_generic_class_array_overlap_methods.ts new file mode 100644 index 0000000000..61b2d9d0fe --- /dev/null +++ b/test-files/test_gap_10796_union_generic_class_array_overlap_methods.ts @@ -0,0 +1,65 @@ +// #10796: the same guard covers the whole "shares a name with +// Array.prototype" method set — find, findIndex, findLast, findLastIndex, +// map, filter, some, every, forEach, reduce, reduceRight, join, plus the +// mutators push/pop/shift/unshift — not just `find`. This fixture exercises +// a representative few of them (find, map, filter, forEach, reduce, push) +// on ONE receiver so a fix verified only on `find` can't pass here while +// leaving the others silently folding to the array fast path. +// +// It also pins the real-world trigger: the receiver's static type is a +// `Union` containing a *generic* class instance (`Box | undefined`), +// matching cheerio's `searchContext: Cheerio | undefined` in +// `load.ts` — a `Type::Named`-only fix would pass a simpler +// `Foo | undefined` test while leaving `Cheerio | undefined` +// (and so cheerio itself) still misrouting through `Type::Generic` inside +// the union. +// +// `Box` is shaped like cheerio's `Cheerio` on purpose (`length` + +// a numeric index signature — "array-like" is exactly the shape that makes +// the array fast path plausible in the first place). +class Box { + length = 0; + [index: number]: T; + label: string; + + constructor(label: string) { + this.label = label; + } + + find(selector: string): string { + return `${this.label}.find(${selector})`; + } + map(selector: string): string { + return `${this.label}.map(${selector})`; + } + filter(selector: string): string { + return `${this.label}.filter(${selector})`; + } + forEach(selector: string): string { + return `${this.label}.forEach(${selector})`; + } + reduce(selector: string): string { + return `${this.label}.reduce(${selector})`; + } + push(selector: string): string { + return `${this.label}.push(${selector})`; + } +} + +function make(flag: boolean): Box | undefined { + return flag ? new Box("box") : undefined; +} + +const b: Box | undefined = make(true); +if (!b) { + throw new Error("unreachable"); +} + +const results: string[] = []; +results.push(b.find("a")); +results.push(b.map("b")); +results.push(b.filter("c")); +results.push(b.forEach("d")); +results.push(b.reduce("e")); +results.push(b.push("f")); +console.log(results.join(" | ")); From 418a9e9ca15f07f3c1620a5b186fd305b82e36ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 21:55:50 +0200 Subject: [PATCH 2/2] changelog: add fragment for #11035 (#10796) --- .../11035-union-class-array-method-guard.md | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 changelog.d/11035-union-class-array-method-guard.md diff --git a/changelog.d/11035-union-class-array-method-guard.md b/changelog.d/11035-union-class-array-method-guard.md new file mode 100644 index 0000000000..55d7cb6d68 --- /dev/null +++ b/changelog.d/11035-union-class-array-method-guard.md @@ -0,0 +1,24 @@ +Fixed a miscompile where a class receiver typed as a `Union` (`Foo | +undefined`, or a generic like cheerio's `Cheerio | undefined`) +had a method call folded to the dense `Array.prototype` fast path whenever +the method name collided with a real Array method (`find`, `map`, +`filter`, `some`, `every`, `forEach`, `reduce`, `reduceRight`, `join`, +`findIndex`, `findLast`, `findLastIndex`, `push`). Three separate guards +in `crates/perry-hir/src/lower/expr_call/{local_array_methods,array_only_methods}.rs` +matched `Type::Named`/`Type::Generic` directly but had no `Type::Union` +arm, so a union-typed class receiver read as "not a class instance" and +the fold went ahead — calling the user's argument as an `Array.prototype` +callback, or reading the class instance's `ObjectHeader` as an +`ArrayHeader`. + +cheerio (`cheerio.load(html)("selector")`) hit this on its most basic +operation: `load.ts`'s `searchContext.find(search)`, where `searchContext: +Cheerio | undefined` and `find` is cheerio's own CSS-selector +method, mixed onto `Cheerio.prototype` at runtime — every call threw +`TypeError: string "..." is not a function`. `cheerio@1.2.0` now compiles +and runs end-to-end, byte-identical to Node. + +All three guards now recurse through `Type::Union` (nested unions +included) using the same per-variant test they already applied to a bare +receiver, matching the idiom the surrounding code already used in six +other places in `local_array_methods.rs`. #10796