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-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) 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); +}