From 6c70fefd33170e4200b64fef9339587712fb0500 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 06:48:01 +0000 Subject: [PATCH 1/2] fix(hir): give an imported constructor's instanceof its value x instanceof F was always false when F was an imported non-class constructor function. Lowering only attached a runtime value to an identifier RHS for a local, a module function or a native module, so an imported binding reached codegen as a bare name, resolved to no class id and folded to js_instanceof(v, 0) - false for every instance. Every import form was affected (named, default, CJS module.exports/exports.F), while ns.F, a local alias and the check inside the defining module all worked. Imported bindings now lower to their value and take the prototype-chain path. Codegen keeps the static class-id check for an imported class, and for a binding that is not a compiled source-module import (its value form is a placeholder), so the class fast path and the reserved builtin ids are unchanged. --- .../perry-codegen/src/expr/instance_misc1.rs | 38 ++++- .../src/expr/instanceof_imported_rhs_tests.rs | 152 ++++++++++++++++++ crates/perry-codegen/src/expr/mod.rs | 2 + .../perry-hir/src/lower/lower_expr/arm_bin.rs | 9 ++ crates/perry-hir/src/lower/tests.rs | 1 + .../src/lower/tests/instanceof_rhs.rs | 79 +++++++++ .../issue_10477_fn_ctor/cjs_default.cjs | 7 + .../issue_10477_fn_ctor/cjs_named.cjs | 4 + .../issue_10477_fn_ctor/default_fn.ts | 3 + .../issue_10477_fn_ctor/default_var.ts | 11 ++ .../fixtures/issue_10477_fn_ctor/lib.ts | 92 +++++++++++ ...10477_instanceof_imported_function_ctor.ts | 117 ++++++++++++++ 12 files changed, 514 insertions(+), 1 deletion(-) create mode 100644 crates/perry-codegen/src/expr/instanceof_imported_rhs_tests.rs create mode 100644 crates/perry-hir/src/lower/tests/instanceof_rhs.rs create mode 100644 test-files/fixtures/issue_10477_fn_ctor/cjs_default.cjs create mode 100644 test-files/fixtures/issue_10477_fn_ctor/cjs_named.cjs create mode 100644 test-files/fixtures/issue_10477_fn_ctor/default_fn.ts create mode 100644 test-files/fixtures/issue_10477_fn_ctor/default_var.ts create mode 100644 test-files/fixtures/issue_10477_fn_ctor/lib.ts create mode 100644 test-files/test_gap_10477_instanceof_imported_function_ctor.ts diff --git a/crates/perry-codegen/src/expr/instance_misc1.rs b/crates/perry-codegen/src/expr/instance_misc1.rs index 4c2690b505..6a9bb1cff6 100644 --- a/crates/perry-codegen/src/expr/instance_misc1.rs +++ b/crates/perry-codegen/src/expr/instance_misc1.rs @@ -126,6 +126,39 @@ pub(crate) fn builtin_parent_reserved_class_id(name: &str) -> Option { }) } +/// #10477: HIR hands `x instanceof F` with an IMPORTED `F` to codegen as a +/// dynamic RHS (the binding's `ExternFuncRef` value), because only codegen +/// knows what the import resolved to. Returns `true` when the static +/// `js_instanceof(v, )` path below still answers the question, so the +/// value is never materialized: +/// +/// - an imported CLASS: its value is the INT32 class-ref immediate that +/// `js_instanceof_dynamic` would only unpack back to the same id. The filter +/// mirrors `ExternFuncRef`'s value lowering (`dyn_extern_i18n.rs`), so class +/// metadata that is not this lexical binding never claims it; +/// - a binding that is not a compiled source-module import (a V8-fallback or +/// node-submodule import, an FFI `declare function`, an unresolved name): its +/// value form is a placeholder, and these keep their reserved-id mapping. +/// +/// Every other import (a function constructor, an exported `const` holding +/// one, a CJS `module.exports = F`) has no class id, so the static path folded +/// it to id 0 and the check was always `false`. `name == ty` confines this to +/// the bare-identifier RHS; a parenthesized or cast RHS was dynamic before. +fn imported_instanceof_rhs_is_static(ctx: &FnCtx<'_>, ty: &str, ty_expr: &Expr) -> bool { + let Expr::ExternFuncRef { name, .. } = ty_expr else { + return false; + }; + if name != ty { + return false; + } + let imported_class = ctx.class_ids.contains_key(name) + && !ctx.imported_vars.contains(name) + && !ctx.namespace_imports.contains(name); + imported_class + || !ctx.import_function_prefixes.contains_key(name) + || ctx.import_function_v8_specifiers.contains_key(name) +} + fn emit_with_key(ctx: &mut FnCtx<'_>, property: &str) -> (String, String) { let key_idx = ctx.strings.intern(property); let key_entry = ctx.strings.entry(key_idx); @@ -334,7 +367,10 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // #7615 slice 2: `v` is live across the RHS's lowering, so the pair // is rooted as a group. The static-RHS path below lowers nothing // after `v` and keeps its plain `lower_expr`. - if let Some(ty_e) = ty_expr { + if let Some(ty_e) = ty_expr + .as_deref() + .filter(|ty_e| !imported_instanceof_rhs_is_static(ctx, ty, ty_e)) + { return rooting::with_operands_rooted(ctx, &[e, ty_e], |ctx, vals| { Ok(ctx.block().call( DOUBLE, diff --git a/crates/perry-codegen/src/expr/instanceof_imported_rhs_tests.rs b/crates/perry-codegen/src/expr/instanceof_imported_rhs_tests.rs new file mode 100644 index 0000000000..a58793068b --- /dev/null +++ b/crates/perry-codegen/src/expr/instanceof_imported_rhs_tests.rs @@ -0,0 +1,152 @@ +//! #10477: `x instanceof F` where `F` is an IMPORTED binding. +//! +//! HIR cannot tell an imported function constructor from an imported class — +//! both are `ExternFuncRef` bindings — so it hands every imported RHS to +//! codegen as a dynamic `ty_expr` and codegen decides. Both directions are +//! asserted here, because each one silently degrades in a way no other test +//! sees: +//! +//! * a compiled-source import with no class id must reach +//! `js_instanceof_dynamic`, which resolves the constructor value and walks +//! the prototype chain. Before #10477 it folded to `js_instanceof(v, 0)` — +//! a well-formed call that always answers `false`; +//! * an imported CLASS must keep the static `js_instanceof(v, )` +//! check. Routing it through the dynamic helper would still be correct, so +//! only an IR census catches the regression: the class fast path would just +//! get slower. + +use crate::{compile_module, CompileOptions, ImportedClass}; +use perry_hir::types::Type; +use perry_hir::{Expr, Module, Stmt}; + +const DYNAMIC_CALL: &str = "call double @js_instanceof_dynamic("; +const STATIC_CALL: &str = "call double @js_instanceof("; + +fn imported_ref(name: &str) -> Expr { + Expr::ExternFuncRef { + name: name.to_string(), + param_types: Vec::new(), + return_type: Type::Any, + } +} + +/// `{} instanceof ` with the imported binding's value attached, exactly +/// as `lower_expr/arm_bin.rs` lowers a bare imported identifier RHS. +fn instanceof_imported(name: &str) -> Module { + let mut module = Module::new("instanceof_imported.ts"); + module.init = vec![Stmt::Expr(Expr::InstanceOf { + expr: Box::new(Expr::Object(Vec::new())), + ty: name.to_string(), + ty_expr: Some(Box::new(imported_ref(name))), + })]; + module +} + +fn imported_class(name: &str, class_id: u32) -> ImportedClass { + ImportedClass { + name: name.to_string(), + local_alias: None, + namespace: None, + source_prefix: "lib_ts".to_string(), + constructor_param_count: 0, + has_own_constructor: true, + constructor_has_rest: false, + has_instance_fields: false, + method_names: Vec::new(), + proven_this_method_names: Vec::new(), + proven_this_tower_method_names: Vec::new(), + method_return_types: Vec::new(), + method_param_counts: Vec::new(), + method_has_rest: Vec::new(), + method_has_synthetic_arguments: Vec::new(), + method_arguments_length_only: Vec::new(), + static_field_names: Vec::new(), + static_method_names: Vec::new(), + static_method_return_types: Vec::new(), + static_method_param_counts: Vec::new(), + static_method_has_rest: Vec::new(), + static_method_has_user_rest: Vec::new(), + static_method_has_synthetic_arguments: Vec::new(), + getter_names: Vec::new(), + getter_return_types: Vec::new(), + setter_names: Vec::new(), + parent_name: None, + field_names: Vec::new(), + field_types: Vec::new(), + source_class_id: Some(class_id), + return_shape_imports: Vec::new(), + object_literal: None, + } +} + +fn compile(module: &Module, opts: CompileOptions) -> String { + String::from_utf8(compile_module(module, opts).expect("instanceof module compiles")) + .expect("LLVM IR is UTF-8") +} + +#[test] +fn imported_function_constructor_rhs_resolves_the_constructor_value() { + let mut opts = CompileOptions { + emit_ir_only: true, + ..Default::default() + }; + opts.import_function_prefixes + .insert("Plain".to_string(), "lib_ts".to_string()); + let ir = compile(&instanceof_imported("Plain"), opts); + + assert!( + ir.contains(DYNAMIC_CALL), + "an imported function constructor must resolve its value and walk the \ + prototype chain:\n{ir}" + ); + assert!( + !ir.contains(STATIC_CALL), + "the class-id check has no id for a function import — it folds to \ + `false` (#10477):\n{ir}" + ); +} + +#[test] +fn imported_class_rhs_keeps_the_static_class_id_check() { + let mut opts = CompileOptions { + emit_ir_only: true, + ..Default::default() + }; + opts.import_function_prefixes + .insert("Klass".to_string(), "lib_ts".to_string()); + opts.imported_classes.push(imported_class("Klass", 7701)); + let ir = compile(&instanceof_imported("Klass"), opts); + + assert!( + ir.contains(STATIC_CALL), + "an imported class must keep the static class-id check:\n{ir}" + ); + assert!( + !ir.contains(DYNAMIC_CALL), + "the dynamic helper would only unpack the same class id back out:\n{ir}" + ); +} + +#[test] +fn unresolved_import_rhs_keeps_its_reserved_builtin_id() { + // Not a compiled source module (nothing in `import_function_prefixes`): + // the binding's value form is a placeholder, so the reserved-id mapping + // stays — `js_instanceof_dynamic` on a placeholder would throw instead of + // answering. + let ir = compile( + &instanceof_imported("Error"), + CompileOptions { + emit_ir_only: true, + ..Default::default() + }, + ); + + assert!( + ir.contains(STATIC_CALL), + "an unresolved import must keep the static reserved-id check:\n{ir}" + ); + assert!( + !ir.contains(DYNAMIC_CALL), + "nothing resolves this binding to a constructor value:\n{ir}" + ); +} diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index fae77a1dba..ffd9b983be 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -170,6 +170,8 @@ mod entry_block_alloca_tests; mod hit_path_access_tests; #[cfg(test)] mod index_set_barrier_tests; +#[cfg(test)] +mod instanceof_imported_rhs_tests; mod record_value; mod repsel_gates; mod scalar_slot_root; diff --git a/crates/perry-hir/src/lower/lower_expr/arm_bin.rs b/crates/perry-hir/src/lower/lower_expr/arm_bin.rs index 738e4a9918..0642f86da4 100644 --- a/crates/perry-hir/src/lower/lower_expr/arm_bin.rs +++ b/crates/perry-hir/src/lower/lower_expr/arm_bin.rs @@ -148,9 +148,18 @@ pub(crate) fn lower_bin_expr(ctx: &mut LoweringContext, bin: &ast::BinExpr) -> R // route through `js_instanceof_dynamic`, which derives the same // `synthetic_class_id_for_function` that `new Foo()` stamps onto // the instance (see js_new_function_construct). + // + // #10477: an IMPORTED binding is a runtime value too. It has + // no class entry in this module unless it is an imported + // class, so without its value an imported function + // constructor (`import F from "./f.js"`, CJS + // `module.exports = F`, decimal.js's `Decimal`) folded to + // class_id 0 and `x instanceof F` was always false. Codegen + // keeps the static class-id check for imported classes. if ctx.lookup_local(name).is_some() || ctx.lookup_func(name).is_some() || ctx.lookup_native_module(name).is_some() + || ctx.lookup_imported_func(name).is_some() { match lower_expr(ctx, &bin.right) { Ok(e) => Some(Box::new(e)), diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index f127f3eff9..afa26dba29 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -15,6 +15,7 @@ fn make_ctx() -> LoweringContext { LoweringContext::new("test.ts") } +mod instanceof_rhs; mod literal_shape; #[test] diff --git a/crates/perry-hir/src/lower/tests/instanceof_rhs.rs b/crates/perry-hir/src/lower/tests/instanceof_rhs.rs new file mode 100644 index 0000000000..421f5035a9 --- /dev/null +++ b/crates/perry-hir/src/lower/tests/instanceof_rhs.rs @@ -0,0 +1,79 @@ +//! #10477: which `instanceof` right-hand sides carry a runtime value. +//! +//! An identifier RHS only reaches `js_instanceof_dynamic` when lowering +//! attaches the binding's value as `ty_expr`; otherwise codegen resolves the +//! bare NAME to a class id, and a name with no class entry folds to +//! `js_instanceof(v, 0)` — always `false`. An imported binding was in exactly +//! that position: `import { Plain } from "./lib.js"; x instanceof Plain` was +//! false for every non-class constructor, in every import form, while +//! `ns.Plain`, `const A = Plain` and the same check inside `lib.js` all worked. +//! +//! The builtin case is the other half: `x instanceof Date` must NOT grow a +//! value, because the reserved class id is what brand-checks a native Date. + +use super::*; + +fn instanceof_rhs_value(source: &str, function: &str) -> Option { + let module = + perry_parser::parse_typescript(source, "instanceof-rhs.ts").expect("source parses"); + let hir = crate::lower::lower_module(&module, "instanceof-rhs", "instanceof-rhs.ts") + .expect("source lowers"); + let body = hir + .functions + .iter() + .find(|f| f.name == function) + .unwrap_or_else(|| panic!("`{function}` must be lowered: {hir:?}")) + .body + .clone(); + match body.first() { + Some(Stmt::Return(Some(Expr::InstanceOf { ty_expr, .. }))) => { + ty_expr.as_ref().map(|value| (**value).clone()) + } + other => panic!("`{function}` must lower to a returned instanceof: {other:?}"), + } +} + +#[test] +fn imported_binding_instanceof_rhs_carries_its_value() { + let rhs = instanceof_rhs_value( + r#" + import { Plain } from "./lib.js"; + export function check(x: unknown) { return x instanceof Plain; } + "#, + "check", + ); + assert!( + matches!(&rhs, Some(Expr::ExternFuncRef { name, .. }) if name == "Plain"), + "an imported RHS must be lowered to its value so the dynamic path can \ + resolve the constructor (#10477): {rhs:?}" + ); +} + +#[test] +fn builtin_instanceof_rhs_stays_a_static_name() { + let rhs = instanceof_rhs_value( + r#" + export function check(x: unknown) { return x instanceof Date; } + "#, + "check", + ); + assert!( + rhs.is_none(), + "an unshadowed builtin RHS must keep the reserved-class-id check: {rhs:?}" + ); +} + +#[test] +fn local_function_constructor_instanceof_rhs_carries_its_value() { + let rhs = instanceof_rhs_value( + r#" + function Plain(this: any) {} + export function check(x: unknown) { return x instanceof Plain; } + "#, + "check", + ); + assert!( + matches!(&rhs, Some(Expr::FuncRef(_))), + "a module-local function constructor keeps its pre-#10477 value RHS: {rhs:?}" + ); +} diff --git a/test-files/fixtures/issue_10477_fn_ctor/cjs_default.cjs b/test-files/fixtures/issue_10477_fn_ctor/cjs_default.cjs new file mode 100644 index 0000000000..83b05a4bc2 --- /dev/null +++ b/test-files/fixtures/issue_10477_fn_ctor/cjs_default.cjs @@ -0,0 +1,7 @@ +function CjsCtor(v) { + this.v = v; +} +CjsCtor.prototype.get = function () { + return this.v; +}; +module.exports = CjsCtor; diff --git a/test-files/fixtures/issue_10477_fn_ctor/cjs_named.cjs b/test-files/fixtures/issue_10477_fn_ctor/cjs_named.cjs new file mode 100644 index 0000000000..6137f9d086 --- /dev/null +++ b/test-files/fixtures/issue_10477_fn_ctor/cjs_named.cjs @@ -0,0 +1,4 @@ +function Named(v) { + this.v = v; +} +exports.Named = Named; diff --git a/test-files/fixtures/issue_10477_fn_ctor/default_fn.ts b/test-files/fixtures/issue_10477_fn_ctor/default_fn.ts new file mode 100644 index 0000000000..43fec252c0 --- /dev/null +++ b/test-files/fixtures/issue_10477_fn_ctor/default_fn.ts @@ -0,0 +1,3 @@ +export default function DefaultFn(this: any, v: number) { + this.v = v; +} diff --git a/test-files/fixtures/issue_10477_fn_ctor/default_var.ts b/test-files/fixtures/issue_10477_fn_ctor/default_var.ts new file mode 100644 index 0000000000..4e16f8c63f --- /dev/null +++ b/test-files/fixtures/issue_10477_fn_ctor/default_var.ts @@ -0,0 +1,11 @@ +// `var D = factory(); export default D` — decimal.js's module shape. +function factory() { + function Dec(this: any, v: number): any { + if (!(this instanceof Dec)) return new (Dec as any)(v); + this.v = v; + } + Dec.prototype = { constructor: Dec }; + return Dec; +} +var D: any = factory(); +export default D; diff --git a/test-files/fixtures/issue_10477_fn_ctor/lib.ts b/test-files/fixtures/issue_10477_fn_ctor/lib.ts new file mode 100644 index 0000000000..280b6a18ee --- /dev/null +++ b/test-files/fixtures/issue_10477_fn_ctor/lib.ts @@ -0,0 +1,92 @@ +// #10477 fixture: function constructors (not classes) exported from a module, +// checked with `instanceof` by the importer. +import { inherits } from "node:util"; + +// Prototype untouched. +export function Plain(this: any, v: number) { + this.v = v; +} +Plain.prototype.get = function (this: any) { + return this.v; +}; + +// Prototype replaced wholesale (the bignumber.js / decimal.js shape). +export function Swapped(this: any, v: number) { + this.v = v; +} +Swapped.prototype = { + constructor: Swapped, + get(this: any) { + return this.v; + }, +}; + +// Controls: real classes keep the static class-id check. +export class Klass { + v: number; + constructor(v: number) { + this.v = v; + } +} +export class SubKlass extends Klass {} +export const ExprKlass = class { + w = 2; +}; + +// ES5 inheritance, both idioms. +export function Base(this: any) { + this.base = true; +} +export function Inherited(this: any) { + Base.call(this); +} +inherits(Inherited, Base); +export function Linked(this: any) {} +Object.setPrototypeOf(Linked.prototype, Base.prototype); + +// An own Symbol.hasInstance overrides the prototype walk. +export function Duck() {} +Object.defineProperty(Duck, Symbol.hasInstance, { + value: (x: any) => !!x && x.quack === true, +}); + +// User constructors that share a name with a builtin the static path maps to a +// reserved class id. +export function Headers(this: any) { + this.h = 1; +} +export function EventEmitter(this: any) { + this.e = 1; +} +export function Stream(this: any) { + this.s = 1; +} + +// Factory-built constructor held in an exported const (decimal.js `clone()`). +function factory() { + function Made(this: any, v: number): any { + if (!(this instanceof Made)) return new (Made as any)(v); + this.v = v; + } + Made.prototype = { constructor: Made }; + return Made; +} +export const MadeConst: any = factory(); + +// A live binding: the importer must read the current value. +export let Rebound: any = function First(this: any) {}; +export function rebind() { + Rebound = function Second(this: any) {}; +} + +export const notCallable = { prototype: {} }; + +export function makePlain(v: number) { + return new (Plain as any)(v); +} +export function makeSwapped(v: number) { + return new (Swapped as any)(v); +} +export function isPlainHere(x: unknown) { + return x instanceof Plain; +} diff --git a/test-files/test_gap_10477_instanceof_imported_function_ctor.ts b/test-files/test_gap_10477_instanceof_imported_function_ctor.ts new file mode 100644 index 0000000000..bd4e14275c --- /dev/null +++ b/test-files/test_gap_10477_instanceof_imported_function_ctor.ts @@ -0,0 +1,117 @@ +// #10477: `x instanceof F` was always false when `F` is an IMPORTED non-class +// constructor function, for every import form. HIR only routed a bare +// identifier RHS through the dynamic `js_instanceof_dynamic` path when it was a +// local / module function / native module; an imported binding fell to the +// static class-id path, which has no id for a function and folded to `false`. +// `ns.F`, a local alias, and the check inside the defining module all worked. +// Imported classes are controls: they keep the static class-id check. + +import { + Plain, + Swapped, + Klass, + SubKlass, + ExprKlass, + Base, + Inherited, + Linked, + Duck, + Headers, + EventEmitter, + Stream, + MadeConst, + Rebound, + rebind, + notCallable, + makePlain, + makeSwapped, + isPlainHere, +} from "./fixtures/issue_10477_fn_ctor/lib.ts"; +import { Plain as RenamedPlain, Klass as RenamedKlass } from "./fixtures/issue_10477_fn_ctor/lib.ts"; +import * as ns from "./fixtures/issue_10477_fn_ctor/lib.ts"; +import DefaultFn from "./fixtures/issue_10477_fn_ctor/default_fn.ts"; +import D from "./fixtures/issue_10477_fn_ctor/default_var.ts"; +import CjsCtor from "./fixtures/issue_10477_fn_ctor/cjs_default.cjs"; +import { Named } from "./fixtures/issue_10477_fn_ctor/cjs_named.cjs"; + +const show = (label: string, value: unknown) => console.log(label, value); +const P: any = Plain; +const S: any = Swapped; + +// Prototype untouched. +show("plain importer-made", new P(1) instanceof Plain); +show("plain definer-made", makePlain(1) instanceof Plain); +show("plain renamed import", new P(1) instanceof RenamedPlain); +show("plain in defining module", isPlainHere(new P(1))); +show("plain via namespace", new P(1) instanceof ns.Plain); +const Alias = Plain; +show("plain via local alias", new P(1) instanceof Alias); +show("plain Object.create", Object.create(P.prototype) instanceof Plain); +show("plain method", new P(7).get()); +show("plain vs {}", ({}) instanceof Plain); +show("plain vs number", (1 as any) instanceof Plain); +show("plain vs null", (null as any) instanceof Plain); +show("plain vs Swapped", new P(1) instanceof Swapped); + +// Prototype replaced. +show("swapped importer-made", new S(1) instanceof Swapped); +show("swapped definer-made", makeSwapped(1) instanceof Swapped); +show("swapped Object.create", Object.create(S.prototype) instanceof Swapped); +show("swapped vs Plain", new S(1) instanceof Plain); + +// Class controls. +show("klass", new Klass(1) instanceof Klass); +show("klass renamed import", new Klass(1) instanceof RenamedKlass); +show("subklass is klass", new SubKlass(1) instanceof Klass); +show("klass vs subklass", new Klass(1) instanceof SubKlass); +show("class expression", new ExprKlass() instanceof ExprKlass); +show("plain vs klass", new P(1) instanceof Klass); +show("klass vs plain", new Klass(1) instanceof Plain); + +// ES5 inheritance. +const I: any = Inherited; +const L: any = Linked; +show("util.inherits child", new I() instanceof Inherited); +show("util.inherits base", new I() instanceof Base); +show("setPrototypeOf child", new L() instanceof Linked); +show("setPrototypeOf base", new L() instanceof Base); +show("base vs child", new (Base as any)() instanceof Inherited); + +// Symbol.hasInstance override. +show("hasInstance yes", ({ quack: true }) instanceof Duck); +show("hasInstance no", ({ quack: false }) instanceof Duck); + +// Names that collide with a builtin the static path maps to a reserved class +// id. (The instances are built through a local alias: `new (Headers as any)()` +// still routes to the BUILTIN constructor, which is a separate defect.) +const UserHeaders: any = Headers; +const UserEmitter: any = EventEmitter; +const UserStream: any = Stream; +show("user Headers", new UserHeaders() instanceof Headers); +show("user EventEmitter", new UserEmitter() instanceof EventEmitter); +show("user Stream", new UserStream() instanceof Stream); +show("{} vs user Headers", ({}) instanceof Headers); + +// Other import forms. +show("factory const", MadeConst(1) instanceof MadeConst); +show("factory const new", new MadeConst(2) instanceof MadeConst); +show("export default function", new (DefaultFn as any)(1) instanceof DefaultFn); +show("export default var", D(1) instanceof D); +show("export default var new", new D(2) instanceof D); +show("cjs module.exports", new (CjsCtor as any)(1) instanceof CjsCtor); +show("cjs exports.Named", new (Named as any)(1) instanceof Named); +show("cjs vs Plain", new (CjsCtor as any)(1) instanceof Plain); + +// Live binding: the check reads the binding's current value. +const first = new Rebound(); +show("rebound before", first instanceof Rebound); +rebind(); +show("rebound after", first instanceof Rebound); +show("rebound new", new Rebound() instanceof Rebound); + +// A non-callable import is a TypeError, not a silent false. +try { + show("non-callable", ({}) instanceof notCallable); +} catch (e) { + show("non-callable throws", (e as Error).constructor.name); +} From 82b94ab5666ff6a1fc3530361c8f213ef3e52edc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 07:03:13 +0000 Subject: [PATCH 2/2] changelog: fragment for #10596 --- changelog.d/10596-instanceof-imported-fn-ctor.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 changelog.d/10596-instanceof-imported-fn-ctor.md diff --git a/changelog.d/10596-instanceof-imported-fn-ctor.md b/changelog.d/10596-instanceof-imported-fn-ctor.md new file mode 100644 index 0000000000..ba8005f9da --- /dev/null +++ b/changelog.d/10596-instanceof-imported-fn-ctor.md @@ -0,0 +1,11 @@ +### Fixed + +- **`x instanceof F` no longer folds to `false` for an imported non-class constructor.** Lowering + (`crates/perry-hir/src/lower/lower_expr/arm_bin.rs`) only attached a runtime value to an identifier + `instanceof` RHS for a local, a module function, or a native module — an imported binding was never + consulted, so codegen resolved the bare name to no class id and folded the check to + `js_instanceof(v, 0)` (always false). Affected every import form (named, default, CJS + `module.exports`/`exports.F`) for a plain ES5-style or factory-built constructor; `ns.F`, a local + alias, and the check written inside the defining module all worked already. Codegen + (`crates/perry-codegen/src/expr/instance_misc1.rs`) keeps the static class-id fast path for + imported classes and every non-compiled-source import, so those emit unchanged LLVM IR.