Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 25 additions & 7 deletions crates/perry-hir/src/lower/lower_expr/arm_ident.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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
Expand Down
7 changes: 6 additions & 1 deletion crates/perry-runtime/src/value/dynamic_arith.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<u8>() as usize)
Expand Down
134 changes: 134 additions & 0 deletions crates/perry/tests/issue_6652_global_proto_inherited_members.rs
Original file line number Diff line number Diff line change
@@ -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");
}
Original file line number Diff line number Diff line change
@@ -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);
}
Loading