From 27e73422709bd00a6ecd3947e3c23231ee6e23dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 19 Jul 2026 03:57:42 +0200 Subject: [PATCH 1/2] runtime: serve Object.prototype-inherited globalThis members on bare-identifier dispatch (#6652) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sloppy/global bare identifiers that resolve to Object.prototype-inherited members of the global object (hasOwnProperty, toString, valueOf, ...) threw 'TypeError: value is not a function' in member-object position: arm_ident's unknown-identifier lowering collapsed the ident to the bare GlobalGet(0) sentinel (which IS globalThis), discarding the identifier name, so hasOwnProperty.call(o, k) dispatched '.call' against globalThis. Node resolves the bare ident through the scope chain to the global object, which inherits Object.prototype.hasOwnProperty. Fix: route ALL unknown identifiers through the existing js_global_get_or_throw_unresolved by-name runtime lookup — member-object position included — which serves globalThis' own and inherited members with identity preserved and still throws the spec ReferenceError on a true miss. Known globals keep the GlobalGet(0) sentinel and their intrinsic member routing. The compile warning text now describes the new behavior. Also fixes runtime-created globals in member position (myGlobal.prop read globalThis.prop pre-fix) as the same lost-name bug. Trigger: @babel/types/lib/definitions/placeholders.js (hasOwnProperty.call(o, t4), 14 sites + 2 bare-toString sites in the pi bundle) during pi-native module init — pi bring-up wall #6. Fixes #6652 Claude-Session: https://claude.ai/code/session_01JuiiePQfrXhAFD9fuCygB9 --- .../src/lower/lower_expr/arm_ident.rs | 32 ++++- ...sue_6652_global_proto_inherited_members.rs | 134 ++++++++++++++++++ ...obal-object-prototype-inherited-members.ts | 81 +++++++++++ 3 files changed, 240 insertions(+), 7 deletions(-) create mode 100644 crates/perry/tests/issue_6652_global_proto_inherited_members.rs create mode 100644 test-parity/node-suite/globals/global-object-prototype-inherited-members.ts diff --git a/crates/perry-hir/src/lower/lower_expr/arm_ident.rs b/crates/perry-hir/src/lower/lower_expr/arm_ident.rs index c0e64ee078..03f1a47970 100644 --- a/crates/perry-hir/src/lower/lower_expr/arm_ident.rs +++ b/crates/perry-hir/src/lower/lower_expr/arm_ident.rs @@ -238,12 +238,36 @@ pub(crate) fn lower_ident_expr(ctx: &mut LoweringContext, ident: &ast::Ident) -> // parent PropertyGet/Call/Member context. Bare uses lower to // 0.0 (perry-codegen/src/expr.rs Expr::GlobalGet arm). let known_global = is_known_global_identifier_name(&name); - if !known_global && !ctx.unresolved_ident_as_global { + if !known_global { // A global created at RUNTIME (sloppy `this.y = 2` with // `this` = globalThis inside a dynamic function) is // invisible to compile-time resolution — look it up on // globalThis first; only a true miss throws the spec // ReferenceError, with the identifier in the message. + // + // #6652: this by-name runtime lookup applies in member-OBJECT + // position too (`ctx.unresolved_ident_as_global`). Pre-fix, + // member-object lowering collapsed the unknown ident to the bare + // `GlobalGet(0)` sentinel — which IS globalThis — so the ident + // name was discarded and the MEMBER dispatched against + // globalThis: `hasOwnProperty.call(o, k)` read `globalThis.call` + // (undefined → "TypeError: value is not a function", @babel/types + // placeholders.js in the pi bundle) and a runtime-created + // `myGlobal.prop` read `globalThis.prop`. The lookup resolves + // through `js_object_get_field_by_name` on globalThis, which + // serves Object.prototype-INHERITED members (`hasOwnProperty`, + // `toString`, `valueOf`, …) with identity preserved — exactly + // Node's global-scope resolution. Bare CALLS of such idents get + // `this = undefined` (spec: the global environment record's + // WithBaseObject is undefined; Node: `toString()` → + // "[object Undefined]" even in sloppy CJS), which the generic + // call path already provides. + if ctx.unresolved_ident_as_global { + eprintln!( + " Warning: unknown identifier '{}' — assuming global; resolved by name on globalThis (incl. Object.prototype-inherited members) at runtime", + name + ); + } return Ok(Expr::Call { callee: Box::new(Expr::ExternFuncRef { name: "js_global_get_or_throw_unresolved".to_string(), @@ -257,12 +281,6 @@ pub(crate) fn lower_ident_expr(ctx: &mut LoweringContext, ident: &ast::Ident) -> byte_offset: ident.span.lo.0, }); } - if !known_global { - eprintln!( - " Warning: unknown identifier '{}' — assuming global; member access will dispatch by name at runtime, bare reads lower to 0", - name - ); - } // Bare built-in constructor identifiers (`Date`, `Array`, // `Object`, ...) used as VALUES (not method receivers / // `new` callees) need a real closure pointer so identity diff --git a/crates/perry/tests/issue_6652_global_proto_inherited_members.rs b/crates/perry/tests/issue_6652_global_proto_inherited_members.rs new file mode 100644 index 0000000000..59d3e70d24 --- /dev/null +++ b/crates/perry/tests/issue_6652_global_proto_inherited_members.rs @@ -0,0 +1,134 @@ +//! Regression tests for #6652 (pi wall #6): bare identifiers that resolve to +//! Object.prototype-INHERITED members of the global object. +//! +//! Node resolves a bare `hasOwnProperty` through the scope chain to the +//! global object, which inherits `Object.prototype.hasOwnProperty` — so +//! `hasOwnProperty.call(o, k)` works. Perry's unknown-identifier-assume- +//! global lowering collapsed the ident in member-OBJECT position to the +//! bare `GlobalGet(0)` sentinel (which IS globalThis), discarding the +//! identifier name entirely: `hasOwnProperty.call(o, k)` read +//! `globalThis.call` (undefined) and threw "TypeError: value is not a +//! function". Trigger in the wild: @babel/types/lib/definitions/ +//! placeholders.js (`hasOwnProperty.call(o, t4) || (o[t4] = [])`, 14 sites +//! in the pi bundle) during pi-native module init. +//! +//! The fix routes ALL unknown identifiers — member-object position included — +//! through the `js_global_get_or_throw_unresolved` by-name runtime lookup, +//! which serves globalThis' own AND inherited members with identity +//! preserved, and still throws the spec ReferenceError on a true miss. +//! +//! Receiver semantics (verified against node v26, both module/strict and +//! sloppy CJS): a bare CALL gets `this = undefined` (the global environment +//! record's WithBaseObject is undefined) — `toString()` is +//! "[object Undefined]", `hasOwnProperty("x")` throws "Cannot convert +//! undefined or null to object". + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn compile_and_run(dir: &std::path::Path, source: &str) -> String { + let entry = dir.join("main.ts"); + let output = dir.join("main_bin"); + std::fs::write(&entry, source).expect("write entry"); + + let compile = Command::new(perry_bin()) + .current_dir(dir) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(&output) + .current_dir(dir) + .output() + .expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + String::from_utf8_lossy(&run.stdout).into_owned() +} + +/// The @babel/types placeholders.js shape plus every access form: member +/// call on the bare ident, extraction with identity, typeof, bare calls +/// with their `this = undefined` semantics, other Object.prototype members, +/// use from inside a function body, and the spec ReferenceError on a true +/// miss. Expected output is node v26's, byte for byte. +#[test] +fn object_prototype_inherited_globals_match_node() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +const o: any = { x: 1 }; +console.log("call:", hasOwnProperty.call(o, "x"), hasOwnProperty.call(o, "y")); +const h: any = hasOwnProperty; +console.log("identity:", h === Object.prototype.hasOwnProperty); +console.log("extracted:", h.call({ y: 2 }, "y"), h.call({ y: 2 }, "z")); +console.log("typeof:", typeof hasOwnProperty, typeof isPrototypeOf); +console.log("toString():", String(toString())); +try { + (hasOwnProperty as any)("x"); + console.log("bare-call: no throw"); +} catch (e: any) { + console.log("bare-call threw:", e.constructor.name + ": " + e.message); +} +console.log("isPrototypeOf:", isPrototypeOf === Object.prototype.isPrototypeOf); +function usesInherited(obj: any, key: string): boolean { + return hasOwnProperty.call(obj, key); +} +console.log("in-function:", usesInherited({ k: 0 }, "k"), usesInherited({}, "k")); +try { + // @ts-ignore -- deliberately unresolvable + issue6652NeverDefined.foo; + console.log("missing: no throw"); +} catch (e: any) { + console.log("missing threw:", e.constructor.name + ": " + e.message); +} +"#, + ); + assert_eq!( + stdout, + "call: true false\n\ + identity: true\n\ + extracted: true false\n\ + typeof: function function\n\ + toString(): [object Undefined]\n\ + bare-call threw: TypeError: Cannot convert undefined or null to object\n\ + isPrototypeOf: true\n\ + in-function: true false\n\ + missing threw: ReferenceError: issue6652NeverDefined is not defined\n" + ); +} + +/// The by-name runtime lookup must also serve runtime-CREATED globals in +/// member position — pre-fix `myGlobal.prop` read `globalThis.prop` +/// (undefined) and `myGlobal.method()` threw "value is not a function". +#[test] +fn runtime_created_global_member_access_matches_node() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +(globalThis as any).issue6652RuntimeGlobal = { prop: 42, greet: () => "hi" }; +// @ts-ignore -- deliberately unresolvable at compile time +console.log("member:", issue6652RuntimeGlobal.prop, issue6652RuntimeGlobal.greet()); +"#, + ); + assert_eq!(stdout, "member: 42 hi\n"); +} diff --git a/test-parity/node-suite/globals/global-object-prototype-inherited-members.ts b/test-parity/node-suite/globals/global-object-prototype-inherited-members.ts new file mode 100644 index 0000000000..aebb933398 --- /dev/null +++ b/test-parity/node-suite/globals/global-object-prototype-inherited-members.ts @@ -0,0 +1,81 @@ +// Regression (#6652, pi wall #6): bare identifiers that resolve to +// Object.prototype-INHERITED members of the global object must work exactly +// as in Node — the global scope chain ends at globalThis, whose prototype +// chain reaches Object.prototype, so `hasOwnProperty`, `toString`, +// `valueOf`, ... are all resolvable bare identifiers. +// +// Pre-fix, perry's unknown-identifier-assume-global lowering collapsed the +// ident in member-object position to the globalThis sentinel itself, so +// `hasOwnProperty.call(o, k)` dispatched `.call` against globalThis +// (undefined -> "TypeError: value is not a function"). Trigger in the wild: +// @babel/types/lib/definitions/placeholders.js +// (`hasOwnProperty.call(o, t4) || (o[t4] = [])`) during pi-bundle init. +// +// Receiver semantics (verified against node v26): a bare CALL of such an +// identifier gets `this = undefined` — the global environment record's +// WithBaseObject is undefined — in BOTH module (strict) and sloppy CJS +// scope. Builtins do not coerce: `toString()` is "[object Undefined]" and +// `hasOwnProperty("x")` throws "Cannot convert undefined or null to object". + +const o: any = { x: 1 }; + +// 1. member access on the bare inherited ident (the @babel/types shape) +console.log("call:", hasOwnProperty.call(o, "x"), hasOwnProperty.call(o, "y")); + +// 2. bare read: extraction preserves identity with Object.prototype +const h: any = hasOwnProperty; +console.log("read typeof:", typeof h); +console.log("identity:", h === Object.prototype.hasOwnProperty); +console.log("extracted:", h.call({ y: 2 }, "y"), h.call({ y: 2 }, "z")); + +// 3. typeof on the bare ident (no extraction) +console.log("typeof:", typeof hasOwnProperty, typeof isPrototypeOf); + +// 4. bare toString() called without receiver: this = undefined +console.log("toString():", String(toString())); + +// 5. bare call of hasOwnProperty: this = undefined -> ToObject throws +try { + (hasOwnProperty as any)("x"); + console.log("bare-call: no throw"); +} catch (e: any) { + console.log("bare-call threw:", e.constructor.name + ": " + e.message); +} + +// 6. valueOf() without receiver: same ToObject(undefined) throw +try { + (valueOf as any)(); + console.log("valueOf: no throw"); +} catch (e: any) { + console.log("valueOf threw:", e.constructor.name + ": " + e.message); +} + +// 7. other Object.prototype members reached the same way +console.log("isPrototypeOf:", isPrototypeOf === Object.prototype.isPrototypeOf); +console.log( + "propertyIsEnumerable:", + propertyIsEnumerable === Object.prototype.propertyIsEnumerable, +); + +// 8. from inside a function body (the pi bundle hits this in webpack +// factories, not at top level) +function usesInherited(obj: any, key: string): boolean { + return hasOwnProperty.call(obj, key); +} +console.log("in-function:", usesInherited({ k: 0 }, "k"), usesInherited({}, "k")); + +// 9. the same by-name runtime resolution must serve runtime-CREATED globals +// in member position (the ident is invisible to compile-time resolution) +(globalThis as any).issue6652RuntimeGlobal = { prop: 42, greet: () => "hi" }; +// @ts-ignore -- deliberately unresolvable at compile time +console.log("runtime-global:", issue6652RuntimeGlobal.prop, issue6652RuntimeGlobal.greet()); + +// 10. a genuinely missing ident in member position is still the spec +// ReferenceError, localized to the identifier +try { + // @ts-ignore -- deliberately unresolvable + issue6652NeverDefined.foo; + console.log("missing: no throw"); +} catch (e: any) { + console.log("missing threw:", e.constructor.name + ": " + e.message); +} From 56d291a81637134bf95f6587ee654ace891f0c47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 19 Jul 2026 04:44:33 +0200 Subject: [PATCH 2/2] fix(runtime): char-boundary-safe string preview in bigint-mix diagnostics (CodeRabbit on #6656) Claude-Session: https://claude.ai/code/session_01JuiiePQfrXhAFD9fuCygB9 --- crates/perry-runtime/src/value/dynamic_arith.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/value/dynamic_arith.rs b/crates/perry-runtime/src/value/dynamic_arith.rs index b837a5dee8..5e36535548 100644 --- a/crates/perry-runtime/src/value/dynamic_arith.rs +++ b/crates/perry-runtime/src/value/dynamic_arith.rs @@ -43,7 +43,12 @@ unsafe fn describe_mix_operand(v: f64) -> String { } else if jv.is_any_string() { let ptr = js_get_string_pointer_unified(v) as *const crate::string::StringHeader; let mut s = crate::exception::string_header_to_string(ptr); - s.truncate(80); + // Char-boundary-safe preview cap: byte-index truncate panics when the + // 80th byte lands inside a multi-byte UTF-8 sequence. + if s.len() > 80 { + let cut = (0..=80).rev().find(|i| s.is_char_boundary(*i)).unwrap_or(0); + s.truncate(cut); + } format!("string({s:?})") } else if jv.is_pointer() { format!("pointer(0x{:x})", jv.as_pointer::() as usize)