From df32c3f5e483268d259834acf0a5da0bf4b02b89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 13:49:28 +0000 Subject: [PATCH 1/2] fix(hir): keep the reified receiver for a computed dynamic key on a builtin namespace Math[k], JSON[k], Object[k] and other builtin namespace/constructor member reads with a non-literal computed key collapsed to the bare GlobalGet(0) intrinsic sentinel, so the read landed on the number 0 instead of the real object (Math[key](x) threw "(number).x is not a function"). #973's value-form reroute wraps these idents as PropertyGet{GlobalGet(0), name}; member_tail.rs undoes that reroute in member-object position so the intrinsic call/constant-fold paths for a STATICALLY-KNOWN member name (Math.max(...)) keep their pre-#973 bare receiver. That undo is only safe when the member name is known at lowering time -- outer_static_member is None for a computed non-literal key, which zeroed out the outer_is_reified_*/ outer_is_inherited_* guards instead of blocking the undo itself. Add outer_is_dynamic_computed_key to the existing conjunction so any dynamic key keeps the reified receiver, letting the runtime property lookup resolve against the real namespace/constructor object. Verified this needs no console carve-out (console[m](...) already falls back correctly to the generic dynamic-dispatch path once its receiver survives). Literal-key paths are untouched by construction -- the flag only fires for MemberProp::Computed with a non-string-literal key. --- .../src/lower/expr_member/member_tail.rs | 42 +++++ ...gap_10483_computed_key_namespace_member.ts | 155 ++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 test-files/test_gap_10483_computed_key_namespace_member.ts diff --git a/crates/perry-hir/src/lower/expr_member/member_tail.rs b/crates/perry-hir/src/lower/expr_member/member_tail.rs index 76acfd900f..d3b984a033 100644 --- a/crates/perry-hir/src/lower/expr_member/member_tail.rs +++ b/crates/perry-hir/src/lower/expr_member/member_tail.rs @@ -434,6 +434,47 @@ pub(crate) fn lower_member_tail( crate::analysis::is_builtin_static_function_member(property, member) }) .unwrap_or(false); + // #10483: a computed non-literal key (`Math[k]`, `JSON[op]`) + // cannot resolve to an intrinsic static at lowering time — + // `outer_static_member` is `None` for it, which only zeros out + // the `outer_is_reified_*`/`outer_is_inherited_*` flags above + // rather than blocking the undo below. Left unguarded, the undo + // hands codegen a bare `GlobalGet(0)` receiver and `Math[k]` + // reads a property of the number 0 instead of the + // namespace/constructor object. Keep the reified receiver for + // any dynamic key so the runtime property lookup runs against + // the real object. (`Array` already keeps its receiver for a + // dynamic key via `receiver_is_array_ctor_unknown_static` + // above — same `outer_static_member == None` trapdoor, fixed + // there first for a different reason (#5898); this flag is + // redundant-but-harmless for `Array` and load-bearing for + // every other builtin.) + // + // `console` is deliberately NOT excluded, despite `console[m]` + // having its own legacy workaround a few dozen lines down (the + // `js_console_method_by_value` IndexGet arm, added for the + // Next.js `prefixedLog` wall back when this same undo collapsed + // the receiver to the bare `GlobalGet(0)` sentinel for a + // call-position dynamic key). Verified rather than assumed: + // with this flag applied uniformly (no console carve-out), + // `console[method](msg)` lowers to a plain + // `IndexGet { PropertyGet{GlobalGet(0),"console"}, key }` + // dynamic call (confirmed via `--trace hir --focus`) instead of + // routing through `js_console_method_by_value` — and it runs + // correctly, because the real `console` receiver this flag now + // preserves is exactly what that generic dynamic-dispatch path + // needs. The old workaround only existed to compensate for the + // receiver being lost; once the receiver survives, the + // workaround's branch simply goes unreached for this shape. + // Confirmed byte-identical against Node for both the call form + // (`console[m](...)`) and the value-read form (`console[m]`, + // separately guarded by `receiver_is_detached_console_read` + // above) in `test_gap_10483_computed_key_namespace_member.ts`. + let outer_is_dynamic_computed_key = matches!( + &member.prop, + ast::MemberProp::Computed(c) + if !matches!(c.expr.as_ref(), ast::Expr::Lit(ast::Lit::Str(_))) + ); if !outer_is_prototype_or_proto && !outer_is_constructor_property && !receiver_is_namespace_value @@ -449,6 +490,7 @@ pub(crate) fn lower_member_tail( && !outer_is_inherited_object_proto_method && !outer_is_inherited_function_proto_method && !receiver_is_detached_console_read + && !outer_is_dynamic_computed_key { object_expr = Expr::GlobalGet(0); } diff --git a/test-files/test_gap_10483_computed_key_namespace_member.ts b/test-files/test_gap_10483_computed_key_namespace_member.ts new file mode 100644 index 0000000000..5ba85cee93 --- /dev/null +++ b/test-files/test_gap_10483_computed_key_namespace_member.ts @@ -0,0 +1,155 @@ +// #10483: `Math[k]` / `JSON[k]` / `Object[k]` / ... with a *variable* (non-literal) +// computed key must resolve against the real namespace/constructor object, not the +// number 0. #973 rerouted bare builtin idents used as VALUES to +// `PropertyGet{GlobalGet(0), name}`; member_tail.rs undoes that reroute in +// member-OBJECT position so the intrinsic call / constant-fold paths for a +// STATICALLY-KNOWN member name (`Math.max(...)`) keep their pre-#973 `GlobalGet(0)` +// receiver. That undo is wrong for a computed non-literal key — nothing can resolve +// to an intrinsic at lowering time, so the receiver collapsed to the bare `GlobalGet(0)` +// sentinel and the read landed on the number 0. Closed #6677 fixed only the +// string-literal direct-call form (`Math["max"](...)`); this covers the variable-key +// forms it left broken, in both call position and value-read position, with keys from +// a plain variable, a template literal, and a function return. Static (literal) key +// paths are re-asserted unchanged at the end — that's the entire point of the +// reroute-undo this fix narrows (test_gap_number_math regressed when #973 first +// landed). + +// --------------------------------------------------------------------------- +// call position, key from a plain variable +// --------------------------------------------------------------------------- +function callMath(key: string, ...args: number[]): number { + // @ts-ignore -- deliberately untyped computed access, the #10483 repro shape + return Math[key](...args); +} +console.log("call/variable:"); +console.log(callMath("max", 1, 5, 3)); +console.log(callMath("min", 1, 5, 3)); +console.log(callMath("round", 2.6)); + +function callJson(key: string, value: unknown): string { + // @ts-ignore + return JSON[key](value); +} +console.log(callJson("stringify", { a: 1, b: [1, 2, 3] })); + +function callReflect(key: string, target: object, prop: string): boolean { + // @ts-ignore + return Reflect[key](target, prop); +} +console.log(callReflect("has", { x: 1 }, "x")); +console.log(callReflect("has", { x: 1 }, "y")); + +function callNumber(key: string, value: number): boolean { + // @ts-ignore + return Number[key](value); +} +console.log(callNumber("isInteger", 5)); +console.log(callNumber("isInteger", 5.5)); + +function callArray(key: string, value: unknown): boolean { + // @ts-ignore + return Array[key](value); +} +console.log(callArray("isArray", [1, 2])); +console.log(callArray("isArray", {})); + +function callString(key: string, ...codes: number[]): string { + // @ts-ignore + return String[key](...codes); +} +console.log(callString("fromCharCode", 72, 105)); + +function callDate(key: string): boolean { + // @ts-ignore + return typeof Date[key]() === "number"; +} +console.log(callDate("now")); + +// --------------------------------------------------------------------------- +// value-read position (not called), key from a plain variable +// --------------------------------------------------------------------------- +console.log("value-read/variable:"); +function readTypeof(obj: unknown, key: string): string { + // @ts-ignore + return typeof obj[key]; +} +console.log(readTypeof(Math, "max")); +console.log(readTypeof(JSON, "stringify")); +console.log(readTypeof(Reflect, "ownKeys")); +console.log(readTypeof(Number, "isInteger")); +console.log(readTypeof(Date, "now")); +console.log(readTypeof(Array, "isArray")); +console.log(readTypeof(String, "fromCharCode")); + +function grabMax(key: string): (...xs: number[]) => number { + // @ts-ignore + return Math[key]; +} +const maxFn = grabMax("max"); +console.log(typeof maxFn, maxFn(7, 42, 3)); + +// --------------------------------------------------------------------------- +// key from a template literal +// --------------------------------------------------------------------------- +console.log("template-literal key:"); +function templateCall(part: string): number { + // @ts-ignore + return Math[`${part}`](4, 9); +} +console.log(templateCall("max")); +console.log(templateCall("min")); + +function templateRead(part: string): string { + // @ts-ignore + return typeof JSON[`${part}`]; +} +console.log(templateRead("parse")); + +// --------------------------------------------------------------------------- +// key from a function return +// --------------------------------------------------------------------------- +console.log("function-return key:"); +function pickMaxKey(): string { + return "max"; +} +// @ts-ignore +console.log(Math[pickMaxKey()](4, 9)); + +function pickIsIntegerKey(): string { + return "isInteger"; +} +// @ts-ignore +console.log(Number[pickIsIntegerKey()](10)); + +// --------------------------------------------------------------------------- +// static (literal) key controls — must stay byte-identical to before #10483 +// --------------------------------------------------------------------------- +console.log("static controls:"); +console.log(Math.max(1, 5, 3)); +console.log(Math["max"](1, 5, 3)); +console.log(Math.min(1, 5, 3)); +console.log(JSON.stringify({ z: 1 })); +console.log(JSON["stringify"]({ z: 1 })); +console.log(Number.parseInt("42", 10)); +console.log(Number.isInteger(5)); +console.log(Array.isArray([1])); +console.log(Reflect.has({ x: 1 }, "x")); + +// --------------------------------------------------------------------------- +// `console`'s pre-existing dynamic dispatch (#js_console_method_by_value, +// the Next.js `prefixedLog` fix) must keep working — #10483's new guard +// deliberately excludes "console" so the call-position undo it depends on +// still fires. +// --------------------------------------------------------------------------- +console.log("console dynamic dispatch:"); +function consoleCall(method: string, msg: string): void { + // @ts-ignore + console[method](msg); +} +consoleCall("log", "console[method](...) still dispatches"); + +function consoleValueRead(method: string): string { + // @ts-ignore + return typeof console[method]; +} +console.log(consoleValueRead("log")); From 977e07575e670434d380515394ea98dc9a8d0cf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 13:50:48 +0000 Subject: [PATCH 2/2] docs(changelog): add fragment for #10629 --- changelog.d/10629-computed-key-namespace-member.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 changelog.d/10629-computed-key-namespace-member.md diff --git a/changelog.d/10629-computed-key-namespace-member.md b/changelog.d/10629-computed-key-namespace-member.md new file mode 100644 index 0000000000..3e6c4ba3c4 --- /dev/null +++ b/changelog.d/10629-computed-key-namespace-member.md @@ -0,0 +1,12 @@ +Fixed `Math[k]`, `JSON[k]`, `Object[k]`, `Number[k]`, `Reflect[k]`, `Date[k]`, `String[k]`, and +other built-in namespace/constructor member reads with a *variable* (non-literal) computed key +reading a property of the number `0` instead of the real object — `Math[key](x)` threw +`(number).x is not a function`, and `typeof Math[key]` was `undefined` for every key. #973's +value-form reroute of bare built-in identifiers was correctly undone in member-object position for +a statically-known member name (`Math.max(...)`), so the intrinsic call/constant-fold paths could +keep their pre-#973 `GlobalGet(0)` receiver — but the undo also fired for a computed non-literal +key, where nothing can resolve to an intrinsic at lowering time, collapsing the receiver to the +bare `GlobalGet(0)` sentinel. Closed #6677 fixed only the string-literal direct-call form +(`Math["max"](...)`); this fixes the variable-key forms (call and value-read position, keys from a +variable, a template literal, or a function return) it left broken. Static (literal) key paths are +unaffected by construction. `#10483`