From 2603b62c75b7bd7a344e613ecfdc69a06573cf2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 9 Jun 2026 22:06:04 +0200 Subject: [PATCH 1/4] fix(runtime,hir): built-ins/Function test262 parity (v2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - const-prop for Function(...)/new Function(...): single-assignment module vars, toString-bearing object literals (incl counters + poison side effects), Object() wrappers, throw-propagation in ToString order, spec-assembled 'function anonymous(...)' source + name, strict-mode early errors, stray-private-name SyntaxError, Function.call/apply folding, AsyncFunction/GeneratorFunction via fn-literal .constructor - OrdinaryCallBindThis: strict-function registry (codegen-emitted); call/apply/bind box primitive thisArg once for sloppy user callees - reified call/apply/bind: spec .length, non-constructor - proxy-of-callable: typeof 'function', toString/ToString/ToPrimitive NativeFunction form (fixes '' + proxy segfault) - Function.prototype: callable (returns undefined), [object Function], new Function.prototype throws, expando proto-walk from closures - bind: brand-check TypeError, length via own-prop ToIntegerOrInfinity (NaN/Infinity/>int32), name/length attrs non-enumerable - class accessors reflect in getOwnPropertyDescriptor (proto + static); class-ref toString/String/concat → native form, not class id - fn.length()/fn.name() calls throw; caller/arguments writes poison all closures; fn.apply(this, arguments) raw-pointer argArray - module top-level 'this' = fresh CJS-style exports object (matches the node oracle); unresolved ident reads consult globalThis before ReferenceError --- crates/perry-codegen/src/codegen/artifacts.rs | 24 + .../perry-codegen/src/codegen/string_pool.rs | 11 + crates/perry-codegen/src/expr/env_clones.rs | 5 + crates/perry-codegen/src/expr/mod.rs | 1 + .../src/runtime_decls/strings.rs | 2 + .../src/runtime_decls/strings_part2.rs | 1 + crates/perry-hir/src/ir/expr.rs | 5 + crates/perry-hir/src/lower/const_fold_fn.rs | 374 +++++- crates/perry-hir/src/lower/context.rs | 1 + crates/perry-hir/src/lower/expr_call/mod.rs | 3 + crates/perry-hir/src/lower/fn_ctor_env.rs | 1134 +++++++++++++++++ crates/perry-hir/src/lower/lower_expr.rs | 29 +- crates/perry-hir/src/lower/lower_module_fn.rs | 5 + .../perry-hir/src/lower/lowering_context.rs | 5 + crates/perry-hir/src/lower/mod.rs | 1 + crates/perry-hir/src/stable_hash/expr.rs | 1 + crates/perry-hir/src/walker/expr_mut.rs | 1 + crates/perry-hir/src/walker/expr_ref.rs | 1 + .../perry-runtime/src/builtins/arithmetic.rs | 10 + crates/perry-runtime/src/builtins/mod.rs | 6 +- crates/perry-runtime/src/closure/dispatch.rs | 148 ++- .../src/closure/dynamic_props.rs | 84 ++ crates/perry-runtime/src/closure/mod.rs | 13 +- crates/perry-runtime/src/closure/registry.rs | 28 + crates/perry-runtime/src/error.rs | 25 + .../src/object/class_registry.rs | 107 ++ .../perry-runtime/src/object/descriptors.rs | 33 + .../src/object/field_set_by_name.rs | 36 +- .../perry-runtime/src/object/global_this.rs | 66 + .../src/object/has_own_helpers.rs | 14 +- crates/perry-runtime/src/object/mod.rs | 4 + .../src/object/native_call_method.rs | 64 +- crates/perry-runtime/src/proxy.rs | 24 + crates/perry-runtime/src/string/concat.rs | 6 +- .../perry-runtime/src/value/dynamic_arith.rs | 10 + crates/perry-runtime/src/value/to_string.rs | 33 +- 36 files changed, 2243 insertions(+), 72 deletions(-) create mode 100644 crates/perry-hir/src/lower/fn_ctor_env.rs diff --git a/crates/perry-codegen/src/codegen/artifacts.rs b/crates/perry-codegen/src/codegen/artifacts.rs index 2d7467d0d5..55863d1806 100644 --- a/crates/perry-codegen/src/codegen/artifacts.rs +++ b/crates/perry-codegen/src/codegen/artifacts.rs @@ -1386,6 +1386,29 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { } } } + // Strict-mode user functions (file-level `"use strict"` or body + // directive), for OrdinaryCallBindThis in `call`/`apply`/`bind`: a + // strict callee must observe the raw primitive `thisArg`, a sloppy one + // gets it boxed. Same two symbol forms as the generator registries. + let mut user_fn_wrapper_strict: std::collections::HashSet = hir + .functions + .iter() + .filter(|f| f.is_strict) + .filter_map(|f| { + func_names + .get(&f.id) + .map(|name| format!("__perry_wrap_{}", name)) + }) + .collect(); + for (func_id, expr) in closures { + if let perry_hir::Expr::Closure { is_strict, .. } = expr { + if *is_strict { + user_fn_wrapper_strict + .insert(format!("perry_closure_{}__{}", module_prefix, func_id)); + } + } + } + // #3664: async-generator wrapper symbols, identified by the func_ids the // generator transform recorded (it cleared `is_async` before we get here, // so the body shape alone can't tell async generators from sync ones). @@ -1541,6 +1564,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { &user_fn_wrapper_async, &user_fn_wrapper_generator, &user_fn_wrapper_async_generator, + &user_fn_wrapper_strict, &user_fn_display_names, &user_fn_source, ); diff --git a/crates/perry-codegen/src/codegen/string_pool.rs b/crates/perry-codegen/src/codegen/string_pool.rs index 7c6f2b87dd..3b110ec1bc 100644 --- a/crates/perry-codegen/src/codegen/string_pool.rs +++ b/crates/perry-codegen/src/codegen/string_pool.rs @@ -75,6 +75,10 @@ pub(super) fn emit_string_pool( // `%AsyncGeneratorFunction%`/`%AsyncGenerator%` intrinsic chain (and // `util.types.isAsyncFunction`) resolve correctly for them. user_fn_wrapper_async_generator: &std::collections::HashSet, + // Strict-mode user functions (wrapper or inline-closure symbols). + // Each entry produces one `js_register_closure_strict_function` call so + // call/apply/bind can apply spec OrdinaryCallBindThis (#4850). + user_fn_wrapper_strict: &std::collections::HashSet, // `(wrapper_symbol, display_name)` for every top-level user function // we want `console.log` / `util.inspect` to label with the original // JS name. Each entry produces one `js_register_function_name` call @@ -992,5 +996,12 @@ pub(super) fn emit_string_pool( ); } + let mut sorted_strict_wrappers: Vec = user_fn_wrapper_strict.iter().cloned().collect(); + sorted_strict_wrappers.sort(); + for wrap_sym in sorted_strict_wrappers { + let func_ref = format!("@{}", wrap_sym); + blk.call_void("js_register_closure_strict_function", &[(PTR, &func_ref)]); + } + blk.ret_void(); } diff --git a/crates/perry-codegen/src/expr/env_clones.rs b/crates/perry-codegen/src/expr/env_clones.rs index 59f7fd05f0..36eb1460ce 100644 --- a/crates/perry-codegen/src/expr/env_clones.rs +++ b/crates/perry-codegen/src/expr/env_clones.rs @@ -58,6 +58,11 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // this same singleton for `globalThis.process.env` etc. Ok(ctx.block().call(DOUBLE, "js_get_global_this", &[])) } + Expr::ModuleTopThis => { + // CJS-style module top-level `this`: a lazily-allocated plain + // object (the module's `exports` stand-in), NOT `globalThis`. + Ok(ctx.block().call(DOUBLE, "js_module_top_this", &[])) + } Expr::DateToISOString(d) => { let v = lower_expr(ctx, d)?; let blk = ctx.block(); diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 2da99115e9..c73deaba97 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -1864,6 +1864,7 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { | Expr::EnvGetDynamic(..) | Expr::ProcessEnv => array_methods::lower(ctx, expr), Expr::GlobalThisExpr + | Expr::ModuleTopThis | Expr::DateToISOString(..) | Expr::DateToLocaleString(..) | Expr::FetchGetWithAuth { .. } diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index f70601a6a3..0b11b95bc3 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -175,6 +175,7 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { module.declare_function("js_register_closure_arity", VOID, &[PTR, I32]); module.declare_function("js_register_closure_length", VOID, &[PTR, I32]); module.declare_function("js_register_closure_arrow_function", VOID, &[PTR]); + module.declare_function("js_register_closure_strict_function", VOID, &[PTR]); module.declare_function("js_register_closure_async_function", VOID, &[PTR]); module.declare_function("js_register_closure_generator_function", VOID, &[PTR]); module.declare_function("js_register_closure_async_generator_function", VOID, &[PTR]); @@ -1198,6 +1199,7 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { module.declare_function("js_with_implicit_read", DOUBLE, &[DOUBLE, DOUBLE]); // Iterator-protocol result validation (for-of lazy loop). module.declare_function("js_iterator_result_validate", DOUBLE, &[DOUBLE]); + module.declare_function("js_global_get_or_throw_unresolved", DOUBLE, &[DOUBLE]); module.declare_function("js_throw_reference_error_this_before_super", DOUBLE, &[]); module.declare_function("js_throw_reference_error_super_delete", DOUBLE, &[]); module.declare_function( diff --git a/crates/perry-codegen/src/runtime_decls/strings_part2.rs b/crates/perry-codegen/src/runtime_decls/strings_part2.rs index 7f18f8fa4f..7aa296d03e 100644 --- a/crates/perry-codegen/src/runtime_decls/strings_part2.rs +++ b/crates/perry-codegen/src/runtime_decls/strings_part2.rs @@ -195,6 +195,7 @@ pub(crate) fn declare_phase_b_strings_part2(module: &mut LlModule) { // The codegen IndexGet/IndexSet paths on `Expr::GlobalGet` route // through this helper. module.declare_function("js_get_global_this", DOUBLE, &[]); + module.declare_function("js_module_top_this", DOUBLE, &[]); module.declare_function("js_global_or_console_property_by_name", DOUBLE, &[I64]); // Refs #420: register a static computed-key Symbol field on a class. // Called from `init_static_fields` for each `static [Symbol.X] = init`. diff --git a/crates/perry-hir/src/ir/expr.rs b/crates/perry-hir/src/ir/expr.rs index e691e582a5..ae8da6fe4f 100644 --- a/crates/perry-hir/src/ir/expr.rs +++ b/crates/perry-hir/src/ir/expr.rs @@ -582,6 +582,11 @@ pub enum Expr { // value is not a function` at module init and the import // resolves to undefined. Followup to #957 / PR #959. GlobalThisExpr, + /// `this` in module top-level code. Node runs the assembled test files + /// as CJS, where top-level `this` is `module.exports` — a fresh plain + /// object distinct from `globalThis`. Lowered separately from + /// `Expr::This` so function-body `this` semantics are untouched. + ModuleTopThis, // Process uptime: process.uptime() -> number (seconds) ProcessUptime, // Process current working directory: process.cwd() -> string diff --git a/crates/perry-hir/src/lower/const_fold_fn.rs b/crates/perry-hir/src/lower/const_fold_fn.rs index 406dc1f13c..2eb8af1b21 100644 --- a/crates/perry-hir/src/lower/const_fold_fn.rs +++ b/crates/perry-hir/src/lower/const_fold_fn.rs @@ -49,8 +49,24 @@ fn synth_function_syntax_error( surface.label() ); } - let src = "(function () { throw new SyntaxError(\"Function constructor: invalid function body\"); })();\n"; - let module = perry_parser::parse_typescript(src, ".cjs") + synth_throwing_iife( + ctx, + "throw new SyntaxError(\"Function constructor: invalid function body\");", + span, + ) +} + +/// Lower an IIFE-in-value-position that executes `throw_stmt` — used both +/// for the runtime-`SyntaxError` path above and for a constant-`toString` +/// argument that throws (`new Function({toString(){throw 1}})` must throw +/// `1` when the call site is evaluated, before any parsing). +fn synth_throwing_iife( + ctx: &mut LoweringContext, + throw_stmt: &str, + span: swc_common::Span, +) -> Result { + let src = format!("(function () {{ {throw_stmt} }})();\n"); + let module = perry_parser::parse_typescript(&src, ".cjs") .map_err(|e| anyhow::Error::new(LowerError::new(format!("internal: {e}"), span)))?; let ast::ModuleItem::Stmt(ast::Stmt::Expr(expr_stmt)) = module.body.first().ok_or_else(|| { @@ -75,7 +91,7 @@ fn synth_function_syntax_error( /// invalid identifier anyway, so the synth fails to parse and both runtimes /// reject); in a *body* slot the literal statement is never executed by these /// tests. -fn js_number_to_string(n: f64) -> String { +pub(crate) fn js_number_to_string(n: f64) -> String { if n.is_nan() { return "NaN".to_string(); } @@ -134,37 +150,87 @@ pub(crate) fn try_const_fold_function_construct( args: &[ast::ExprOrSpread], surface: EvalSurface, span: swc_common::Span, +) -> Result> { + try_const_fold_function_construct_kind( + ctx, + args, + surface, + span, + super::fn_ctor_env::DynFnCtorKind::Plain, + ) +} + +/// Kind-aware core of the fold: `AsyncFunction(...)` assembles +/// `async function anonymous(...)`, `GeneratorFunction(...)` a +/// `function* anonymous(...)`, etc. +pub(crate) fn try_const_fold_function_construct_kind( + ctx: &mut LoweringContext, + args: &[ast::ExprOrSpread], + surface: EvalSurface, + span: swc_common::Span, + kind: super::fn_ctor_env::DynFnCtorKind, ) -> Result> { // A spread argument can't be expanded into a static param/body list. if args.iter().any(|a| a.spread.is_some()) { return Ok(None); } - // Every argument must coerce to a compile-time-constant string the way - // Node's `ToString` would (params *and* body). This covers string and - // template literals plus the primitive literals Test262 passes — - // `null` / `undefined` / `void 0` / numbers / booleans — so - // `new Function("a,b,c", null)` folds (body `null` → `"null"`) instead - // of being refused. Objects / identifiers / calls stay non-constant and - // bail to the runtime path (keeping throwing-`toString` cases there). + // Every argument must resolve to a compile-time-constant string the way + // Node's `ToString` would (params *and* body): literals, substitution-free + // templates, `null`/`undefined`/`void 0`/numbers/booleans, plus — + // via the pre-scanned `fn_ctor_env` — single-assignment module variables, + // object literals with a constant `toString`, and `Object()` + // wrappers. ToString runs left-to-right; an argument whose `toString` + // throws a constant aborts the sequence and the call site lowers to an + // IIFE that throws that value (`new Function({toString(){throw 1}})` + // throws `1`). Anything outside the subset bails to the runtime path. let mut consts: Vec = Vec::with_capacity(args.len()); + let mut thrown: Option = None; + ctx.fn_ctor_env.pending_side_effects.clear(); for a in args { - match coerce_arg_to_string(&a.expr) { - Some(s) => consts.push(s), + match resolve_fn_ctor_arg(ctx, &a.expr) { + Some(super::fn_ctor_env::ResolvedArg::Str(s)) => consts.push(s), + Some(super::fn_ctor_env::ResolvedArg::Thrown(v)) => { + thrown = Some(v); + break; + } None => return Ok(None), } } + if let Some(v) = thrown { + if eval_diag_enabled() { + eprintln!( + "[perry-eval-diag] {} -> constant toString throws at runtime (#1679)", + surface.label() + ); + } + // Replay the toString side effects (`p = 1`) before throwing — the + // synthesized IIFE lowers in the enclosing scope, so the assignments + // resolve to the same module-level bindings. + let effects = ctx.fn_ctor_env.pending_side_effects.join(" "); + let stmt = format!("{effects} throw {};", v.to_js_literal()); + return synth_throwing_iife(ctx, stmt.trim_start(), span).map(Some); + } // Node treats the last argument as the body and every earlier argument // as a (possibly comma-joined) parameter list: `new Function('a','b', // 'return a+b')` ≡ `new Function('a, b', 'return a+b')`. Joining the // param args with `,` reproduces either spelling. let (body_src, params_src) = match consts.split_last() { - Some((body, params)) => (body.clone(), params.join(", ")), + Some((body, params)) => (body.clone(), params.join(",")), // `new Function()` / `Function()` — empty params, empty body. None => (String::new(), String::new()), }; - let synth = format!("(function ({params_src}) {{\n{body_src}\n}});\n"); + // Assemble the exact source text the spec's CreateDynamicFunction + // prescribes: newlines around the body and *before the closing paren* + // so a `//` comment in the params or body can't swallow a delimiter. + // This text is also what `fn.toString()` must return, and the function's + // name is `anonymous`. + let assembled = format!( + "{} anonymous({params_src}\n) {{\n{body_src}\n}}", + kind.prefix() + ); + let synth = format!("({assembled});\n"); // A `new Function(...)` / `Function(...)` whose params+body don't form a // syntactically valid function is a *runtime* `SyntaxError` in JS — the // parse happens inside the constructor call, not at our compile time. @@ -183,6 +249,14 @@ pub(crate) fn try_const_fold_function_construct( return synth_function_syntax_error(ctx, surface, span).map(Some); }; + // Early errors SWC's parser doesn't surface: a `"use strict"` directive + // prologue makes duplicate or `eval`/`arguments` parameter names a + // SyntaxError, and a private name (`o.#f`) outside any class body is a + // SyntaxError regardless of mode (AllPrivateIdentifiersValid). + if fn_ctor_strict_param_early_error(fn_expr) || fn_body_has_stray_private_name(fn_expr) { + return synth_function_syntax_error(ctx, surface, span).map(Some); + } + let outer_strict = ctx.current_strict; ctx.current_strict = false; let lowered_result = lower_fn_expr(ctx, fn_expr); @@ -212,6 +286,12 @@ pub(crate) fn try_const_fold_function_construct( } }; + // `fn.toString()` must return the spec-assembled source, not a slice of + // the enclosing module at the synthetic span (which would be garbage). + if let Expr::Closure { func_id, .. } = &lowered { + ctx.closure_source_text.insert(*func_id, assembled); + } + if eval_diag_enabled() { eprintln!( "[perry-eval-diag] {} -> const-foldable: compiled to native function (#1679)", @@ -221,6 +301,191 @@ pub(crate) fn try_const_fold_function_construct( Ok(Some(lowered)) } +/// A `"use strict"` directive prologue in a dynamic function's body makes +/// duplicate parameter names and parameters named `eval` / `arguments` +/// SyntaxErrors — early errors SWC's parser accepts in sloppy mode. +fn fn_ctor_strict_param_early_error(fn_expr: &ast::FnExpr) -> bool { + let Some(body) = &fn_expr.function.body else { + return false; + }; + let mut strict = false; + for stmt in &body.stmts { + let Some(directive) = super::string_directive_stmt_lit(stmt) else { + break; + }; + if super::is_raw_use_strict_directive(directive) { + strict = true; + break; + } + } + if !strict { + return false; + } + let mut seen = std::collections::HashSet::new(); + for p in &fn_expr.function.params { + if let ast::Pat::Ident(b) = &p.pat { + let name = b.id.sym.to_string(); + if name == "eval" || name == "arguments" || !seen.insert(name) { + return true; + } + } + } + false +} + +/// AllPrivateIdentifiersValid: a private name (`o.#f`, `#f in o`) outside +/// any class body is a SyntaxError. SWC parses it without complaint, so walk +/// the synthesized body — skipping class expressions/declarations, where +/// private names are legal — and report any stray use. +fn fn_body_has_stray_private_name(fn_expr: &ast::FnExpr) -> bool { + fn expr_has(e: &ast::Expr) -> bool { + match e { + ast::Expr::Member(m) => { + matches!(m.prop, ast::MemberProp::PrivateName(_)) || expr_has(&m.obj) + } + ast::Expr::Bin(b) => { + matches!(b.left.as_ref(), ast::Expr::PrivateName(_)) + || expr_has(&b.left) + || expr_has(&b.right) + } + ast::Expr::PrivateName(_) => true, + ast::Expr::Paren(p) => expr_has(&p.expr), + ast::Expr::Unary(u) => expr_has(&u.arg), + ast::Expr::Update(u) => expr_has(&u.arg), + ast::Expr::Assign(a) => expr_has(&a.right), + ast::Expr::Cond(c) => expr_has(&c.test) || expr_has(&c.cons) || expr_has(&c.alt), + ast::Expr::Seq(s) => s.exprs.iter().any(|e| expr_has(e)), + ast::Expr::Call(c) => { + let callee = match &c.callee { + ast::Callee::Expr(e) => expr_has(e), + _ => false, + }; + callee || c.args.iter().any(|a| expr_has(&a.expr)) + } + ast::Expr::New(n) => { + expr_has(&n.callee) + || n.args + .as_ref() + .map(|args| args.iter().any(|a| expr_has(&a.expr))) + .unwrap_or(false) + } + ast::Expr::Array(arr) => arr.elems.iter().flatten().any(|elem| expr_has(&elem.expr)), + ast::Expr::Object(obj) => obj.props.iter().any(|p| match p { + ast::PropOrSpread::Spread(s) => expr_has(&s.expr), + ast::PropOrSpread::Prop(p) => match p.as_ref() { + ast::Prop::KeyValue(kv) => expr_has(&kv.value), + _ => false, + }, + }), + ast::Expr::Fn(f) => f + .function + .body + .as_ref() + .map(|b| b.stmts.iter().any(stmt_has)) + .unwrap_or(false), + ast::Expr::Arrow(a) => match a.body.as_ref() { + ast::BlockStmtOrExpr::BlockStmt(b) => b.stmts.iter().any(stmt_has), + ast::BlockStmtOrExpr::Expr(e) => expr_has(e), + }, + // Private names are legal inside class bodies. + ast::Expr::Class(_) => false, + _ => false, + } + } + fn stmt_has(s: &ast::Stmt) -> bool { + match s { + ast::Stmt::Expr(e) => expr_has(&e.expr), + ast::Stmt::Return(r) => r.arg.as_deref().map(expr_has).unwrap_or(false), + ast::Stmt::Throw(t) => expr_has(&t.arg), + ast::Stmt::If(i) => { + expr_has(&i.test) + || stmt_has(&i.cons) + || i.alt.as_deref().map(stmt_has).unwrap_or(false) + } + ast::Stmt::Block(b) => b.stmts.iter().any(stmt_has), + ast::Stmt::Decl(ast::Decl::Var(v)) => v + .decls + .iter() + .any(|d| d.init.as_deref().map(expr_has).unwrap_or(false)), + ast::Stmt::Decl(ast::Decl::Fn(f)) => f + .function + .body + .as_ref() + .map(|b| b.stmts.iter().any(stmt_has)) + .unwrap_or(false), + ast::Stmt::While(w) => expr_has(&w.test) || stmt_has(&w.body), + ast::Stmt::Try(t) => { + t.block.stmts.iter().any(stmt_has) + || t.handler + .as_ref() + .map(|h| h.body.stmts.iter().any(stmt_has)) + .unwrap_or(false) + || t.finalizer + .as_ref() + .map(|f| f.stmts.iter().any(stmt_has)) + .unwrap_or(false) + } + _ => false, + } + } + fn_expr + .function + .body + .as_ref() + .map(|b| b.stmts.iter().any(stmt_has)) + .unwrap_or(false) +} + +/// Resolve one `Function(...)` argument to the string `ToString` would +/// produce (or the constant it would throw). Extends [`coerce_arg_to_string`] +/// with inline object literals and — at module top level, where shadowing +/// can't bite — identifiers resolved through the pre-scanned +/// [`super::fn_ctor_env::FnCtorEnv`]. +fn resolve_fn_ctor_arg( + ctx: &mut LoweringContext, + expr: &ast::Expr, +) -> Option { + use super::fn_ctor_env::{eval_tostring, object_tostring_body, FnCtorShape, ResolvedArg}; + if let Some(s) = coerce_arg_to_string(expr) { + return Some(ResolvedArg::Str(s)); + } + let mut e = expr; + loop { + match e { + ast::Expr::Paren(p) => e = p.expr.as_ref(), + ast::Expr::TsAs(t) => e = t.expr.as_ref(), + ast::Expr::TsTypeAssertion(t) => e = t.expr.as_ref(), + _ => break, + } + } + if let ast::Expr::Object(obj) = e { + if obj.props.is_empty() { + return Some(ResolvedArg::Str("[object Object]".to_string())); + } + if let Some(body) = object_tostring_body(e) { + return eval_tostring(&mut ctx.fn_ctor_env, &body); + } + return None; + } + if let Some(s) = super::fn_ctor_env::wrapper_const_string(e) { + return Some(ResolvedArg::Str(s)); + } + if ctx.scope_depth == 0 { + if let ast::Expr::Ident(id) = e { + let shape = ctx.fn_ctor_env.entries.get(id.sym.as_str()).cloned()?; + return match shape { + FnCtorShape::Str(s) => Some(ResolvedArg::Str(s)), + FnCtorShape::UndefinedVar => Some(ResolvedArg::Str("undefined".to_string())), + FnCtorShape::ObjToString(body) => eval_tostring(&mut ctx.fn_ctor_env, &body), + // A dynamic-function ctor VALUE used as a ToString-able arg + // isn't a constant string. + FnCtorShape::DynCtor(_) | FnCtorShape::FnLiteral(_) => None, + }; + } + } + None +} + /// Fold the indirect-eval `globalThis` idiom — `(0, eval)('this')` / /// `(0, eval)('globalThis')` (and parenthesized variants) — to /// [`Expr::GlobalThisExpr`], the same singleton `Function('return this')()` @@ -513,9 +778,90 @@ pub(crate) fn try_eval_function_call_fold( { return try_const_fold_eval(ctx, &call.args, call.span); } + // `var AsyncFunction = (async function(){}).constructor; AsyncFunction(...)` + // — a single-assignment module var recorded as a dynamic-function ctor. + if ctx.scope_depth == 0 { + if let Some(super::fn_ctor_env::FnCtorShape::DynCtor(kind)) = + ctx.fn_ctor_env.entries.get(id.sym.as_str()).cloned() + { + return try_const_fold_function_construct_kind( + ctx, + &call.args, + EvalSurface::FunctionCall, + call.span, + kind, + ); + } + } Ok(None) } +/// Fold `Function.call(thisArg, ...ctorArgs)` / `Function.apply(thisArg, +/// [ctorArgs])` — CreateDynamicFunction ignores its `this`, so these are the +/// plain constructor call with the leading argument dropped (Test262 +/// S15.3_A2_T*: `Function.call(this, "var x / = 1;")` must throw a +/// SyntaxError at runtime). +pub(crate) fn try_eval_function_member_call_fold( + ctx: &mut LoweringContext, + call: &ast::CallExpr, +) -> Result> { + let ast::Callee::Expr(callee) = &call.callee else { + return Ok(None); + }; + let mut c = callee.as_ref(); + while let ast::Expr::Paren(p) = c { + c = p.expr.as_ref(); + } + let ast::Expr::Member(m) = c else { + return Ok(None); + }; + let ast::Expr::Ident(obj) = m.obj.as_ref() else { + return Ok(None); + }; + let ast::MemberProp::Ident(prop) = &m.prop else { + return Ok(None); + }; + if obj.sym.as_ref() != "Function" + || ctx.lookup_local("Function").is_some() + || ctx.lookup_func("Function").is_some() + || ctx.lookup_imported_func("Function").is_some() + { + return Ok(None); + } + match prop.sym.as_ref() { + "call" if !call.args.is_empty() && call.args.iter().all(|a| a.spread.is_none()) => { + try_const_fold_function_construct( + ctx, + &call.args[1..], + EvalSurface::FunctionCall, + call.span, + ) + } + "apply" if call.args.len() == 2 && call.args.iter().all(|a| a.spread.is_none()) => { + let mut arg1 = call.args[1].expr.as_ref(); + while let ast::Expr::Paren(p) = arg1 { + arg1 = p.expr.as_ref(); + } + let ast::Expr::Array(arr) = arg1 else { + return Ok(None); + }; + if arr.elems.iter().any(|e| e.is_none()) + || arr.elems.iter().flatten().any(|e| e.spread.is_some()) + { + return Ok(None); + } + let synth_args: Vec = arr.elems.iter().flatten().cloned().collect(); + try_const_fold_function_construct( + ctx, + &synth_args, + EvalSurface::FunctionCall, + call.span, + ) + } + _ => Ok(None), + } +} + /// Pull the `FnExpr` out of a synthesized `(function (...) { ... });` /// module (a single expression statement wrapping a parenthesized /// function expression). diff --git a/crates/perry-hir/src/lower/context.rs b/crates/perry-hir/src/lower/context.rs index 91eb449bb4..bdc3485118 100644 --- a/crates/perry-hir/src/lower/context.rs +++ b/crates/perry-hir/src/lower/context.rs @@ -149,6 +149,7 @@ impl LoweringContext { strict_mode_stack: Vec::new(), is_external_module: false, optional_require_try_depth: 0, + fn_ctor_env: super::fn_ctor_env::FnCtorEnv::default(), } } diff --git a/crates/perry-hir/src/lower/expr_call/mod.rs b/crates/perry-hir/src/lower/expr_call/mod.rs index 28eae831da..a6677af6d9 100644 --- a/crates/perry-hir/src/lower/expr_call/mod.rs +++ b/crates/perry-hir/src/lower/expr_call/mod.rs @@ -220,6 +220,9 @@ fn lower_call_inner(ctx: &mut LoweringContext, call: &ast::CallExpr) -> Result)` wrappers). + Str(String), + /// `var x;` with no initializer and no assignment anywhere — reading it + /// yields `undefined` (`ToString` → `"undefined"`). + UndefinedVar, + /// Object literal whose only property is a literal `toString` method. + /// The body (one `return ` or `throw ` statement) is kept + /// for the partial evaluator. + ObjToString(ToStringBody), + /// `var AsyncFunction = (async function () {}).constructor;` — a dynamic + /// function constructor obtained off a function literal. Calling it with + /// constant args folds like `Function(...)` with the matching prefix. + DynCtor(DynFnCtorKind), + /// `var f = async function () {};` — a function-literal var, recorded so + /// a later `f.constructor` resolves to the right dynamic ctor kind. + FnLiteral(DynFnCtorKind), +} + +/// Which dynamic-function intrinsic a `.constructor` read names. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DynFnCtorKind { + Plain, + Async, + Generator, + AsyncGenerator, +} + +impl DynFnCtorKind { + /// The `function` keyword form for the spec-assembled source. + pub(crate) fn prefix(self) -> &'static str { + match self { + DynFnCtorKind::Plain => "function", + DynFnCtorKind::Async => "async function", + DynFnCtorKind::Generator => "function*", + DynFnCtorKind::AsyncGenerator => "async function*", + } + } +} + +/// Classify a function LITERAL by its dynamic-ctor kind. +pub(crate) fn fn_literal_kind_of(expr: &ast::Expr) -> Option { + let mut e = expr; + while let ast::Expr::Paren(p) = e { + e = p.expr.as_ref(); + } + match e { + ast::Expr::Fn(f) => Some(match (f.function.is_async, f.function.is_generator) { + (false, false) => DynFnCtorKind::Plain, + (true, false) => DynFnCtorKind::Async, + (false, true) => DynFnCtorKind::Generator, + (true, true) => DynFnCtorKind::AsyncGenerator, + }), + ast::Expr::Arrow(a) => Some(if a.is_async { + DynFnCtorKind::Async + } else { + DynFnCtorKind::Plain + }), + _ => None, + } +} + +/// Recognize `.constructor` (or ` +/// .constructor`, resolved through `known_literals`) and classify which +/// dynamic function constructor it denotes. +pub(crate) fn dyn_fn_ctor_kind_of( + expr: &ast::Expr, + known_literals: &HashMap, +) -> Option { + let mut e = expr; + while let ast::Expr::Paren(p) = e { + e = p.expr.as_ref(); + } + let ast::Expr::Member(m) = e else { + return None; + }; + if !matches!(&m.prop, ast::MemberProp::Ident(id) if id.sym.as_ref() == "constructor") { + return None; + } + let mut obj = m.obj.as_ref(); + while let ast::Expr::Paren(p) = obj { + obj = p.expr.as_ref(); + } + if let Some(kind) = fn_literal_kind_of(obj) { + return Some(kind); + } + if let ast::Expr::Ident(id) = obj { + return known_literals.get(id.sym.as_str()).copied(); + } + None +} + +/// The retained `return`/`throw` statement of a recorded `toString` method. +#[derive(Debug, Clone)] +pub(crate) struct ToStringBody { + pub(crate) is_throw: bool, + pub(crate) expr: ast::Expr, + /// Names assigned by leading side-effect statements in the body + /// (`toString: function() { p = 1; return "a"; }`). Evaluating the body + /// poisons these in the env — a later read of a reassigned name must + /// not see the stale recorded shape. + pub(crate) poisoned: Vec, + /// The same leading assignments rendered as JS statements (`"p = 1;"`) + /// so the synthesized lowering can replay them at runtime — they are + /// real observable side effects (Test262 asserts `p === 1` afterwards). + pub(crate) assigns: Vec, +} + +/// Pre-scanned constant environment for `Function(...)` argument resolution. +#[derive(Debug, Default)] +pub(crate) struct FnCtorEnv { + pub(crate) entries: HashMap, + /// Counter variables: name → current compile-time value. Mutated by the + /// evaluator as it models successive `toString` calls. + pub(crate) counters: HashMap, + /// Side-effect assignment statements (rendered JS) performed by the + /// `toString` bodies evaluated so far for the CURRENT call site, in + /// execution order. The fold prepends these to a synthesized throw so + /// the effects stay observable at runtime. + pub(crate) pending_side_effects: Vec, +} + +/// A resolved `Function(...)` argument: either the string `ToString` would +/// produce, or the constant value its `toString` would throw. +pub(crate) enum ResolvedArg { + Str(String), + Thrown(ConstVal), +} + +/// Values the partial evaluator can produce. +#[derive(Debug, Clone)] +pub(crate) enum ConstVal { + Str(String), + Num(f64), + Bool(bool), + Null, + Undefined, +} + +impl ConstVal { + fn to_js_string(&self) -> String { + match self { + ConstVal::Str(s) => s.clone(), + ConstVal::Num(n) => super::const_fold_fn::js_number_to_string(*n), + ConstVal::Bool(b) => if *b { "true" } else { "false" }.to_string(), + ConstVal::Null => "null".to_string(), + ConstVal::Undefined => "undefined".to_string(), + } + } + + /// Render as a JavaScript expression that evaluates to this value, for + /// splicing into a synthesized `throw ;` statement. + pub(crate) fn to_js_literal(&self) -> String { + match self { + ConstVal::Str(s) => { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\u{2028}' => out.push_str("\\u2028"), + '\u{2029}' => out.push_str("\\u2029"), + _ => out.push(c), + } + } + out.push('"'); + out + } + ConstVal::Num(n) if n.is_nan() => "NaN".to_string(), + ConstVal::Num(n) => super::const_fold_fn::js_number_to_string(*n), + ConstVal::Bool(b) => if *b { "true" } else { "false" }.to_string(), + ConstVal::Null => "null".to_string(), + ConstVal::Undefined => "undefined".to_string(), + } + } +} + +/// Build the constant environment for a module. Called once from +/// `lower_module_full` before statement lowering. +pub(crate) fn build_fn_ctor_env(module: &ast::Module) -> FnCtorEnv { + let mut decls: HashMap)> = HashMap::new(); + let mut writes: HashMap = HashMap::new(); + + let empty_shadow = Shadow::new(); + for item in &module.body { + if let ast::ModuleItem::Stmt(stmt) = item { + scan_stmt(stmt, &mut decls, &mut writes, &empty_shadow); + } + } + + let mut env = FnCtorEnv::default(); + + // First gather every recorded-shape `toString` body so writes that occur + // INSIDE those bodies (counter updates `++i`, leading poison assignments + // `p = 1`) can be netted out of the disqualifying write counts. + let mut tostring_update_counts: HashMap = HashMap::new(); + let mut tostring_poison_counts: HashMap = HashMap::new(); + let mut tostring_candidates: Vec<(String, ToStringBody)> = Vec::new(); + let mut numeric_candidates: Vec<(String, f64)> = Vec::new(); + + for (name, (decl_count, init)) in &decls { + if *decl_count != 1 { + continue; + } + if let Some(expr) = init { + if let Some(body) = object_tostring_body(expr) { + count_counter_updates(&body.expr, &mut tostring_update_counts); + for p in &body.poisoned { + *tostring_poison_counts.entry(p.clone()).or_insert(0) += 1; + } + tostring_candidates.push((name.clone(), body)); + } else if let Some(n) = numeric_literal_of(expr) { + numeric_candidates.push((name.clone(), n)); + } + } + } + + let accounted = |name: &str, + update_counts: &HashMap, + poison_counts: &HashMap| { + update_counts.get(name).copied().unwrap_or(0) + + poison_counts.get(name).copied().unwrap_or(0) + }; + + // Function-literal vars first, so `var f = async function () {}; + // var AF = f.constructor;` resolves in declaration order regardless of + // HashMap iteration. + let mut fn_literal_vars: HashMap = HashMap::new(); + for (name, (decl_count, init)) in &decls { + if *decl_count != 1 || writes.get(name).copied().unwrap_or(0) != 0 { + continue; + } + if let Some(expr) = init { + if let Some(kind) = fn_literal_kind_of(expr) { + fn_literal_vars.insert(name.clone(), kind); + } + } + } + + for (name, (decl_count, init)) in &decls { + if *decl_count != 1 { + continue; + } + let write_count = writes.get(name).copied().unwrap_or(0); + match init { + None if write_count == 0 => { + env.entries.insert(name.clone(), FnCtorShape::UndefinedVar); + } + Some(expr) if write_count == 0 => { + if let Some(s) = wrapper_const_string(expr) { + env.entries.insert(name.clone(), FnCtorShape::Str(s)); + } else if let Some(kind) = dyn_fn_ctor_kind_of(expr, &fn_literal_vars) { + env.entries.insert(name.clone(), FnCtorShape::DynCtor(kind)); + } else if let Some(kind) = fn_literal_kind_of(expr) { + env.entries + .insert(name.clone(), FnCtorShape::FnLiteral(kind)); + } + } + _ => {} + } + } + + // An object-with-toString var qualifies when every write to it is one of + // the poison assignments inside a recorded body (evaluation poisons it + // at the right moment, so order stays faithful). + for (name, body) in tostring_candidates { + let write_count = writes.get(&name).copied().unwrap_or(0); + if write_count == tostring_poison_counts.get(&name).copied().unwrap_or(0) { + env.entries.insert(name, FnCtorShape::ObjToString(body)); + } + } + + for (name, n) in numeric_candidates { + let total_writes = writes.get(&name).copied().unwrap_or(0); + if total_writes == 0 { + env.entries.insert( + name, + FnCtorShape::Str(super::const_fold_fn::js_number_to_string(n)), + ); + } else if total_writes == accounted(&name, &tostring_update_counts, &tostring_poison_counts) + { + env.counters.insert(name, n); + } + } + + env +} + +fn numeric_literal_of(expr: &ast::Expr) -> Option { + let mut e = expr; + while let ast::Expr::Paren(p) = e { + e = p.expr.as_ref(); + } + match e { + ast::Expr::Lit(ast::Lit::Num(n)) => Some(n.value), + _ => None, + } +} + +/// `ToString` of an initializer that is itself a compile-time constant: +/// literals, substitution-free templates, and the `Object()` / +/// `new Object()` wrappers Test262 uses (whose `toString` is the +/// wrapped primitive's). +pub(crate) fn wrapper_const_string(expr: &ast::Expr) -> Option { + let mut e = expr; + loop { + match e { + ast::Expr::Paren(p) => e = p.expr.as_ref(), + ast::Expr::TsAs(t) => e = t.expr.as_ref(), + ast::Expr::TsTypeAssertion(t) => e = t.expr.as_ref(), + _ => break, + } + } + if let Some(v) = literal_const_val(e) { + return Some(v.to_js_string()); + } + // Object("...") / new Object(1) — a primitive wrapper whose ToString is + // the wrapped primitive's string. With no (or undefined/null) argument + // the result is a plain empty object → "[object Object]". + let args = match e { + ast::Expr::Call(call) => { + let ast::Callee::Expr(callee) = &call.callee else { + return None; + }; + let ast::Expr::Ident(id) = callee.as_ref() else { + return None; + }; + if id.sym.as_ref() != "Object" { + return None; + } + Some(&call.args) + } + ast::Expr::New(new_expr) => { + let ast::Expr::Ident(id) = new_expr.callee.as_ref() else { + return None; + }; + if id.sym.as_ref() != "Object" { + return None; + } + new_expr.args.as_ref() + } + _ => return None, + }; + match args.map(|a| a.as_slice()).unwrap_or(&[]) { + [] => Some("[object Object]".to_string()), + [arg] if arg.spread.is_none() => match literal_const_val(&arg.expr) { + Some(ConstVal::Null) | Some(ConstVal::Undefined) => Some("[object Object]".to_string()), + Some(v) => Some(v.to_js_string()), + None => None, + }, + _ => None, + } +} + +fn literal_const_val(expr: &ast::Expr) -> Option { + let mut e = expr; + while let ast::Expr::Paren(p) = e { + e = p.expr.as_ref(); + } + match e { + ast::Expr::Lit(ast::Lit::Str(s)) => { + Some(ConstVal::Str(s.value.as_str().unwrap_or("").to_string())) + } + ast::Expr::Lit(ast::Lit::Num(n)) => Some(ConstVal::Num(n.value)), + ast::Expr::Lit(ast::Lit::Bool(b)) => Some(ConstVal::Bool(b.value)), + ast::Expr::Lit(ast::Lit::Null(_)) => Some(ConstVal::Null), + ast::Expr::Ident(id) if id.sym.as_str() == "undefined" => Some(ConstVal::Undefined), + ast::Expr::Tpl(tpl) if tpl.exprs.is_empty() => tpl.quasis.first().map(|q| { + ConstVal::Str( + q.cooked + .as_ref() + .and_then(|c| c.as_str()) + .map(str::to_string) + .unwrap_or_else(|| q.raw.as_str().to_string()), + ) + }), + _ => None, + } +} + +/// Recognize `{ toString: function() { return/throw ; } }` (the only +/// property) and return its retained body statement. +pub(crate) fn object_tostring_body(expr: &ast::Expr) -> Option { + let mut e = expr; + while let ast::Expr::Paren(p) = e { + e = p.expr.as_ref(); + } + let ast::Expr::Object(obj) = e else { + return None; + }; + if obj.props.len() != 1 { + return None; + } + let ast::PropOrSpread::Prop(prop) = &obj.props[0] else { + return None; + }; + let (key_is_tostring, function) = match prop.as_ref() { + ast::Prop::KeyValue(kv) => { + let is_ts = matches!(&kv.key, ast::PropName::Ident(id) if id.sym.as_ref() == "toString") + || matches!(&kv.key, ast::PropName::Str(s) if s.value.as_str() == Some("toString")); + let ast::Expr::Fn(fn_expr) = kv.value.as_ref() else { + return None; + }; + (is_ts, &fn_expr.function) + } + ast::Prop::Method(m) => { + let is_ts = matches!(&m.key, ast::PropName::Ident(id) if id.sym.as_ref() == "toString") + || matches!(&m.key, ast::PropName::Str(s) if s.value.as_str() == Some("toString")); + (is_ts, &m.function) + } + _ => return None, + }; + if !key_is_tostring || !function.params.is_empty() { + return None; + } + let body = function.body.as_ref()?; + // Leading statements may only be simple `ident = ` side effects + // (Test262's `toString(){ p = 1; return "a"; }`); the assigned names are + // poisoned at evaluation time. The final statement is the return/throw. + let (last, leading) = body.stmts.split_last()?; + let mut poisoned = Vec::new(); + let mut assigns = Vec::new(); + for stmt in leading { + let ast::Stmt::Expr(es) = stmt else { + return None; + }; + let ast::Expr::Assign(assign) = es.expr.as_ref() else { + return None; + }; + let ast::AssignTarget::Simple(ast::SimpleAssignTarget::Ident(b)) = &assign.left else { + return None; + }; + // The RHS must be side-effect-free for the skip to be sound. + let rhs_src = match assign.right.as_ref() { + ast::Expr::Lit(ast::Lit::Str(st)) => { + ConstVal::Str(st.value.as_str().unwrap_or("").to_string()).to_js_literal() + } + ast::Expr::Lit(ast::Lit::Num(n)) => ConstVal::Num(n.value).to_js_literal(), + ast::Expr::Lit(ast::Lit::Bool(bl)) => ConstVal::Bool(bl.value).to_js_literal(), + ast::Expr::Lit(ast::Lit::Null(_)) => "null".to_string(), + ast::Expr::Ident(id) => id.sym.to_string(), + _ => return None, + }; + assigns.push(format!("{} = {};", b.id.sym, rhs_src)); + poisoned.push(b.id.sym.to_string()); + } + match last { + ast::Stmt::Return(ret) => ret.arg.as_ref().map(|arg| ToStringBody { + is_throw: false, + expr: (**arg).clone(), + poisoned: poisoned.clone(), + assigns: assigns.clone(), + }), + ast::Stmt::Throw(thr) => Some(ToStringBody { + is_throw: true, + expr: (*thr.arg).clone(), + poisoned, + assigns, + }), + _ => None, + } +} + +/// Count `++name` / `name++` / `--name` updates inside a recorded `toString` +/// body, for counter qualification. +fn count_counter_updates(expr: &ast::Expr, out: &mut HashMap) { + match expr { + ast::Expr::Update(u) => { + if let ast::Expr::Ident(id) = u.arg.as_ref() { + *out.entry(id.sym.to_string()).or_insert(0) += 1; + } + count_counter_updates(&u.arg, out); + } + ast::Expr::Bin(b) => { + count_counter_updates(&b.left, out); + count_counter_updates(&b.right, out); + } + ast::Expr::Paren(p) => count_counter_updates(&p.expr, out), + _ => {} + } +} + +/// Evaluate a recorded `toString` body against the env's counter state. +/// Returns the produced value or `None` when the body falls outside the +/// modeled subset (in which case the whole fold is abandoned, so any counter +/// mutation already applied is irrelevant — folding won't happen). +pub(crate) fn eval_tostring(env: &mut FnCtorEnv, body: &ToStringBody) -> Option { + let val = eval_const_expr(env, &body.expr)?; + env.pending_side_effects + .extend(body.assigns.iter().cloned()); + for name in &body.poisoned { + env.entries.remove(name); + env.counters.remove(name); + } + if body.is_throw { + Some(ResolvedArg::Thrown(val)) + } else { + Some(ResolvedArg::Str(val.to_js_string())) + } +} + +fn eval_const_expr(env: &mut FnCtorEnv, expr: &ast::Expr) -> Option { + if let Some(v) = literal_const_val(expr) { + return Some(v); + } + match expr { + ast::Expr::Paren(p) => eval_const_expr(env, &p.expr), + ast::Expr::Ident(id) => { + let name = id.sym.to_string(); + if let Some(v) = env.counters.get(&name) { + return Some(ConstVal::Num(*v)); + } + match env.entries.get(&name) { + Some(FnCtorShape::Str(s)) => Some(ConstVal::Str(s.clone())), + Some(FnCtorShape::UndefinedVar) => Some(ConstVal::Undefined), + _ => None, + } + } + ast::Expr::Update(u) => { + let ast::Expr::Ident(id) = u.arg.as_ref() else { + return None; + }; + let name = id.sym.to_string(); + let old = *env.counters.get(&name)?; + let new = match u.op { + ast::UpdateOp::PlusPlus => old + 1.0, + ast::UpdateOp::MinusMinus => old - 1.0, + }; + env.counters.insert(name, new); + Some(ConstVal::Num(if u.prefix { new } else { old })) + } + ast::Expr::Bin(b) if b.op == ast::BinaryOp::Add => { + let l = eval_const_expr(env, &b.left)?; + let r = eval_const_expr(env, &b.right)?; + match (&l, &r) { + (ConstVal::Num(a), ConstVal::Num(c)) => Some(ConstVal::Num(a + c)), + _ => Some(ConstVal::Str(format!( + "{}{}", + l.to_js_string(), + r.to_js_string() + ))), + } + } + _ => None, + } +} + +/// One declarator seen by the scan. +fn record_decl( + name: &str, + init: Option<&ast::Expr>, + decls: &mut HashMap)>, +) { + let entry = decls.entry(name.to_string()).or_insert((0, None)); + entry.0 += 1; + if entry.0 == 1 { + entry.1 = init.cloned(); + } else { + entry.1 = None; + } +} + +/// `var`/function declarations are FUNCTION-scoped: a `for (var i = …)` +/// inside a harness helper must not disqualify the test's module-level +/// `var i` counter. Each function walk extends the shadow set with its +/// params and every name it (re)declares; writes to shadowed names target +/// the inner binding and are not recorded against the module-level one. +type Shadow = std::collections::HashSet; + +fn record_write(name: &str, writes: &mut HashMap, shadow: &Shadow) { + if shadow.contains(name) { + return; + } + *writes.entry(name.to_string()).or_insert(0) += 1; +} + +fn collect_pat_names(pat: &ast::Pat, out: &mut Shadow) { + match pat { + ast::Pat::Ident(b) => { + out.insert(b.id.sym.to_string()); + } + ast::Pat::Array(arr) => { + for elem in arr.elems.iter().flatten() { + collect_pat_names(elem, out); + } + } + ast::Pat::Object(obj) => { + for prop in &obj.props { + match prop { + ast::ObjectPatProp::Assign(a) => { + out.insert(a.key.sym.to_string()); + } + ast::ObjectPatProp::KeyValue(kv) => collect_pat_names(&kv.value, out), + ast::ObjectPatProp::Rest(r) => collect_pat_names(&r.arg, out), + } + } + } + ast::Pat::Rest(r) => collect_pat_names(&r.arg, out), + ast::Pat::Assign(a) => collect_pat_names(&a.left, out), + _ => {} + } +} + +/// Hoisted names a function body declares (var/function/class declarations, +/// at any block depth but NOT inside nested functions). +fn collect_fn_scope_names(stmts: &[ast::Stmt], out: &mut Shadow) { + for stmt in stmts { + match stmt { + ast::Stmt::Decl(ast::Decl::Var(var)) => { + for d in &var.decls { + collect_pat_names(&d.name, out); + } + } + ast::Stmt::Decl(ast::Decl::Fn(f)) => { + out.insert(f.ident.sym.to_string()); + } + ast::Stmt::Decl(ast::Decl::Class(c)) => { + out.insert(c.ident.sym.to_string()); + } + ast::Stmt::Block(b) => collect_fn_scope_names(&b.stmts, out), + ast::Stmt::If(i) => { + collect_fn_scope_names(std::slice::from_ref(&i.cons), out); + if let Some(alt) = &i.alt { + collect_fn_scope_names(std::slice::from_ref(alt), out); + } + } + ast::Stmt::Try(t) => { + collect_fn_scope_names(&t.block.stmts, out); + if let Some(h) = &t.handler { + if let Some(p) = &h.param { + collect_pat_names(p, out); + } + collect_fn_scope_names(&h.body.stmts, out); + } + if let Some(f) = &t.finalizer { + collect_fn_scope_names(&f.stmts, out); + } + } + ast::Stmt::While(w) => collect_fn_scope_names(std::slice::from_ref(&w.body), out), + ast::Stmt::DoWhile(w) => collect_fn_scope_names(std::slice::from_ref(&w.body), out), + ast::Stmt::For(f) => { + if let Some(ast::VarDeclOrExpr::VarDecl(v)) = &f.init { + for d in &v.decls { + collect_pat_names(&d.name, out); + } + } + collect_fn_scope_names(std::slice::from_ref(&f.body), out); + } + ast::Stmt::ForIn(f) => { + if let ast::ForHead::VarDecl(v) = &f.left { + for d in &v.decls { + collect_pat_names(&d.name, out); + } + } + collect_fn_scope_names(std::slice::from_ref(&f.body), out); + } + ast::Stmt::ForOf(f) => { + if let ast::ForHead::VarDecl(v) = &f.left { + for d in &v.decls { + collect_pat_names(&d.name, out); + } + } + collect_fn_scope_names(std::slice::from_ref(&f.body), out); + } + ast::Stmt::Switch(sw) => { + for case in &sw.cases { + collect_fn_scope_names(&case.cons, out); + } + } + ast::Stmt::Labeled(l) => collect_fn_scope_names(std::slice::from_ref(&l.body), out), + ast::Stmt::With(w) => collect_fn_scope_names(std::slice::from_ref(&w.body), out), + _ => {} + } + } +} + +/// Treat every binding identifier in a pattern as a write (catch clauses, +/// destructuring) — it shadows or mutates the name. +fn record_pat_bindings(pat: &ast::Pat, writes: &mut HashMap, shadow: &Shadow) { + match pat { + ast::Pat::Ident(b) => record_write(&b.id.sym, writes, shadow), + ast::Pat::Array(arr) => { + for elem in arr.elems.iter().flatten() { + record_pat_bindings(elem, writes, shadow); + } + } + ast::Pat::Object(obj) => { + for prop in &obj.props { + match prop { + ast::ObjectPatProp::Assign(a) => { + record_write(&a.key.sym, writes, shadow); + if let Some(v) = &a.value { + scan_expr_writes(v, writes, shadow); + } + } + ast::ObjectPatProp::KeyValue(kv) => { + record_pat_bindings(&kv.value, writes, shadow) + } + ast::ObjectPatProp::Rest(r) => record_pat_bindings(&r.arg, writes, shadow), + } + } + } + ast::Pat::Rest(r) => record_pat_bindings(&r.arg, writes, shadow), + ast::Pat::Assign(a) => { + record_pat_bindings(&a.left, writes, shadow); + scan_expr_writes(&a.right, writes, shadow); + } + _ => {} + } +} + +fn scan_stmt( + stmt: &ast::Stmt, + decls: &mut HashMap)>, + writes: &mut HashMap, + shadow: &Shadow, +) { + match stmt { + ast::Stmt::Decl(ast::Decl::Var(var)) => { + for d in &var.decls { + if let ast::Pat::Ident(b) = &d.name { + record_decl(&b.id.sym, d.init.as_deref(), decls); + } else { + record_pat_bindings(&d.name, writes, shadow); + } + if let Some(init) = &d.init { + scan_expr_writes(init, writes, shadow); + } + } + } + ast::Stmt::Decl(ast::Decl::Fn(f)) => { + // The function name itself is a declaration of that name. + record_write(&f.ident.sym, writes, shadow); + scan_function_writes(&f.function, writes, shadow); + } + ast::Stmt::Decl(ast::Decl::Class(c)) => { + record_write(&c.ident.sym, writes, shadow); + scan_class_writes(&c.class, writes, shadow); + } + ast::Stmt::Decl(_) => {} + ast::Stmt::Block(b) => { + for s in &b.stmts { + scan_stmt(s, decls, writes, shadow); + } + } + ast::Stmt::If(i) => { + scan_expr_writes(&i.test, writes, shadow); + scan_stmt(&i.cons, decls, writes, shadow); + if let Some(alt) = &i.alt { + scan_stmt(alt, decls, writes, shadow); + } + } + ast::Stmt::Try(t) => { + for s in &t.block.stmts { + scan_stmt(s, decls, writes, shadow); + } + if let Some(h) = &t.handler { + if let Some(p) = &h.param { + record_pat_bindings(p, writes, shadow); + } + for s in &h.body.stmts { + scan_stmt(s, decls, writes, shadow); + } + } + if let Some(f) = &t.finalizer { + for s in &f.stmts { + scan_stmt(s, decls, writes, shadow); + } + } + } + ast::Stmt::While(w) => { + scan_expr_writes(&w.test, writes, shadow); + scan_stmt(&w.body, decls, writes, shadow); + } + ast::Stmt::DoWhile(w) => { + scan_expr_writes(&w.test, writes, shadow); + scan_stmt(&w.body, decls, writes, shadow); + } + ast::Stmt::For(f) => { + match &f.init { + Some(ast::VarDeclOrExpr::VarDecl(v)) => scan_stmt( + &ast::Stmt::Decl(ast::Decl::Var(v.clone())), + decls, + writes, + shadow, + ), + Some(ast::VarDeclOrExpr::Expr(e)) => scan_expr_writes(e, writes, shadow), + None => {} + } + if let Some(t) = &f.test { + scan_expr_writes(t, writes, shadow); + } + if let Some(u) = &f.update { + scan_expr_writes(u, writes, shadow); + } + scan_stmt(&f.body, decls, writes, shadow); + } + ast::Stmt::ForIn(f) => { + scan_for_head_writes(&f.left, writes, shadow); + scan_expr_writes(&f.right, writes, shadow); + scan_stmt(&f.body, decls, writes, shadow); + } + ast::Stmt::ForOf(f) => { + scan_for_head_writes(&f.left, writes, shadow); + scan_expr_writes(&f.right, writes, shadow); + scan_stmt(&f.body, decls, writes, shadow); + } + ast::Stmt::Switch(s) => { + scan_expr_writes(&s.discriminant, writes, shadow); + for case in &s.cases { + if let Some(t) = &case.test { + scan_expr_writes(t, writes, shadow); + } + for st in &case.cons { + scan_stmt(st, decls, writes, shadow); + } + } + } + ast::Stmt::Labeled(l) => scan_stmt(&l.body, decls, writes, shadow), + ast::Stmt::Return(r) => { + if let Some(a) = &r.arg { + scan_expr_writes(a, writes, shadow); + } + } + ast::Stmt::Throw(t) => scan_expr_writes(&t.arg, writes, shadow), + ast::Stmt::Expr(e) => scan_expr_writes(&e.expr, writes, shadow), + ast::Stmt::With(w) => { + scan_expr_writes(&w.obj, writes, shadow); + scan_stmt(&w.body, decls, writes, shadow); + } + _ => {} + } +} + +fn scan_for_head_writes(head: &ast::ForHead, writes: &mut HashMap, shadow: &Shadow) { + match head { + ast::ForHead::VarDecl(v) => { + for d in &v.decls { + record_pat_bindings(&d.name, writes, shadow); + if let Some(init) = &d.init { + scan_expr_writes(init, writes, shadow); + } + } + } + ast::ForHead::Pat(p) => record_pat_bindings(p, writes, shadow), + ast::ForHead::UsingDecl(u) => { + for d in &u.decls { + record_pat_bindings(&d.name, writes, shadow); + } + } + } +} + +fn scan_assign_target_writes( + target: &ast::AssignTarget, + writes: &mut HashMap, + shadow: &Shadow, +) { + match target { + ast::AssignTarget::Simple(simple) => match simple { + ast::SimpleAssignTarget::Ident(b) => record_write(&b.id.sym, writes, shadow), + ast::SimpleAssignTarget::Member(m) => { + scan_expr_writes(&m.obj, writes, shadow); + if let ast::MemberProp::Computed(c) = &m.prop { + scan_expr_writes(&c.expr, writes, shadow); + } + } + ast::SimpleAssignTarget::Paren(p) => scan_expr_writes(&p.expr, writes, shadow), + _ => {} + }, + ast::AssignTarget::Pat(pat) => match pat { + ast::AssignTargetPat::Array(arr) => { + for elem in arr.elems.iter().flatten() { + record_pat_bindings(elem, writes, shadow); + } + } + ast::AssignTargetPat::Object(obj) => { + for prop in &obj.props { + match prop { + ast::ObjectPatProp::Assign(a) => record_write(&a.key.sym, writes, shadow), + ast::ObjectPatProp::KeyValue(kv) => { + record_pat_bindings(&kv.value, writes, shadow) + } + ast::ObjectPatProp::Rest(r) => record_pat_bindings(&r.arg, writes, shadow), + } + } + } + _ => {} + }, + } +} + +/// Walk a nested function for writes to NON-shadowed (module-level) names. +/// The function's params and its own hoisted declarations extend the shadow. +fn scan_fn_body_writes( + params: &[&ast::Pat], + stmts: &[ast::Stmt], + writes: &mut HashMap, + outer_shadow: &Shadow, +) { + let mut shadow = outer_shadow.clone(); + for p in params { + collect_pat_names(p, &mut shadow); + } + collect_fn_scope_names(stmts, &mut shadow); + let mut nested_decls: HashMap)> = HashMap::new(); + for s in stmts { + scan_stmt(s, &mut nested_decls, writes, &shadow); + } +} + +fn scan_function_writes( + function: &ast::Function, + writes: &mut HashMap, + shadow: &Shadow, +) { + let params: Vec<&ast::Pat> = function.params.iter().map(|p| &p.pat).collect(); + let stmts: &[ast::Stmt] = function + .body + .as_ref() + .map(|b| b.stmts.as_slice()) + .unwrap_or(&[]); + scan_fn_body_writes(¶ms, stmts, writes, shadow); +} + +fn scan_class_writes(class: &ast::Class, writes: &mut HashMap, shadow: &Shadow) { + if let Some(sup) = &class.super_class { + scan_expr_writes(sup, writes, shadow); + } + for member in &class.body { + match member { + ast::ClassMember::Method(m) => scan_function_writes(&m.function, writes, shadow), + ast::ClassMember::PrivateMethod(m) => scan_function_writes(&m.function, writes, shadow), + ast::ClassMember::Constructor(c) => { + let params: Vec<&ast::Pat> = c + .params + .iter() + .filter_map(|p| match p { + ast::ParamOrTsParamProp::Param(p) => Some(&p.pat), + _ => None, + }) + .collect(); + let stmts: &[ast::Stmt] = + c.body.as_ref().map(|b| b.stmts.as_slice()).unwrap_or(&[]); + scan_fn_body_writes(¶ms, stmts, writes, shadow); + } + ast::ClassMember::ClassProp(p) => { + if let Some(v) = &p.value { + scan_expr_writes(v, writes, shadow); + } + } + ast::ClassMember::PrivateProp(p) => { + if let Some(v) = &p.value { + scan_expr_writes(v, writes, shadow); + } + } + ast::ClassMember::StaticBlock(b) => { + scan_fn_body_writes(&[], &b.body.stmts, writes, shadow); + } + _ => {} + } + } +} + +fn scan_expr_writes(expr: &ast::Expr, writes: &mut HashMap, shadow: &Shadow) { + match expr { + ast::Expr::Assign(a) => { + scan_assign_target_writes(&a.left, writes, shadow); + scan_expr_writes(&a.right, writes, shadow); + } + ast::Expr::Update(u) => { + if let ast::Expr::Ident(id) = u.arg.as_ref() { + record_write(&id.sym, writes, shadow); + } else { + scan_expr_writes(&u.arg, writes, shadow); + } + } + ast::Expr::Bin(b) => { + scan_expr_writes(&b.left, writes, shadow); + scan_expr_writes(&b.right, writes, shadow); + } + ast::Expr::Unary(u) => scan_expr_writes(&u.arg, writes, shadow), + ast::Expr::Cond(c) => { + scan_expr_writes(&c.test, writes, shadow); + scan_expr_writes(&c.cons, writes, shadow); + scan_expr_writes(&c.alt, writes, shadow); + } + ast::Expr::Call(c) => { + if let ast::Callee::Expr(callee) = &c.callee { + scan_expr_writes(callee, writes, shadow); + } + for a in &c.args { + scan_expr_writes(&a.expr, writes, shadow); + } + } + ast::Expr::New(n) => { + scan_expr_writes(&n.callee, writes, shadow); + if let Some(args) = &n.args { + for a in args { + scan_expr_writes(&a.expr, writes, shadow); + } + } + } + ast::Expr::Member(m) => { + scan_expr_writes(&m.obj, writes, shadow); + if let ast::MemberProp::Computed(c) = &m.prop { + scan_expr_writes(&c.expr, writes, shadow); + } + } + ast::Expr::OptChain(o) => { + if let ast::OptChainBase::Member(m) = &*o.base { + scan_expr_writes(&m.obj, writes, shadow); + if let ast::MemberProp::Computed(c) = &m.prop { + scan_expr_writes(&c.expr, writes, shadow); + } + } else if let ast::OptChainBase::Call(c) = &*o.base { + scan_expr_writes(&c.callee, writes, shadow); + for a in &c.args { + scan_expr_writes(&a.expr, writes, shadow); + } + } + } + ast::Expr::Paren(p) => scan_expr_writes(&p.expr, writes, shadow), + ast::Expr::Seq(s) => { + for e in &s.exprs { + scan_expr_writes(e, writes, shadow); + } + } + ast::Expr::Array(arr) => { + for elem in arr.elems.iter().flatten() { + scan_expr_writes(&elem.expr, writes, shadow); + } + } + ast::Expr::Object(obj) => { + for prop in &obj.props { + match prop { + ast::PropOrSpread::Spread(s) => scan_expr_writes(&s.expr, writes, shadow), + ast::PropOrSpread::Prop(p) => match p.as_ref() { + ast::Prop::KeyValue(kv) => { + if let ast::PropName::Computed(c) = &kv.key { + scan_expr_writes(&c.expr, writes, shadow); + } + scan_expr_writes(&kv.value, writes, shadow); + } + ast::Prop::Method(m) => scan_function_writes(&m.function, writes, shadow), + ast::Prop::Getter(g) => { + let stmts: &[ast::Stmt] = + g.body.as_ref().map(|b| b.stmts.as_slice()).unwrap_or(&[]); + scan_fn_body_writes(&[], stmts, writes, shadow); + } + ast::Prop::Setter(st) => { + let stmts: &[ast::Stmt] = + st.body.as_ref().map(|b| b.stmts.as_slice()).unwrap_or(&[]); + scan_fn_body_writes(&[&st.param], stmts, writes, shadow); + } + ast::Prop::Shorthand(_) => {} + ast::Prop::Assign(a) => scan_expr_writes(&a.value, writes, shadow), + }, + } + } + } + ast::Expr::Fn(f) => scan_function_writes(&f.function, writes, shadow), + ast::Expr::Arrow(a) => { + let params: Vec<&ast::Pat> = a.params.iter().collect(); + match &*a.body { + ast::BlockStmtOrExpr::BlockStmt(b) => { + scan_fn_body_writes(¶ms, &b.stmts, writes, shadow); + } + ast::BlockStmtOrExpr::Expr(e) => { + let mut inner = shadow.clone(); + for p in ¶ms { + collect_pat_names(p, &mut inner); + } + scan_expr_writes(e, writes, &inner); + } + } + } + ast::Expr::Class(c) => scan_class_writes(&c.class, writes, shadow), + ast::Expr::Tpl(t) => { + for e in &t.exprs { + scan_expr_writes(e, writes, shadow); + } + } + ast::Expr::TaggedTpl(t) => { + scan_expr_writes(&t.tag, writes, shadow); + for e in &t.tpl.exprs { + scan_expr_writes(e, writes, shadow); + } + } + ast::Expr::Await(a) => scan_expr_writes(&a.arg, writes, shadow), + ast::Expr::Yield(y) => { + if let Some(a) = &y.arg { + scan_expr_writes(a, writes, shadow); + } + } + ast::Expr::TsAs(t) => scan_expr_writes(&t.expr, writes, shadow), + ast::Expr::TsTypeAssertion(t) => scan_expr_writes(&t.expr, writes, shadow), + ast::Expr::TsNonNull(t) => scan_expr_writes(&t.expr, writes, shadow), + _ => {} + } +} diff --git a/crates/perry-hir/src/lower/lower_expr.rs b/crates/perry-hir/src/lower/lower_expr.rs index 7602111457..7b8ac57b85 100644 --- a/crates/perry-hir/src/lower/lower_expr.rs +++ b/crates/perry-hir/src/lower/lower_expr.rs @@ -544,9 +544,20 @@ pub(crate) fn lower_expr(ctx: &mut LoweringContext, expr: &ast::Expr) -> Result< // 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 { - return Ok(throw_reference_error_expr( - "js_throw_reference_error_unresolved_get", - )); + // 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. + return Ok(Expr::Call { + callee: Box::new(Expr::ExternFuncRef { + name: "js_global_get_or_throw_unresolved".to_string(), + param_types: vec![Type::Any], + return_type: Type::Any, + }), + args: vec![Expr::String(name.clone())], + type_args: Vec::new(), + }); } if !known_global { eprintln!( @@ -1555,7 +1566,17 @@ pub(crate) fn lower_expr(ctx: &mut LoweringContext, expr: &ast::Expr) -> Result< } ast::Expr::Object(obj) => expr_object::lower_object(ctx, obj), ast::Expr::This(_) => { - // Always use Expr::This - the codegen will handle it with ThisContext + // Module TOP-LEVEL `this` is Node-CJS `module.exports` — a fresh + // plain object, not `globalThis` (the oracle runs assembled test + // files as CommonJS). Function/class/with bodies keep dynamic + // `Expr::This` semantics, handled by codegen's ThisContext. + if ctx.scope_depth == 0 + && ctx.current_class.is_none() + && ctx.with_env_stack.is_empty() + && !ctx.is_external_module + { + return Ok(Expr::ModuleTopThis); + } Ok(Expr::This) } ast::Expr::New(new_expr) => expr_new::lower_new(ctx, new_expr), diff --git a/crates/perry-hir/src/lower/lower_module_fn.rs b/crates/perry-hir/src/lower/lower_module_fn.rs index f365ceb7d9..47aeb39a0b 100644 --- a/crates/perry-hir/src/lower/lower_module_fn.rs +++ b/crates/perry-hir/src/lower/lower_module_fn.rs @@ -349,6 +349,11 @@ pub fn lower_module_full( } let mut module = Module::new(name); + // Pre-scan for `new Function` / `Function(...)` constant-argument + // resolution: single-assignment module vars, `toString`-bearing object + // literals, and counter vars (see `fn_ctor_env`). + ctx.fn_ctor_env = super::fn_ctor_env::build_fn_ctor_env(ast_module); + // Pre-scan for WeakRef/FinalizationRegistry variable declarations so subsequent // method-call lowering (`x.deref()`, `x.register(...)`, `x.unregister(...)`) can // route via the dedicated HIR variants without relying on type inference. diff --git a/crates/perry-hir/src/lower/lowering_context.rs b/crates/perry-hir/src/lower/lowering_context.rs index 722f4aead4..8c69d1ba28 100644 --- a/crates/perry-hir/src/lower/lowering_context.rs +++ b/crates/perry-hir/src/lower/lowering_context.rs @@ -565,4 +565,9 @@ pub struct LoweringContext { /// observe the failure instead of rejecting the whole user source at /// compile time. Outside try blocks, `require(literal)` still hard-errors. pub(crate) optional_require_try_depth: u32, + /// Pre-scanned constant environment for `new Function` / `Function(...)` + /// argument resolution (single-assignment module vars, `toString`-bearing + /// object literals, counters). Built once per module in + /// `lower_module_full`; consumed by `const_fold_fn`. + pub(crate) fn_ctor_env: super::fn_ctor_env::FnCtorEnv, } diff --git a/crates/perry-hir/src/lower/mod.rs b/crates/perry-hir/src/lower/mod.rs index 8766a7aa0c..3fb1270e40 100644 --- a/crates/perry-hir/src/lower/mod.rs +++ b/crates/perry-hir/src/lower/mod.rs @@ -63,6 +63,7 @@ mod pre_scan; pub(crate) use pre_scan::*; mod closure_analysis; mod const_fold_fn; +mod fn_ctor_env; pub(crate) use closure_analysis::*; mod decorators; pub(crate) use decorators::*; diff --git a/crates/perry-hir/src/stable_hash/expr.rs b/crates/perry-hir/src/stable_hash/expr.rs index f6b58c1af2..fb0884125f 100644 --- a/crates/perry-hir/src/stable_hash/expr.rs +++ b/crates/perry-hir/src/stable_hash/expr.rs @@ -99,6 +99,7 @@ impl SH for Expr { Expr::EnvGetDynamic(e) => { tag(h, 54); e.as_ref().hash(h); } Expr::ProcessEnv => tag(h, 55), Expr::GlobalThisExpr => tag(h, 474), + Expr::ModuleTopThis => tag(h, 4741), Expr::ProcessUptime => tag(h, 56), Expr::ProcessCwd => tag(h, 57), Expr::ProcessArgv => tag(h, 58), diff --git a/crates/perry-hir/src/walker/expr_mut.rs b/crates/perry-hir/src/walker/expr_mut.rs index 777404002f..2ab806a75b 100644 --- a/crates/perry-hir/src/walker/expr_mut.rs +++ b/crates/perry-hir/src/walker/expr_mut.rs @@ -36,6 +36,7 @@ where | Expr::EnvGet(_) | Expr::ProcessEnv | Expr::GlobalThisExpr + | Expr::ModuleTopThis | Expr::ProcessUptime | Expr::ProcessCwd | Expr::ProcessArgv diff --git a/crates/perry-hir/src/walker/expr_ref.rs b/crates/perry-hir/src/walker/expr_ref.rs index 40c8ca5932..f501e8e11f 100644 --- a/crates/perry-hir/src/walker/expr_ref.rs +++ b/crates/perry-hir/src/walker/expr_ref.rs @@ -37,6 +37,7 @@ where | Expr::EnvGet(_) | Expr::ProcessEnv | Expr::GlobalThisExpr + | Expr::ModuleTopThis | Expr::ProcessUptime | Expr::ProcessCwd | Expr::ProcessArgv diff --git a/crates/perry-runtime/src/builtins/arithmetic.rs b/crates/perry-runtime/src/builtins/arithmetic.rs index f804f5e340..3281c80652 100644 --- a/crates/perry-runtime/src/builtins/arithmetic.rs +++ b/crates/perry-runtime/src/builtins/arithmetic.rs @@ -498,6 +498,16 @@ pub extern "C" fn js_value_typeof(value: f64) -> *mut StringHeader { // Reading a fake handle's `[ptr+12]` type tag otherwise segfaults // (e.g. zlib's reserved stream base). let ptr = jsval.as_pointer::(); + // A Proxy id is a SMALL pointer (below the heap floor), so check the + // registry before the floor gate. typeof proxy is "function" iff its + // (possibly nested) [[ProxyTarget]] is callable. + if crate::proxy::js_proxy_is_proxy(value) == 1 { + return if crate::proxy::proxy_wraps_callable(value) { + get_cached(&TYPEOF_FUNCTION, "function") + } else { + get_cached(&TYPEOF_OBJECT, "object") + }; + } if !ptr.is_null() && (ptr as usize) >= 0x100000 { // Symbols: registered in SYMBOL_POINTERS (handles both gc_malloc'd // and Box-leaked symbols, which have no GcHeader). diff --git a/crates/perry-runtime/src/builtins/mod.rs b/crates/perry-runtime/src/builtins/mod.rs index 620684be7e..3f78f8602a 100644 --- a/crates/perry-runtime/src/builtins/mod.rs +++ b/crates/perry-runtime/src/builtins/mod.rs @@ -75,9 +75,9 @@ pub(crate) use console::{ }; pub use formatting::{ - function_name_for_ptr, function_source_for_func_ptr, js_array_print, js_boxed_bigint_new, - js_boxed_boolean_new, js_boxed_number_new, js_boxed_string_new, js_boxed_symbol_new, - js_register_function_name, js_register_function_source, js_util_format, + function_name_for_ptr, function_source_for_func_ptr, function_source_for_ptr, js_array_print, + js_boxed_bigint_new, js_boxed_boolean_new, js_boxed_number_new, js_boxed_string_new, + js_boxed_symbol_new, js_register_function_name, js_register_function_source, js_util_format, js_util_format_with_options, js_util_inspect, js_util_is_deep_strict_equal, js_util_is_deep_strict_equal_skip_prototype, js_util_strip_vt_control_characters, register_function_name_if_absent, scan_boxed_primitive_payload_roots_mut, diff --git a/crates/perry-runtime/src/closure/dispatch.rs b/crates/perry-runtime/src/closure/dispatch.rs index 6ddde7f64d..14bf27cb9c 100644 --- a/crates/perry-runtime/src/closure/dispatch.rs +++ b/crates/perry-runtime/src/closure/dispatch.rs @@ -90,6 +90,57 @@ pub unsafe fn dispatch_bound_function(closure: *const ClosureHeader, args: &[f64 result } +/// OrdinaryCallBindThis for the `call`/`apply`/`bind` entry points: box a +/// primitive `thisArg` to its wrapper object ONCE, up front, so writes the +/// callee makes through `this` land on the same object it later returns +/// (`Function("this.touched = true; return this;").apply(1)` must yield a +/// Number wrapper with `.touched`). Per-access boxing inside the callee +/// created a fresh wrapper per `this` expression, losing the writes. +/// +/// Boxing is gated on the CALLEE: only a *sloppy user* function coerces its +/// `this`. A strict callee observes the raw primitive (`fun.call("")` under +/// `"use strict"` must see `this instanceof String === false`), and built-in +/// thunks (no registered source) do their own receiver coercion — handing +/// them a pre-boxed wrapper would change generic-`this` method semantics. +/// `undefined`/`null` pass through (sloppy global substitution happens +/// elsewhere), as do existing objects. +pub(crate) fn coerce_call_this(target: f64, this_arg: f64) -> f64 { + let jv = crate::value::JSValue::from_bits(this_arg.to_bits()); + if jv.is_undefined() || jv.is_null() || jv.is_pointer() { + return this_arg; + } + let tj = crate::value::JSValue::from_bits(target.to_bits()); + if !tj.is_pointer() { + return this_arg; + } + let mut closure = tj.as_pointer::(); + // Look through bound-function wrappers to the ultimate target — the + // bound `this` is what reaches it, so its strictness decides. + for _ in 0..8 { + if closure.is_null() || unsafe { (*closure).type_tag } != CLOSURE_MAGIC { + return this_arg; + } + if unsafe { (*closure).func_ptr } as usize == BOUND_FUNCTION_FUNC_PTR as usize { + let inner = unsafe { js_closure_get_capture_f64(closure, 0) }; + let ij = crate::value::JSValue::from_bits(inner.to_bits()); + if !ij.is_pointer() { + return this_arg; + } + closure = ij.as_pointer::(); + continue; + } + break; + } + let func_ptr = get_valid_func_ptr(closure); + if func_ptr.is_null() + || crate::builtins::function_source_for_ptr(func_ptr as usize).is_none() + || crate::closure::is_registered_strict_function(func_ptr) + { + return this_arg; + } + crate::object::js_object_coerce(this_arg) +} + /// Read a callable's own `name` *property* as a Rust `String`, if present and a /// String value. Covers names installed by `Object.defineProperty(fn, "name", /// …)` and the `"bound …"` name a prior `.bind()` stores, neither of which is @@ -123,8 +174,18 @@ pub unsafe extern "C" fn js_function_bind( use crate::value::JSValue; let target_jv = JSValue::from_bits(target_value.to_bits()); - // Only closures can be bound; non-callable receivers fall back to - // returning the receiver unchanged (the prior conservative behavior). + // Spec brand check: `Function.prototype.bind` on a non-callable receiver + // throws a TypeError. Callable non-closures (small native function + // handles, proxies wrapping callables) keep the prior conservative + // pass-through — they can't be wrapped in a BOUND_FUNCTION closure yet. + if !crate::object::value_is_callable(target_value) + && crate::proxy::js_proxy_is_proxy(target_value) != 1 + { + let message = b"Bind must be called on a function"; + let msg = crate::string::js_string_from_bytes(message.as_ptr(), message.len() as u32); + let err = crate::error::js_typeerror_new(msg); + crate::exception::js_throw(crate::value::js_nanbox_pointer(err as i64)); + } if !target_jv.is_pointer() { return target_value; } @@ -134,7 +195,7 @@ pub unsafe extern "C" fn js_function_bind( } let bound_this = if args_len >= 1 && !args_ptr.is_null() { - *args_ptr + coerce_call_this(target_value, *args_ptr) } else { f64::from_bits(crate::value::TAG_UNDEFINED) }; @@ -158,10 +219,41 @@ pub unsafe extern "C" fn js_function_bind( js_closure_set_capture_f64(bound, 1, bound_this); js_closure_set_capture_ptr(bound, 2, bound_args_arr as i64); - // Spec `.length` = max(0, target.length - boundArgs.length). - let target_len = crate::closure::closure_length(target_closure).unwrap_or(0); - let bound_len = target_len.saturating_sub(bound_arg_count as u32); - crate::object::set_builtin_closure_length(bound as usize, bound_len); + // Spec `.length` = max(0, ToIntegerOrInfinity(Get(target, "length")) - + // boundArgs.length). An `Object.defineProperty(fn, "length", {value})` + // override (own dynamic prop) wins over the registered declared length, + // and the value may be NaN (→ 0), ±Infinity, or beyond int32. + let target_len_f = + match crate::closure::closure_get_own_dynamic_prop(target_closure as usize, "length") { + Some(v) => { + let jv = JSValue::from_bits(v.to_bits()); + if jv.is_int32() { + jv.as_int32() as f64 + } else if jv.is_number() { + jv.as_number() + } else { + 0.0 + } + } + None => crate::closure::closure_length(target_closure).unwrap_or(0) as f64, + }; + let target_len_f = if target_len_f.is_nan() { + 0.0 + } else { + target_len_f.trunc() + }; + let bound_len = (target_len_f - bound_arg_count as f64).max(0.0); + if bound_len.is_finite() && bound_len <= u32::MAX as f64 { + crate::object::set_builtin_closure_length(bound as usize, bound_len as u32); + } else { + // +Infinity (or beyond u32): store as an own dynamic prop, which the + // `.length` read path prefers over the registered builtin length. + crate::closure::closure_set_dynamic_prop( + bound as usize, + "length", + f64::from_bits(JSValue::number(bound_len).bits()), + ); + } // Spec `.name` = "bound " + targetName, where targetName is `Get(Target, // "name")` (the empty string when that is not a String). Read the target's @@ -178,6 +270,20 @@ pub unsafe extern "C" fn js_function_bind( crate::string::js_string_from_bytes(bound_name.as_ptr(), bound_name.len() as u32); let name_value = f64::from_bits(JSValue::string_ptr(name_ptr).bits()); crate::closure::closure_set_dynamic_prop(bound as usize, "name", name_value); + // Spec attributes for a function's own `name`/`length`: + // { writable: false, enumerable: false, configurable: true }. Without + // these the dynamic-prop `name` slot defaults to enumerable and shows + // up in for-in / Object.keys (Test262 bind/instance-name*). + crate::object::set_builtin_property_attrs( + bound as usize, + "name".to_string(), + crate::object::PropertyAttrs::new(false, false, true), + ); + crate::object::set_builtin_property_attrs( + bound as usize, + "length".to_string(), + crate::object::PropertyAttrs::new(false, false, true), + ); crate::gc::runtime_write_barrier_root_heap_word(bound as u64); f64::from_bits(JSValue::pointer(bound as *mut u8).bits()) @@ -215,6 +321,17 @@ pub(crate) unsafe fn reify_function_method_value(receiver: f64, method: &'static // read back sensibly (e.g. `"bind"`). if let Ok(name) = std::str::from_utf8(method) { crate::object::set_bound_native_closure_name(closure, name); + // Spec `.length` of the Function.prototype methods: call/bind take + // `(thisArg, ...)` → 1, apply `(thisArg, argArray)` → 2. Built-in + // methods are also not constructors — `new (f.apply)` is a TypeError + // and they expose no own `.prototype`. + let len = match name { + "apply" => 2, + "call" | "bind" => 1, + _ => 0, + }; + crate::object::set_builtin_closure_length(closure as usize, len); + crate::object::set_builtin_closure_non_constructable(closure as usize); } crate::gc::runtime_write_barrier_root_heap_word(closure as u64); f64::from_bits(crate::value::JSValue::pointer(closure as *mut u8).bits()) @@ -1223,6 +1340,16 @@ pub unsafe extern "C" fn js_native_call_value( // TAG_UNDEFINED, TAG_NULL, or other NaN values are not callable return f64::from_bits(JSValue::undefined().bits()); } else { + // A genuine double (bits outside the NaN-box tag space), a string, or + // a boolean is never callable — `fn.length()` must throw a TypeError, + // not get reinterpreted as a raw pointer. Raw-i64 heap pointers + // (top 16 bits zero) and INT32/class-ref/bigint tags keep the legacy + // pointer treatment below. + let bits = func_value.to_bits(); + let top = (bits >> 48) & 0x7FFF; + if (top != 0 && (top & 0x7FF8) != 0x7FF8) || top == 0x7FFF || top == 0x7FFC { + throw_not_callable(); + } // Try treating the value directly as a pointer (for i64 representation) func_value.to_bits() as *const ClosureHeader }; @@ -1253,6 +1380,13 @@ pub unsafe extern "C" fn js_native_call_value( // own registry path via `lookup_closure_rest` which already pads, so we // skip the arity lookup when the rest registry has an entry. let func_ptr = get_valid_func_ptr(closure); + // %Function.prototype% is itself callable: it accepts any arguments and + // returns `undefined` (ECMA-262 20.2.3). It is stored as a plain object, + // so it lands here with no valid func_ptr — short-circuit before the + // not-callable throw. + if func_ptr.is_null() && crate::object::is_function_prototype_object_value(func_value) { + return f64::from_bits(crate::value::TAG_UNDEFINED); + } let dispatch_args_len = if !func_ptr.is_null() && lookup_closure_rest(func_ptr).is_none() { match lookup_closure_arity(func_ptr) { Some(declared) if (declared as usize) > args_len => declared as usize, diff --git a/crates/perry-runtime/src/closure/dynamic_props.rs b/crates/perry-runtime/src/closure/dynamic_props.rs index e32a217306..e52b67a087 100644 --- a/crates/perry-runtime/src/closure/dynamic_props.rs +++ b/crates/perry-runtime/src/closure/dynamic_props.rs @@ -381,6 +381,80 @@ pub fn closure_get_dynamic_prop(ptr: usize, prop: &str) -> f64 { } break; } + // Every function's [[Prototype]] is %Function.prototype% — an expando + // installed there (`Function.prototype.property = 12`) must be readable + // through any closure (`fn.property`, `boundFn.property`, + // `Function.indicator`). Synthesized own slots (`prototype`/`name`/ + // `length`/`caller`/`arguments`/`constructor`) never come from the + // expando walk; excluding `prototype` also breaks the recursion through + // `builtin_prototype_value` (which reads `Function.prototype` via this + // very function). A re-entrancy guard covers the rest of that resolution + // cycle. + if !matches!( + prop, + "prototype" | "name" | "length" | "caller" | "arguments" | "constructor" + ) { + thread_local! { + static IN_FN_PROTO_FALLBACK: std::cell::Cell = + const { std::cell::Cell::new(false) }; + } + let reentrant = IN_FN_PROTO_FALLBACK.with(|c| c.replace(true)); + if !reentrant { + let proto_val = crate::object::builtin_prototype_value("Function"); + IN_FN_PROTO_FALLBACK.with(|c| c.set(false)); + let proto_jv = crate::value::JSValue::from_bits(proto_val.to_bits()); + if proto_jv.is_pointer() { + let proto_ptr = (proto_jv.bits() & crate::value::POINTER_MASK) as usize; + // USER expandos walk through (a `Function.prototype.x = …` + // write records no attrs), as do the spec call-routing + // methods `apply`/`call`/`bind` (so an OBJECT whose proto + // chain contains a function resolves `obj.apply` — + // S15.3.4.3_A1_T1). Every OTHER method installed at init + // (`hasOwnProperty`, `propertyIsEnumerable`, …) stays + // excluded: serving those generic object thunks to closure + // reads would hijack the dedicated closure-aware dispatch + // arms. + let routed_method = matches!(prop, "apply" | "call" | "bind"); + if proto_ptr != 0 + && proto_ptr != ptr + && !is_closure_ptr(proto_ptr) + && (routed_method + || crate::object::get_property_attrs(proto_ptr, prop).is_none()) + { + // A defineProperty accessor on Function.prototype + // (`{ get: () => 12 }`) is invoked with the reading + // closure as receiver. + if !routed_method { + if let Some(acc) = crate::object::get_accessor_descriptor(proto_ptr, prop) { + if acc.get != 0 { + let getter = (acc.get & crate::value::POINTER_MASK) + as *const crate::closure::ClosureHeader; + if !getter.is_null() { + let receiver = crate::value::js_nanbox_pointer(ptr as i64); + let prev = crate::object::js_implicit_this_set(receiver); + let result = crate::closure::js_closure_call0(getter); + crate::object::js_implicit_this_set(prev); + return result; + } + } + return f64::from_bits(crate::value::TAG_UNDEFINED); + } + } + unsafe { + let key_hdr = + crate::string::js_string_from_bytes(prop.as_ptr(), prop.len() as u32); + let v = crate::object::js_object_get_field_by_name( + proto_ptr as *const crate::object::ObjectHeader, + key_hdr as *const crate::StringHeader, + ); + if !v.is_undefined() { + return f64::from_bits(v.bits()); + } + } + } + } + } + } f64::from_bits(crate::value::TAG_UNDEFINED) } @@ -399,6 +473,16 @@ pub fn closure_set_dynamic_prop(ptr: usize, prop: &str, value: f64) { } } +/// Read an OWN dynamic property without any prototype/builtin fallback. +/// Used by `bind` to honor an `Object.defineProperty(fn, "length", …)` +/// override before falling back to the registered declared length. +pub fn closure_get_own_dynamic_prop(ptr: usize, prop: &str) -> Option { + if let Ok(props) = get_closure_props().lock() { + return props.get(&ptr).and_then(|m| m.get(prop).copied()); + } + None +} + /// #3655: remove an OWN user dynamic property from a closure (used by /// `delete fn.userProp`). Returns true if a property was actually removed. /// Built-in synthesized slots (`name`/`length`/`prototype`) are handled by diff --git a/crates/perry-runtime/src/closure/mod.rs b/crates/perry-runtime/src/closure/mod.rs index 085e9fb57c..bb6d185241 100644 --- a/crates/perry-runtime/src/closure/mod.rs +++ b/crates/perry-runtime/src/closure/mod.rs @@ -28,10 +28,11 @@ pub use registry::{ build_rest_array, closure_arity, closure_is_arrow, closure_is_bound_method, closure_length, dispatch_rest_bundled, dispatch_with_arity, is_registered_arrow_function, is_registered_async_function, is_registered_async_generator_function, - is_registered_generator_function, js_register_closure_arity, + is_registered_generator_function, is_registered_strict_function, js_register_closure_arity, js_register_closure_arrow_function, js_register_closure_async_function, js_register_closure_async_generator_function, js_register_closure_generator_function, js_register_closure_length, js_register_closure_rest, js_register_closure_rest_and_arguments, + js_register_closure_strict_function, js_register_closure_synthetic_arguments, lookup_closure_arity, lookup_closure_length, lookup_closure_rest, lookup_closure_rest_full, real_capture_count, resolve_strategy, DispatchStrategy, BOUND_FUNCTION_FUNC_PTR, BOUND_METHOD_FUNC_PTR, CAPTURES_THIS_FLAG, @@ -47,7 +48,8 @@ pub use dispatch::{ js_function_bind, js_native_call_value, throw_not_callable, }; pub(crate) use dispatch::{ - reify_function_method_value, reset_throw_not_callable_counter, resolve_call2_direct, + coerce_call_this, reify_function_method_value, reset_throw_not_callable_counter, + resolve_call2_direct, }; #[cfg(test)] @@ -59,9 +61,10 @@ pub(crate) use dynamic_props::{ }; pub use dynamic_props::{ closure_delete_own_dynamic_prop, closure_dynamic_props_snapshot, closure_get_dynamic_prop, - closure_has_own_dynamic_prop, closure_is_key_deleted, closure_mark_key_deleted, - closure_set_dynamic_prop, closure_set_static_prototype, closure_static_prototype, - is_closure_ptr, js_closure_unbind_this, scan_closure_dynamic_props_roots_mut, + closure_get_own_dynamic_prop, closure_has_own_dynamic_prop, closure_is_key_deleted, + closure_mark_key_deleted, closure_set_dynamic_prop, closure_set_static_prototype, + closure_static_prototype, is_closure_ptr, js_closure_unbind_this, + scan_closure_dynamic_props_roots_mut, }; // v8_stubs re-exports the AOT stubs + non-macOS Rust V8-interop stubs. diff --git a/crates/perry-runtime/src/closure/registry.rs b/crates/perry-runtime/src/closure/registry.rs index 14c3621c0b..6d75d015bf 100644 --- a/crates/perry-runtime/src/closure/registry.rs +++ b/crates/perry-runtime/src/closure/registry.rs @@ -60,6 +60,14 @@ thread_local! { static CLOSURE_ARROW_FUNCTION_REGISTRY: RefCell> = RefCell::new(crate::fast_hash::new_ptr_hash_map()); + /// Side-table marking closure body `func_ptr`s whose body is strict-mode + /// code (file-level `"use strict"` or a body directive). Drives + /// OrdinaryCallBindThis in `call`/`apply`/`bind`: a strict callee + /// observes the raw primitive `thisArg`; a sloppy user callee gets it + /// boxed once. + static CLOSURE_STRICT_FUNCTION_REGISTRY: RefCell> = + RefCell::new(crate::fast_hash::new_ptr_hash_map()); + /// Side-table marking closure body `func_ptr`s that came from async /// functions. `util.types.isAsyncFunction` uses this when the predicate /// sees a runtime closure value instead of a statically-known HIR node. @@ -326,6 +334,26 @@ pub fn is_registered_arrow_function(func_ptr: *const u8) -> bool { CLOSURE_ARROW_FUNCTION_REGISTRY.with(|r| r.borrow().contains_key(&(func_ptr as usize))) } +/// Register a compiled function address as strict-mode code. Emitted from +/// module init alongside the arrow-function registration. +#[no_mangle] +pub extern "C" fn js_register_closure_strict_function(func_ptr: *const u8) { + if func_ptr.is_null() { + return; + } + CLOSURE_STRICT_FUNCTION_REGISTRY.with(|r| { + r.borrow_mut().insert(func_ptr as usize, ()); + }); +} + +#[inline(always)] +pub fn is_registered_strict_function(func_ptr: *const u8) -> bool { + if func_ptr.is_null() { + return false; + } + CLOSURE_STRICT_FUNCTION_REGISTRY.with(|r| r.borrow().contains_key(&(func_ptr as usize))) +} + pub fn closure_is_arrow(closure: *const ClosureHeader) -> bool { let func_ptr = get_valid_func_ptr(closure); if func_ptr.is_null() { diff --git a/crates/perry-runtime/src/error.rs b/crates/perry-runtime/src/error.rs index 37ed45df6f..7377ba9f48 100644 --- a/crates/perry-runtime/src/error.rs +++ b/crates/perry-runtime/src/error.rs @@ -733,6 +733,31 @@ pub extern "C" fn js_throw_reference_error_unresolved_get() -> f64 { throw_reference_error_message(b"identifier is not defined") } +/// Read a compile-time-unresolved identifier off `globalThis` (a global the +/// program created dynamically — `Function("this.y = 2")()` — exists only at +/// runtime), throwing the spec ReferenceError when no such global property +/// exists. +#[no_mangle] +pub extern "C" fn js_global_get_or_throw_unresolved(name_value: f64) -> f64 { + let g = crate::object::js_get_global_this(); + let gj = crate::value::JSValue::from_bits(g.to_bits()); + if gj.is_pointer() { + let gptr = (gj.bits() & crate::value::POINTER_MASK) as *const crate::object::ObjectHeader; + let key = crate::builtins::js_string_coerce(name_value); + if !gptr.is_null() && !key.is_null() { + let v = unsafe { crate::object::js_object_get_field_by_name(gptr, key) }; + if !v.is_undefined() { + return f64::from_bits(v.bits()); + } + } + } + let name = value_to_lossy_string(name_value); + let msg = format!("{} is not defined", name); + let msg_str = js_string_from_bytes(msg.as_ptr(), msg.len() as u32); + let err_ptr = js_referenceerror_new(msg_str); + crate::exception::js_throw(crate::value::js_nanbox_pointer(err_ptr as i64)) +} + #[no_mangle] pub extern "C" fn js_throw_reference_error_unresolved_assignment() -> f64 { throw_reference_error_message(b"assignment to undeclared variable") diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index afc66befd6..876ec5d6d0 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -260,6 +260,24 @@ pub(crate) fn class_parent_closure(class_id: u32) -> Option { .and_then(|g| g.as_ref().and_then(|m| m.get(&class_id).copied())) } +/// Reverse lookup: which declared class's `.prototype` is this heap object? +/// Used by `Object.getOwnPropertyDescriptor(C.prototype, name)` to surface +/// vtable accessors as own properties of the prototype object. Linear scan — +/// the table is small (one entry per materialized declared-class prototype) +/// and this only runs on the reflection slow path. +pub(crate) fn class_id_for_decl_prototype_object(ptr: usize) -> Option { + if ptr == 0 { + return None; + } + CLASS_DECL_PROTOTYPE_OBJECTS + .read() + .ok()? + .as_ref()? + .iter() + .find(|(_, &p)| p == ptr) + .map(|(k, _)| *k) +} + pub(crate) fn class_decl_prototype_object(class_id: u32) -> *mut ObjectHeader { if let Ok(read) = CLASS_DECL_PROTOTYPE_OBJECTS.read() { if let Some(map) = read.as_ref() { @@ -1454,6 +1472,12 @@ pub unsafe extern "C" fn js_new_function_construct( if is_non_constructable_builtin_function_value(func_value) { throw_non_constructable_builtin_function(); } + // `new Function.prototype` — %Function.prototype% is callable but NOT a + // constructor (ECMA-262 20.2.3: "does not have a [[Construct]] internal + // method"). + if super::global_this::is_function_prototype_object_value(func_value) { + super::object_ops::throw_object_type_error(b"is not a constructor"); + } if let Some((module, method)) = bound_native_callable_module_and_method(func_value) { if module == "sqlite" && matches!( @@ -3286,6 +3310,89 @@ pub unsafe extern "C" fn js_register_class_method( VTABLE_GEN.fetch_add(1, Ordering::Release); } +/// Own (non-inherited) instance accessor func_ptrs for `class_id` + `name`: +/// `(getter_ptr, setter_ptr)`, each 0 when that half is absent. Consulted by +/// `Object.getOwnPropertyDescriptor(C.prototype, name)`. +pub(crate) fn class_own_accessor_ptrs(class_id: u32, name: &str) -> Option<(usize, usize)> { + let guard = CLASS_VTABLE_REGISTRY.read().ok()?; + let reg = guard.as_ref()?; + let vt = reg.get(&class_id)?; + let g = vt.getters.get(name).copied().unwrap_or(0); + let s = vt.setters.get(name).copied().unwrap_or(0); + if g == 0 && s == 0 { + None + } else { + Some((g, s)) + } +} + +/// Own static accessor func_ptrs for the class *constructor*. Mirrors +/// `class_own_accessor_ptrs` against `CLASS_STATIC_ACCESSORS`. +pub(crate) fn class_own_static_accessor_ptrs(class_id: u32, name: &str) -> Option<(usize, usize)> { + let guard = CLASS_STATIC_ACCESSORS.read().ok()?; + let reg = guard.as_ref()?; + let pair = reg.get(&class_id)?.get(name).copied()?; + if pair.0 == 0 && pair.1 == 0 { + None + } else { + Some(pair) + } +} + +/// Trampoline giving a raw vtable getter func_ptr (`fn(this) -> f64`) the +/// closure calling convention. The receiver comes from `IMPLICIT_THIS`, set +/// by the method-call dispatch the closure value travels through. +extern "C" fn class_accessor_getter_thunk(closure: *const crate::closure::ClosureHeader) -> f64 { + let raw = unsafe { crate::closure::js_closure_get_capture_ptr(closure, 0) } as usize; + if raw == 0 { + return f64::from_bits(crate::value::TAG_UNDEFINED); + } + let this = crate::object::js_implicit_this_get(); + let f: extern "C" fn(f64) -> f64 = unsafe { std::mem::transmute(raw) }; + f(this) +} + +/// Trampoline for a raw vtable setter func_ptr (`fn(this, value) -> f64`). +extern "C" fn class_accessor_setter_thunk( + closure: *const crate::closure::ClosureHeader, + value: f64, +) -> f64 { + let raw = unsafe { crate::closure::js_closure_get_capture_ptr(closure, 0) } as usize; + if raw == 0 { + return f64::from_bits(crate::value::TAG_UNDEFINED); + } + let this = crate::object::js_implicit_this_get(); + let f: extern "C" fn(f64, f64) -> f64 = unsafe { std::mem::transmute(raw) }; + f(this, value) +} + +/// Wrap a raw class accessor func_ptr as a callable function VALUE for +/// descriptor reflection (`Object.getOwnPropertyDescriptor(C.prototype, +/// "x").get`). Built-in-shaped: `.length` 0/1, no `.prototype`, native +/// `toString` form. +pub(crate) fn class_accessor_function_value(raw_ptr: usize, is_setter: bool) -> f64 { + if raw_ptr == 0 { + return f64::from_bits(crate::value::TAG_UNDEFINED); + } + let thunk = if is_setter { + class_accessor_setter_thunk as *const u8 + } else { + class_accessor_getter_thunk as *const u8 + }; + let closure = crate::closure::js_closure_alloc(thunk, 1); + if closure.is_null() { + return f64::from_bits(crate::value::TAG_UNDEFINED); + } + unsafe { crate::closure::js_closure_set_capture_ptr(closure, 0, raw_ptr as i64) }; + super::native_module::set_builtin_closure_length( + closure as usize, + if is_setter { 1 } else { 0 }, + ); + super::native_module::set_builtin_closure_non_constructable(closure as usize); + crate::gc::runtime_write_barrier_root_heap_word(closure as u64); + crate::value::js_nanbox_pointer(closure as i64) +} + /// Register a class getter in the vtable registry. #[no_mangle] pub unsafe extern "C" fn js_register_class_getter( diff --git a/crates/perry-runtime/src/object/descriptors.rs b/crates/perry-runtime/src/object/descriptors.rs index 877e0e7f95..0ce413d2a0 100644 --- a/crates/perry-runtime/src/object/descriptors.rs +++ b/crates/perry-runtime/src/object/descriptors.rs @@ -308,6 +308,23 @@ pub extern "C" fn js_object_get_own_property_descriptor(obj_value: f64, key_valu ); } } + // Class accessors reflect as accessor descriptors: instance + // `get x(){}` is an own property of `C.prototype`, a static + // accessor an own property of `C` itself. The raw vtable + // func_ptrs are wrapped as callable function values. + let accessor = if super::class_prototype_ref_id(obj_value).is_some() { + super::class_registry::class_own_accessor_ptrs(class_id, &method_name) + } else { + super::class_registry::class_own_static_accessor_ptrs(class_id, &method_name) + }; + if let Some((g, s)) = accessor { + return build_accessor_descriptor( + super::class_registry::class_accessor_function_value(g, false), + super::class_registry::class_accessor_function_value(s, true), + false, + true, + ); + } if method_name == "constructor" || class_has_own_method(class_id, &method_name) { let value = if method_name == "constructor" && super::class_prototype_ref_id(obj_value).is_some() @@ -691,6 +708,22 @@ pub extern "C" fn js_object_get_own_property_descriptor(obj_value: f64, key_valu } } + // A declared class's materialized `.prototype` object: instance + // accessors (`get x(){}`) live in the class vtable, not the object's + // fields, but they ARE own properties of the prototype. + if let Some(cid) = super::class_registry::class_id_for_decl_prototype_object(obj as usize) { + if let Some(ref name) = key_rust { + if let Some((g, s)) = super::class_registry::class_own_accessor_ptrs(cid, name) { + return build_accessor_descriptor( + super::class_registry::class_accessor_function_value(g, false), + super::class_registry::class_accessor_function_value(s, true), + false, + true, + ); + } + } + } + // Check whether the key is actually present on the object. A property can // legitimately hold `undefined`, and accessor descriptors have no value slot, // so we check the keys_array directly instead of relying on "value != undefined". diff --git a/crates/perry-runtime/src/object/field_set_by_name.rs b/crates/perry-runtime/src/object/field_set_by_name.rs index af2d567d65..087cccec56 100644 --- a/crates/perry-runtime/src/object/field_set_by_name.rs +++ b/crates/perry-runtime/src/object/field_set_by_name.rs @@ -499,17 +499,15 @@ pub extern "C" fn js_object_set_field_by_name( let name_len = (*key).byte_len as usize; let name_bytes = std::slice::from_raw_parts(name_ptr, name_len); if let Ok(name_str) = std::str::from_utf8(name_bytes) { - // ECMAScript poison pill: `fn.caller = v` / `fn.arguments - // = v` hits the setter-less %ThrowTypeError% accessor on - // Function.prototype — a TypeError in strict mode (all - // Perry-compiled code). Applies to every closure, not just - // arrows. An explicitly defined own property (via - // Object.defineProperty → dynamic-prop side table) still - // wins, matching the read path. Refs test262 13.2-*-s. + // ECMAScript "poison pill" — assigning `caller`/`arguments` + // on any strict-mode function (Perry compiles everything + // strict: declarations, expressions, bound and built-in + // closures, arrows) throws via the %ThrowTypeError% + // accessor's missing setter. A genuine own data prop of + // that name (defineProperty round-trip) still wins. + // Refs test262 13.2-*-s / StrictFunction_restricted-*. if matches!(name_str, "caller" | "arguments") - && crate::closure::closure_get_dynamic_prop(obj as usize, name_str) - .to_bits() - == crate::value::TAG_UNDEFINED + && !crate::closure::closure_has_own_dynamic_prop(obj as usize, name_str) { crate::fs::validate::throw_type_error_with_code( "Restricted function property assignment", @@ -539,17 +537,15 @@ pub extern "C" fn js_object_set_field_by_name( let name_len = (*key).byte_len as usize; let name_bytes = std::slice::from_raw_parts(name_ptr, name_len); if let Ok(name_str) = std::str::from_utf8(name_bytes) { - // ECMAScript poison pill: `fn.caller = v` / `fn.arguments - // = v` hits the setter-less %ThrowTypeError% accessor on - // Function.prototype — a TypeError in strict mode (all - // Perry-compiled code). Applies to every closure, not just - // arrows. An explicitly defined own property (via - // Object.defineProperty → dynamic-prop side table) still - // wins, matching the read path. Refs test262 13.2-*-s. + // ECMAScript "poison pill" — assigning `caller`/`arguments` + // on any strict-mode function (Perry compiles everything + // strict: declarations, expressions, bound and built-in + // closures, arrows) throws via the %ThrowTypeError% + // accessor's missing setter. A genuine own data prop of + // that name (defineProperty round-trip) still wins. + // Refs test262 13.2-*-s / StrictFunction_restricted-*. if matches!(name_str, "caller" | "arguments") - && crate::closure::closure_get_dynamic_prop(obj as usize, name_str) - .to_bits() - == crate::value::TAG_UNDEFINED + && !crate::closure::closure_has_own_dynamic_prop(obj as usize, name_str) { crate::fs::validate::throw_type_error_with_code( "Restricted function property assignment", diff --git a/crates/perry-runtime/src/object/global_this.rs b/crates/perry-runtime/src/object/global_this.rs index 10efeba255..55934a39e4 100644 --- a/crates/perry-runtime/src/object/global_this.rs +++ b/crates/perry-runtime/src/object/global_this.rs @@ -17,6 +17,34 @@ thread_local! { static THREAD_GLOBAL_THIS: std::cell::Cell = const { std::cell::Cell::new(0) }; } +thread_local! { + /// Module top-level `this` (Node-CJS `module.exports` stand-in) — a + /// lazily-allocated plain object distinct from `globalThis`. See + /// `Expr::ModuleTopThis`. + static THREAD_MODULE_TOP_THIS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// `this` in module top-level code. Node runs files as CommonJS where +/// top-level `this` is `module.exports`: a fresh ordinary object, NOT the +/// global. One object per thread (Perry links the whole program into one +/// binary; the test corpus is single-module). +#[no_mangle] +pub extern "C" fn js_module_top_this() -> f64 { + let cached = THREAD_MODULE_TOP_THIS.with(|c| c.get()); + if cached != 0 { + return f64::from_bits(cached); + } + let obj = super::alloc::js_object_alloc(0, 0); + let val = crate::value::js_nanbox_pointer(obj as i64); + THREAD_MODULE_TOP_THIS.with(|c| c.set(val.to_bits())); + // Keep it alive across GCs — the cell is a raw bits cache, not a scanned + // root, so register the slot address as a global root once. + crate::gc::runtime_write_barrier_root_heap_word(obj as u64); + let slot = THREAD_MODULE_TOP_THIS.with(|c| c.as_ptr() as usize); + crate::gc::js_gc_register_global_root(slot as i64); + val +} + /// Issue #611: lazily allocate `globalThis` for computed global access. #[no_mangle] pub extern "C" fn js_get_global_this() -> f64 { @@ -623,6 +651,21 @@ pub(crate) extern "C" fn uri_error_constructor_call_thunk( error_constructor_call(crate::error::ERROR_KIND_URI_ERROR, message) } +/// Whether `value` is the %Function.prototype% intrinsic object. It is the +/// one ordinary-object-shaped value that is itself a Function: callable +/// (returns `undefined`), tagged `[object Function]`, but NOT a constructor. +/// Only consulted on slow paths (failed call dispatch, `Object.prototype. +/// toString`), so the per-call re-resolution through the global registry is +/// fine — and safer than caching a raw pointer across GC cycles. +pub(crate) fn is_function_prototype_object_value(value: f64) -> bool { + let jv = JSValue::from_bits(value.to_bits()); + if !jv.is_pointer() { + return false; + } + let proto = builtin_prototype_value("Function"); + proto.to_bits() == value.to_bits() +} + pub(crate) fn builtin_prototype_value(name: &str) -> f64 { let ctor = js_get_global_this_builtin_value(name.as_ptr(), name.len()); let ctor_bits = ctor.to_bits(); @@ -1198,6 +1241,7 @@ extern "C" fn function_prototype_call_thunk( } else { (args.as_ptr(), args.len()) }; + let this_arg = crate::closure::coerce_call_this(target, this_arg); let prev_this = IMPLICIT_THIS.with(|c| c.replace(this_arg.to_bits())); let result = unsafe { crate::closure::js_native_call_value(target, args_ptr, args_len) }; IMPLICIT_THIS.with(|c| c.set(prev_this)); @@ -1544,6 +1588,7 @@ extern "C" fn function_prototype_apply_thunk( unsafe { let target = f64::from_bits(IMPLICIT_THIS.with(|c| c.get())); let args = function_apply_args(args_array); + let this_arg = crate::closure::coerce_call_this(target, this_arg); let prev_this = IMPLICIT_THIS.with(|c| c.replace(this_arg.to_bits())); let result = crate::closure::js_native_call_value(target, args.as_ptr(), args.len()); IMPLICIT_THIS.with(|c| c.set(prev_this)); @@ -1570,6 +1615,27 @@ extern "C" fn function_prototype_to_string_thunk( 0 }; if raw == 0 || !crate::closure::is_closure_ptr(raw) { + // A Proxy whose target is callable is itself callable; its source is + // never introspectable, so the spec mandates the NativeFunction form. + let this_val = f64::from_bits(this_bits); + if crate::proxy::js_proxy_is_proxy(this_val) == 1 + && crate::proxy::proxy_wraps_callable(this_val) + { + let s = "function () { [native code] }"; + let str_ptr = crate::string::js_string_from_bytes(s.as_ptr(), s.len() as u32); + return f64::from_bits(JSValue::string_ptr(str_ptr).bits()); + } + // A class reference (INT32-tagged registered class id) is a function + // value; Perry retains no class source, so emit the NativeFunction + // form with the class name. + if super::class_prototype_ref_id(this_val).is_none() { + if let Some(cid) = super::native_module::class_ref_id(this_val) { + let name = super::class_registry::class_name_for_id(cid).unwrap_or_default(); + let s = format!("function {name}() {{ [native code] }}"); + let str_ptr = crate::string::js_string_from_bytes(s.as_ptr(), s.len() as u32); + return f64::from_bits(JSValue::string_ptr(str_ptr).bits()); + } + } super::object_ops::throw_object_type_error( b"Function.prototype.toString requires that 'this' be a Function", ); diff --git a/crates/perry-runtime/src/object/has_own_helpers.rs b/crates/perry-runtime/src/object/has_own_helpers.rs index cf5fb5ae9d..c051d3271a 100644 --- a/crates/perry-runtime/src/object/has_own_helpers.rs +++ b/crates/perry-runtime/src/object/has_own_helpers.rs @@ -21,8 +21,18 @@ pub(crate) fn closure_own_key_present(ptr: usize, key: &str) -> bool { match key { // Always-present built-in function slots. "name" | "length" => true, - // `prototype` and user props are real own dynamic props (constructors - // stash `prototype` in the side table; methods/arrows have neither). + // A constructor-capable function's `.prototype` is an own property + // from birth even though Perry materializes the object lazily — + // `f.hasOwnProperty('prototype')` must be true BEFORE any read of + // `f.prototype`. The for-read helper materializes (idempotently) and + // returns None for arrows/builtins, which really have no own slot. + "prototype" => { + crate::closure::closure_has_own_dynamic_prop(ptr, key) || { + let val = crate::value::js_nanbox_pointer(ptr as i64); + super::class_registry::ordinary_function_prototype_value_for_read(val).is_some() + } + } + // User props are real own dynamic props in the side table. _ => crate::closure::closure_has_own_dynamic_prop(ptr, key), } } diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 0d98cf0454..c5b71cc615 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -1989,7 +1989,11 @@ pub unsafe extern "C" fn js_object_to_string(value: f64) -> f64 { } if (raw_addr >= 0x10000 && crate::closure::is_closure_ptr(raw_addr)) || crate::object::is_class_object_ptr(raw_addr as *const u8) + || is_function_prototype_object_value(value) { + // %Function.prototype% is itself a (callable) Function object, so + // `Object.prototype.toString.call(Function.prototype)` is + // "[object Function]" even though Perry stores it as a plain object. let bytes = b"[object Function]"; let str_ptr = crate::string::js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32); return f64::from_bits(STRING_TAG | (str_ptr as u64 & POINTER_MASK)); diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index 630eb0e960..62639c2f40 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -1427,6 +1427,17 @@ pub unsafe extern "C" fn js_native_call_method( IMPLICIT_THIS.with(|c| c.set(prev_this)); return result; } + // `fn.length()` / `fn.name()` — the own slots hold a number / + // string, never a callable; calling one is a TypeError + // (`f.length is not a function`), not a read. + if matches!(method_name, "length" | "name") { + crate::error::js_throw_type_error_not_a_function( + std::ptr::null(), + 0, + method_name.as_ptr(), + method_name.len(), + ); + } } } @@ -4298,7 +4309,7 @@ pub unsafe extern "C" fn js_native_call_method( let raw_ptr = (object.to_bits() & 0x0000_FFFF_FFFF_FFFF) as usize; if crate::closure::is_closure_ptr(raw_ptr) { let this_arg = if args_len >= 1 && !args_ptr.is_null() { - *args_ptr + crate::closure::coerce_call_this(object, *args_ptr) } else { f64::from_bits(crate::value::TAG_UNDEFINED) }; @@ -4353,7 +4364,7 @@ pub unsafe extern "C" fn js_native_call_method( let raw_ptr = (object.to_bits() & 0x0000_FFFF_FFFF_FFFF) as usize; if crate::closure::is_closure_ptr(raw_ptr) { let this_arg = if args_len >= 1 && !args_ptr.is_null() { - *args_ptr + crate::closure::coerce_call_this(object, *args_ptr) } else { f64::from_bits(crate::value::TAG_UNDEFINED) }; @@ -4363,22 +4374,34 @@ pub unsafe extern "C" fn js_native_call_method( f64::from_bits(crate::value::TAG_UNDEFINED) }; let args_arr_jsval = JSValue::from_bits(args_arr_val.to_bits()); - let buf: Vec = if args_arr_jsval.is_pointer() { - let raw_ptr = (args_arr_val.to_bits() & 0x0000_FFFF_FFFF_FFFF) as usize; + // The argArray may arrive NaN-boxed (POINTER_TAG) or as a + // legacy RAW i64 pointer bit-cast to f64 (a function's + // synthetic `arguments` array local) — top 16 bits zero. + let args_arr_bits = args_arr_val.to_bits(); + let arr_raw: usize = if args_arr_jsval.is_pointer() { + (args_arr_bits & 0x0000_FFFF_FFFF_FFFF) as usize + } else if (args_arr_bits >> 48) == 0 && args_arr_bits >= 0x1000 { + args_arr_bits as usize + } else { + 0 + }; + // Spec CreateListFromArrayLike: a non-nullish, non-object + // argArray (`fn.apply(null, true)` / `NaN` / `'1,2,3'`) is a + // TypeError. null/undefined mean "no arguments". + if arr_raw == 0 && !args_arr_jsval.is_undefined() && !args_arr_jsval.is_null() { + throw_type_error_message(b"CreateListFromArrayLike called on non-object"); + } + let buf: Vec = if arr_raw != 0 { if let Some(values) = crate::object::arguments_object_to_vec( - raw_ptr as *const crate::object::ObjectHeader, + arr_raw as *const crate::object::ObjectHeader, ) { values } else { - let arr_ptr = raw_ptr as *const crate::array::ArrayHeader; - if arr_ptr.is_null() { - Vec::new() - } else { - let n = crate::array::js_array_length(arr_ptr) as usize; - (0..n) - .map(|i| crate::array::js_array_get_f64(arr_ptr, i as u32)) - .collect() - } + let arr_ptr = arr_raw as *const crate::array::ArrayHeader; + let n = crate::array::js_array_length(arr_ptr) as usize; + (0..n) + .map(|i| crate::array::js_array_get_f64(arr_ptr, i as u32)) + .collect() } } else { Vec::new() @@ -4403,6 +4426,19 @@ pub unsafe extern "C" fn js_native_call_method( // Common string methods on string values "toString" => { + // A class REFERENCE (INT32-tagged registered class id) is a + // function value: `C.toString()` must produce function source, + // not the numeric rendering of its class id ("1"). Perry doesn't + // retain class source text, so emit the NativeFunction form — + // Test262's assertToStringOrNativeFunction accepts it. + if super::class_prototype_ref_id(object).is_none() { + if let Some(cid) = super::native_module::class_ref_id(object) { + let name = super::class_registry::class_name_for_id(cid).unwrap_or_default(); + let s = format!("function {name}() {{ [native code] }}"); + let str_ptr = crate::string::js_string_from_bytes(s.as_ptr(), s.len() as u32); + return f64::from_bits(JSValue::string_ptr(str_ptr).bits()); + } + } if let Some((_, payload)) = crate::builtins::boxed_primitive_payload(object) { let payload_jsv = JSValue::from_bits(payload.to_bits()); match crate::builtins::boxed_primitive_to_string_tag(object) { diff --git a/crates/perry-runtime/src/proxy.rs b/crates/perry-runtime/src/proxy.rs index b73c96e4f4..ba8bb5723c 100644 --- a/crates/perry-runtime/src/proxy.rs +++ b/crates/perry-runtime/src/proxy.rs @@ -244,6 +244,30 @@ pub(crate) fn is_array_proxy_step(value: f64) -> Option { Some(target) } +/// Whether a Proxy value's (possibly nested) [[ProxyTarget]] is callable — +/// the predicate behind `typeof proxyOfFn === "function"` and +/// `Function.prototype.toString` accepting a proxy receiver. A revoked +/// proxy's recorded target is retained, so callability survives revocation +/// (per spec, `typeof` of a revoked proxy is unchanged). +pub(crate) fn proxy_wraps_callable(value: f64) -> bool { + let mut v = value; + for _ in 0..32 { + match lookup(v) { + Some(id) => { + v = PROXIES.with(|p| { + p.borrow() + .get(id as usize) + .and_then(|o| o.as_ref()) + .map(|e| e.target) + .unwrap_or(f64::from_bits(TAG_UNDEFINED)) + }); + } + None => return crate::object::value_is_callable(v), + } + } + false +} + /// Return the proxy's target (for Proxy.revocable.proxy revocation checks). #[no_mangle] pub extern "C" fn js_proxy_target(proxy_boxed: f64) -> f64 { diff --git a/crates/perry-runtime/src/string/concat.rs b/crates/perry-runtime/src/string/concat.rs index b29a193ef0..006a0c65a1 100644 --- a/crates/perry-runtime/src/string/concat.rs +++ b/crates/perry-runtime/src/string/concat.rs @@ -519,8 +519,10 @@ pub extern "C" fn js_string_concat_chain(parts: *const f64, n: i32) -> *mut Stri continue; } - // INT32_TAG = 0x7FFE — extract int from lower 32 bits. - if tag == 0x7FFE { + // INT32_TAG = 0x7FFE — extract int from lower 32 bits. A registered + // class id (Expr::ClassRef) stringifies via the slow path so it + // renders as function source, not its numeric id. + if tag == 0x7FFE && !crate::object::is_class_id_registered((bits & 0xFFFF_FFFF) as u32) { let v = (bits & 0xFFFF_FFFF) as u32 as i32; let len = if v >= 0 { fast_itoa_u32(v as u32, &mut num_bufs[i]) diff --git a/crates/perry-runtime/src/value/dynamic_arith.rs b/crates/perry-runtime/src/value/dynamic_arith.rs index 49d4993450..116ecaac5c 100644 --- a/crates/perry-runtime/src/value/dynamic_arith.rs +++ b/crates/perry-runtime/src/value/dynamic_arith.rs @@ -79,6 +79,16 @@ unsafe fn to_primitive_default_for_add(value: f64) -> f64 { return value; } + // A Proxy is a small registered id, not a heap object — the ToPrimitive + // machinery below dereferences the fake pointer and segfaults + // (`"" + new Proxy(fn, {})`). A trap-less default ToPrimitive forwards + // to the target; a callable target stringifies via + // Function.prototype.toString (the NativeFunction form). + if crate::proxy::js_proxy_is_proxy(value) == 1 { + let s = crate::value::js_jsvalue_to_string(value); + return crate::value::js_nanbox_string(s as i64); + } + let primitive = crate::symbol::js_to_primitive(value, 0); if primitive.to_bits() != value.to_bits() { if is_nonprimitive_object_value(primitive) { diff --git a/crates/perry-runtime/src/value/to_string.rs b/crates/perry-runtime/src/value/to_string.rs index 3d093cbb05..d7a7454425 100644 --- a/crates/perry-runtime/src/value/to_string.rs +++ b/crates/perry-runtime/src/value/to_string.rs @@ -521,8 +521,17 @@ pub extern "C" fn js_jsvalue_to_string(value: f64) -> *mut crate::string::String crate::string::js_string_from_bytes(b"false".as_ptr(), 5) } } else if jsval.is_int32() { - // Convert int32 to string + // A registered class id shares the INT32 encoding (`Expr::ClassRef`) + // — `String(C)` / `"" + C` must produce function source, not the + // numeric id. Perry keeps no class source, so the NativeFunction + // form with the class name. let n = jsval.as_int32(); + let cid = (value.to_bits() & 0xFFFF_FFFF) as u32; + if crate::object::is_class_id_registered(cid) { + let name = crate::object::class_name_for_id(cid).unwrap_or_default(); + let s = format!("function {name}() {{ [native code] }}"); + return crate::string::js_string_from_bytes(s.as_ptr(), s.len() as u32); + } let s = n.to_string(); crate::string::js_string_from_bytes(s.as_ptr(), s.len() as u32) } else if jsval.is_bigint() { @@ -534,6 +543,19 @@ pub extern "C" fn js_jsvalue_to_string(value: f64) -> *mut crate::string::String // stringify via `Array.prototype.join(",")` per JS semantics; other // objects fall back to "[object Object]". let ptr: *const u8 = jsval.as_pointer(); + // Proxy ids can be SMALLER than the 0x10000 heap floor — check the + // registry (a by-value lookup, no deref) before the gate. + if crate::proxy::js_proxy_is_proxy(value) != 0 { + if crate::proxy::proxy_wraps_callable(value) { + let s = "function () { [native code] }"; + return crate::string::js_string_from_bytes(s.as_ptr(), s.len() as u32); + } + let target = crate::proxy::js_proxy_target(value); + if target.to_bits() != value.to_bits() { + return js_jsvalue_to_string(target); + } + return crate::string::js_string_from_bytes(b"[object Object]".as_ptr(), 15); + } if !ptr.is_null() && (ptr as usize) >= 0x10000 { // A Proxy is a small registered id, not a heap object — the GC-header // probes / ToPrimitive dispatch below would deref the fake pointer @@ -542,6 +564,15 @@ pub extern "C" fn js_jsvalue_to_string(value: f64) -> *mut crate::string::String // stringify that ("[object Object]" for an ordinary object target), // which matches Node for the trap-less case. (Proxy crash cluster.) if crate::proxy::js_proxy_is_proxy(value) != 0 { + // A callable-target proxy's default ToString runs + // Function.prototype.toString with the PROXY as receiver — + // never introspectable, so the NativeFunction form (matches + // Node: `String(new Proxy(fn, {}))`). Non-callable targets + // resolve through the target. + if crate::proxy::proxy_wraps_callable(value) { + let s = "function () { [native code] }"; + return crate::string::js_string_from_bytes(s.as_ptr(), s.len() as u32); + } let target = crate::proxy::js_proxy_target(value); if target.to_bits() != value.to_bits() { return js_jsvalue_to_string(target); From cf57f7dab3350e48753a2869a21e66df53631414 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 9 Jun 2026 23:16:35 +0200 Subject: [PATCH 2/4] =?UTF-8?q?fix(runtime,hir):=20Function=20parity=20v2?= =?UTF-8?q?=20rounds=209-11=20=E2=80=94=20proxy=20ToString/ToPrimitive=20+?= =?UTF-8?q?=20js=5Fis=5Fsymbol=20floor,=20apply(this,arguments)=20raw=20ar?= =?UTF-8?q?gArray=20+=20arguments-object=20unpack,=20arg-expression=20cons?= =?UTF-8?q?t-eval,=20fn-decl=20.constructor=20kinds,=20PutValue=20caller?= =?UTF-8?q?=20poison,=20class-ref=20stringification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/perry-hir/src/lower/const_fold_fn.rs | 6 ++ crates/perry-hir/src/lower/fn_ctor_env.rs | 74 +++++++++++++++++++ .../perry-runtime/src/object/global_this.rs | 8 ++ .../src/object/native_call_method.rs | 6 ++ crates/perry-runtime/src/proxy.rs | 13 ++++ crates/perry-runtime/src/symbol.rs | 8 +- 6 files changed, 114 insertions(+), 1 deletion(-) diff --git a/crates/perry-hir/src/lower/const_fold_fn.rs b/crates/perry-hir/src/lower/const_fold_fn.rs index 2eb8af1b21..1f919fbcba 100644 --- a/crates/perry-hir/src/lower/const_fold_fn.rs +++ b/crates/perry-hir/src/lower/const_fold_fn.rs @@ -482,6 +482,12 @@ fn resolve_fn_ctor_arg( FnCtorShape::DynCtor(_) | FnCtorShape::FnLiteral(_) => None, }; } + // A constant EXPRESSION over env entries — `Function(p + "," + p, + // …)` where `p` is a recorded toString object. The partial + // evaluator runs the toStrings (counters, side effects) in order. + if let Some(v) = super::fn_ctor_env::eval_arg_expr(&mut ctx.fn_ctor_env, e) { + return Some(ResolvedArg::Str(v)); + } } None } diff --git a/crates/perry-hir/src/lower/fn_ctor_env.rs b/crates/perry-hir/src/lower/fn_ctor_env.rs index 258aa326dd..48e7bbb0dc 100644 --- a/crates/perry-hir/src/lower/fn_ctor_env.rs +++ b/crates/perry-hir/src/lower/fn_ctor_env.rs @@ -269,6 +269,52 @@ pub(crate) fn build_fn_ctor_env(module: &ast::Module) -> FnCtorEnv { } } } + // Top-level function DECLARATIONS too (`async function f() {}` then + // `f.constructor`). The declaration itself counted as the name's one + // write; any further write disqualifies. + fn collect_fn_decl_kinds( + stmts: &[ast::Stmt], + decls: &HashMap)>, + writes: &HashMap, + out: &mut HashMap, + ) { + for stmt in stmts { + match stmt { + ast::Stmt::Decl(ast::Decl::Fn(f)) => { + let name = f.ident.sym.to_string(); + if writes.get(&name).copied().unwrap_or(0) == 1 && !decls.contains_key(&name) { + let kind = match (f.function.is_async, f.function.is_generator) { + (false, false) => DynFnCtorKind::Plain, + (true, false) => DynFnCtorKind::Async, + (false, true) => DynFnCtorKind::Generator, + (true, true) => DynFnCtorKind::AsyncGenerator, + }; + out.insert(name, kind); + } + } + ast::Stmt::Block(b) => collect_fn_decl_kinds(&b.stmts, decls, writes, out), + ast::Stmt::Try(t) => { + collect_fn_decl_kinds(&t.block.stmts, decls, writes, out); + if let Some(h) = &t.handler { + collect_fn_decl_kinds(&h.body.stmts, decls, writes, out); + } + if let Some(fin) = &t.finalizer { + collect_fn_decl_kinds(&fin.stmts, decls, writes, out); + } + } + _ => {} + } + } + } + let top_stmts: Vec = module + .body + .iter() + .filter_map(|item| match item { + ast::ModuleItem::Stmt(stmt) => Some(stmt.clone()), + _ => None, + }) + .collect(); + collect_fn_decl_kinds(&top_stmts, &decls, &writes, &mut fn_literal_vars); for (name, (decl_count, init)) in &decls { if *decl_count != 1 { @@ -532,6 +578,17 @@ pub(crate) fn eval_tostring(env: &mut FnCtorEnv, body: &ToStringBody) -> Option< } } +/// Evaluate a whole `Function(...)` ARGUMENT expression (Bin-add chains over +/// env entries) to the string ToString would produce. +pub(crate) fn eval_arg_expr(env: &mut FnCtorEnv, expr: &ast::Expr) -> Option { + // Only composite expressions — bare idents/literals are handled (with + // throw support) by the caller. + if !matches!(expr, ast::Expr::Bin(_)) { + return None; + } + eval_const_expr(env, expr).map(|v| v.to_js_string()) +} + fn eval_const_expr(env: &mut FnCtorEnv, expr: &ast::Expr) -> Option { if let Some(v) = literal_const_val(expr) { return Some(v); @@ -546,6 +603,23 @@ fn eval_const_expr(env: &mut FnCtorEnv, expr: &ast::Expr) -> Option { match env.entries.get(&name) { Some(FnCtorShape::Str(s)) => Some(ConstVal::Str(s.clone())), Some(FnCtorShape::UndefinedVar) => Some(ConstVal::Undefined), + // An object-with-toString var inside a larger expression + // (`Function(p + "," + p, …)`) — ToString runs its body, + // counters and all. Throwing bodies bail (conservative). + Some(FnCtorShape::ObjToString(body)) => { + let body = body.clone(); + if body.is_throw { + return None; + } + let v = eval_const_expr(env, &body.expr)?; + env.pending_side_effects + .extend(body.assigns.iter().cloned()); + for name in &body.poisoned { + env.entries.remove(name); + env.counters.remove(name); + } + Some(ConstVal::Str(v.to_js_string())) + } _ => None, } } diff --git a/crates/perry-runtime/src/object/global_this.rs b/crates/perry-runtime/src/object/global_this.rs index 55934a39e4..fe5e2f515e 100644 --- a/crates/perry-runtime/src/object/global_this.rs +++ b/crates/perry-runtime/src/object/global_this.rs @@ -1556,6 +1556,14 @@ unsafe fn function_apply_args(args_array: f64) -> Vec { if value.is_undefined() || value.is_null() { return Vec::new(); } + // An arguments OBJECT is array-like but fails the IsArray check below — + // unpack it via its registry (`fn.apply(this, arguments)`). + if value.is_pointer() { + let raw = (value.bits() & crate::value::POINTER_MASK) as usize; + if let Some(values) = super::arguments_object_to_vec(raw as *const super::ObjectHeader) { + return values; + } + } let is_array = JSValue::from_bits(crate::array::js_array_is_array(args_array).to_bits()); if !is_array.is_bool() || !is_array.as_bool() { return Vec::new(); diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index 62639c2f40..77a5fa7cad 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -1419,6 +1419,12 @@ pub unsafe extern "C" fn js_native_call_method( if raw_addr >= 0x100000 && crate::closure::is_closure_ptr(raw_addr) && !crate::closure::closure_is_key_deleted(raw_addr, method_name) + // apply/call/bind/toString on a closure receiver have dedicated + // spec-accurate arms below; the dynamic-prop read would resolve + // them through the Function.prototype expando fallback to the + // GENERIC thunks, which lose arguments-object argArrays + // (`G.apply(this, arguments)`). + && !matches!(method_name, "apply" | "call" | "bind" | "toString") { let dyn_val = crate::closure::closure_get_dynamic_prop(raw_addr, method_name); if dyn_val.to_bits() != crate::value::TAG_UNDEFINED { diff --git a/crates/perry-runtime/src/proxy.rs b/crates/perry-runtime/src/proxy.rs index ba8bb5723c..7601feb33e 100644 --- a/crates/perry-runtime/src/proxy.rs +++ b/crates/perry-runtime/src/proxy.rs @@ -1066,6 +1066,19 @@ fn ordinary_set_with_receiver(target: f64, key: f64, value: f64, receiver: f64) }; } if crate::closure::is_closure_ptr(extract_pointer(current.to_bits()) as usize) { + // ECMAScript poison pill: `fn.caller = v` / `fn.arguments = v` on + // a strict-mode function (all Perry-compiled code) throws via the + // %ThrowTypeError% accessor's absent setter. A genuine own data + // prop (defineProperty round-trip) still wins via the descriptor + // arm above. + let cur_ptr = extract_pointer(current.to_bits()) as usize; + if let Some(name) = key_to_rust_string(key) { + if matches!(name.as_str(), "caller" | "arguments") + && !crate::closure::closure_has_own_dynamic_prop(cur_ptr, &name) + { + throw_type_error("Restricted function property assignment"); + } + } return create_or_update_receiver_property(receiver, key, value); } let Some(proto) = prototype_of_for_set(current) else { diff --git a/crates/perry-runtime/src/symbol.rs b/crates/perry-runtime/src/symbol.rs index 0ea776f0f5..5807a9828b 100644 --- a/crates/perry-runtime/src/symbol.rs +++ b/crates/perry-runtime/src/symbol.rs @@ -263,7 +263,13 @@ pub unsafe extern "C" fn js_is_symbol(value: f64) -> i32 { return 1; } let ptr = ptr_usize as *const SymbolHeader; - if ptr.is_null() || (ptr as usize) < 0x1000 { + // Registry handles (proxies, fetch/stream handles, …) are POINTER_TAG'd + // small ids, NOT heap allocations — dereferencing one for the magic + // probe segfaults on Linux (unmapped page; mimalloc on macOS happens to + // retain, hiding it). Real heap symbols live above the 0x100000 floor + // (same rationale as the typeof / iterator guards, #1843/#4800), and + // registered symbols already returned above. + if ptr.is_null() || (ptr as usize) < 0x100000 { return 0; } if (*ptr).magic == SYMBOL_MAGIC { From e7f840e95cc5b1a9d921a766216bb25b5bd1c29a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 9 Jun 2026 23:28:06 +0200 Subject: [PATCH 3/4] =?UTF-8?q?fix(runtime,hir):=20Function=20parity=20v2?= =?UTF-8?q?=20round=209-11=20=E2=80=94=20proxy=20ToString/ToPrimitive=20+?= =?UTF-8?q?=20js=5Fis=5Fsymbol=20floor,=20apply(this,arguments)=20raw=20ar?= =?UTF-8?q?gArray=20+=20arguments-object=20unpack,=20arg-expression=20cons?= =?UTF-8?q?t-eval=20(p+","+p),=20fn-decl=20.constructor=20kinds,=20PutValu?= =?UTF-8?q?e=20caller=20poison,=20class-ref=20stringification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/perry-runtime/src/proxy.rs | 362 +----------------- .../perry-runtime/src/proxy/reflect_misc.rs | 358 +++++++++++++++++ 2 files changed, 368 insertions(+), 352 deletions(-) create mode 100644 crates/perry-runtime/src/proxy/reflect_misc.rs diff --git a/crates/perry-runtime/src/proxy.rs b/crates/perry-runtime/src/proxy.rs index 7601feb33e..4266352911 100644 --- a/crates/perry-runtime/src/proxy.rs +++ b/crates/perry-runtime/src/proxy.rs @@ -28,6 +28,13 @@ mod metadata; mod own_keys; mod prototype; mod reflect; +mod reflect_misc; +pub(crate) use reflect_misc::js_proxy_get_prototype_of; +pub use reflect_misc::{ + js_reflect_apply, js_reflect_construct, js_reflect_define_property, + js_reflect_get_prototype_of, js_reflect_is_extensible, js_reflect_own_keys, + js_reflect_prevent_extensions, +}; pub use own_keys::js_proxy_own_keys; pub(crate) use own_keys::{ @@ -956,7 +963,7 @@ fn prototype_of_for_set(value: f64) -> Option { // (no-trap → the target's prototype) instead. Returns `None` for a null / // self prototype, matching the heap-object handling below. if lookup(value).is_some() { - let proto = proxy_get_prototype_of_impl(value); + let proto = reflect_misc::proxy_get_prototype_of_impl(value); let proto_bits = proto.to_bits(); return if proto_bits == TAG_NULL || proto_bits == TAG_UNDEFINED @@ -1538,355 +1545,6 @@ pub extern "C" fn js_proxy_construct(proxy_boxed: f64, args_array: f64, new_targ result } -fn array_from_args(args: &[f64]) -> f64 { - let arr = crate::array::js_array_alloc(0); - let mut a = arr; - for &arg in args { - a = crate::array::js_array_push_f64(a, arg); - } - f64::from_bits(POINTER_TAG | ((a as u64) & POINTER_MASK)) -} - -#[no_mangle] -pub extern "C" fn js_reflect_construct(target: f64, args_like: f64, new_target: f64) -> f64 { - if !is_constructor_function(target) { - return throw_type_error("target is not a constructor"); - } - let nt = if new_target.to_bits() == TAG_UNDEFINED { - target - } else { - new_target - }; - if !is_constructor_function(nt) { - return throw_type_error("newTarget is not a constructor"); - } - let args = create_list_from_array_like(args_like); - if lookup(target).is_some() { - let args_array = array_from_args(&args); - return js_proxy_construct(target, args_array, nt); - } - let (ptr, n) = if args.is_empty() { - (std::ptr::null::(), 0usize) - } else { - (args.as_ptr(), args.len()) - }; - unsafe { crate::object::js_new_function_construct_with_new_target(target, ptr, n, nt) } -} - -/// `Reflect.ownKeys(target)` (#2763) — returns string own-property names -/// followed by own symbol keys (Node order: integer-index then insertion-order -/// string keys, then symbols). Throws `TypeError` for a non-object target. -/// -/// For a proxy, dispatches the `ownKeys` trap (with type/duplicate/invariant -/// validation) via [`js_proxy_own_keys`]. -#[no_mangle] -pub extern "C" fn js_reflect_own_keys(target: f64) -> f64 { - if lookup(target).is_some() { - return own_keys::js_proxy_own_keys(target); - } - let real = target; - if !reflect_value_is_object(real) { - return reflect_non_object_typeerror("ownKeys"); - } - // String own names (this fn already throws for null/undefined; we've - // validated above for the other primitives). - let names = crate::object::js_object_get_own_property_names(real); - let names_ptr = (names.to_bits() & POINTER_MASK) as *mut crate::array::ArrayHeader; - if names_ptr.is_null() { - return names; - } - // Append own symbol keys (#2763). - let syms_raw = unsafe { crate::symbol::js_object_get_own_property_symbols(real) }; - let syms_ptr = syms_raw as *const crate::array::ArrayHeader; - if !syms_ptr.is_null() { - let sym_count = crate::array::js_array_length(syms_ptr) as usize; - let mut out = names_ptr; - for i in 0..sym_count { - let sym = crate::array::js_array_get(syms_ptr, i as u32); - out = crate::array::js_array_push_f64(out, f64::from_bits(sym.bits())); - } - return f64::from_bits(POINTER_TAG | ((out as u64) & POINTER_MASK)); - } - names -} - -/// `Reflect.apply(fn, thisArg, argumentsList)` (#2767). -/// -/// - throws `TypeError` for a non-callable target, -/// - implements `CreateListFromArrayLike(argumentsList)` (throws for a -/// non-object `argumentsList`, reads `0..length` from any array-like), -/// - binds `thisArg` for the call. -/// -/// Proxy targets still dispatch to `js_proxy_apply` (which forwards the -/// already-constructed `args_array`). Proxy `apply` trap result fidelity for -/// an `undefined` trap return is out of scope here — Perry's proxy-apply path -/// keeps a pragmatic fallback (see `js_proxy_apply`). -#[no_mangle] -pub extern "C" fn js_reflect_apply(f: f64, this_arg: f64, args_array: f64) -> f64 { - // If `f` is a proxy with apply trap, dispatch through it. - if lookup(f).is_some() { - return js_proxy_apply(f, this_arg, args_array); - } - // Non-callable target → TypeError (before evaluating argumentsList, - // matching Node which reports the function check first). - if !is_callable(f) { - return throw_type_error("Reflect.apply target is not a function"); - } - let args = create_list_from_array_like(args_array); - call_with_this_and_args(f, this_arg, &args) -} - -/// `Reflect.defineProperty(obj, key, descriptor)` — returns `false` when the -/// definition cannot be applied (#2758): defining a *new* property on a -/// non-extensible object, or redefining an existing *non-configurable* -/// property. Successful definitions return `true`. For a proxy target, the -/// coerced `defineProperty` trap result is returned. -#[no_mangle] -pub extern "C" fn js_reflect_define_property(obj: f64, key: f64, descriptor: f64) -> f64 { - if lookup(obj).is_some() { - let id = lookup(obj).unwrap(); - let (target, handler, revoked) = PROXIES.with(|p| { - p.borrow() - .get(id as usize) - .and_then(|o| o.as_ref()) - .map(|e| (e.target, e.handler, e.revoked)) - .unwrap_or(( - f64::from_bits(TAG_UNDEFINED), - f64::from_bits(TAG_UNDEFINED), - false, - )) - }); - if revoked { - return revoked_return(); - } - let trap = handler_trap(handler, "defineProperty"); - if is_callable(trap) { - let scope = crate::gc::RuntimeHandleScope::new(); - let target_h = scope.root_nanbox_f64(target); - let key_h = scope.root_nanbox_f64(key); - let desc_h = scope.root_nanbox_f64(descriptor); - let trap_result = call_trap( - handler, - trap, - &[ - target_h.get_nanbox_f64(), - key_h.get_nanbox_f64(), - desc_h.get_nanbox_f64(), - ], - ); - if crate::value::js_is_truthy(trap_result) == 0 { - return nanbox_bool(false); - } - invariants::enforce_define_property_invariant( - target_h.get_nanbox_f64(), - key_h.get_nanbox_f64(), - desc_h.get_nanbox_f64(), - ); - return nanbox_bool(true); - } - // No trap — define on the underlying target. When the target is itself - // a Proxy, recurse through the proxy dispatch rather than the ordinary - // path, which would deref the fake pointer. - if lookup(target).is_some() { - return js_reflect_define_property(target, key, descriptor); - } - return crate::object::reflect_define_property(target, key, descriptor); - } - // ECMA-262 28.1.3: Reflect.defineProperty throws when target is not an - // Object (a Symbol / BigInt primitive slips past the heap-pointer probe). - if !reflect_value_is_object(obj) { - return reflect_non_object_typeerror("defineProperty"); - } - crate::object::reflect_define_property(obj, key, descriptor) -} - -/// `[[GetPrototypeOf]]` for a Proxy: invoke the handler's `getPrototypeOf` -/// trap when present, otherwise forward to the TARGET's `[[Prototype]]`. A -/// Proxy itself is a small registered id (not a heap object), so the generic -/// `js_object_get_prototype_of` would mis-read it and return `null` — which -/// broke `Object.getPrototypeOf(proxy).constructor` (drizzle aliases columns as -/// `new Proxy(column, …)` and its `is(value, type)` reads -/// `getPrototypeOf(value).constructor`, crashing on `null.constructor`). -/// Callers must have already confirmed `obj` is a registered proxy. -pub(crate) fn js_proxy_get_prototype_of(obj: f64) -> f64 { - if lookup(obj).is_none() { - return crate::object::js_object_get_prototype_of(obj); - } - proxy_get_prototype_of_impl(obj) -} - -/// Shared Proxy `[[GetPrototypeOf]]` (ECMA-262 §10.5.1): dispatch the trap -/// bound to the handler, validate the result is an Object or `null`, and (when -/// the target is non-extensible) enforce that the trap result matches the -/// target's actual prototype. Used by both `Object.getPrototypeOf(proxy)` and -/// `Reflect.getPrototypeOf(proxy)` so they validate identically. -fn proxy_get_prototype_of_impl(obj: f64) -> f64 { - let Some(id) = lookup(obj) else { - return crate::object::js_object_get_prototype_of(obj); - }; - let (target, handler, revoked) = PROXIES.with(|p| { - p.borrow() - .get(id as usize) - .and_then(|o| o.as_ref()) - .map(|e| (e.target, e.handler, e.revoked)) - .unwrap_or(( - f64::from_bits(TAG_UNDEFINED), - f64::from_bits(TAG_UNDEFINED), - false, - )) - }); - if revoked { - return revoked_return(); - } - let trap = handler_trap(handler, "getPrototypeOf"); - let trap_bits = trap.to_bits(); - if trap_bits == TAG_UNDEFINED || trap_bits == TAG_NULL { - return reflect_target_get_prototype_of(target); - } - if !is_callable_function(trap) { - return throw_type_error("proxy getPrototypeOf trap is not a function"); - } - let scope = crate::gc::RuntimeHandleScope::new(); - let target_h = scope.root_nanbox_f64(target); - let result = call_trap(handler, trap, &[target_h.get_nanbox_f64()]); - let result_bits = result.to_bits(); - if result_bits != TAG_NULL && !reflect_value_is_object(result) { - return throw_type_error("proxy getPrototypeOf trap returned non-object"); - } - let target = target_h.get_nanbox_f64(); - if crate::object::obj_value_no_extend(target) { - let actual = reflect_target_get_prototype_of(target); - if actual.to_bits() != result_bits { - return throw_type_error("proxy getPrototypeOf trap violates target invariant"); - } - } - result -} - -/// `Reflect.getPrototypeOf(obj)` — shares the actual prototype lookup with -/// `Object.getPrototypeOf` (#2757): returns the object's `[[Prototype]]`, -/// including `null` for null-prototype objects, not the object itself. -#[no_mangle] -pub extern "C" fn js_reflect_get_prototype_of(obj: f64) -> f64 { - // Reflect.getPrototypeOf on a non-object target must throw TypeError (spec - // step 1). Note `Object.getPrototypeOf` is more lenient (ToObject-coerces - // primitives), so guard here before delegating. Proxies have a registered - // entry and are objects, so they pass this check and dispatch below. - if lookup(obj).is_some() { - return proxy_get_prototype_of_impl(obj); - } - if !reflect_value_is_object(obj) { - return reflect_non_object_typeerror("getPrototypeOf"); - } - crate::object::js_object_get_prototype_of(obj) -} - -/// `Reflect.isExtensible(target)` — throws a `TypeError` for non-object targets -/// (#2762), otherwise returns the boolean extensibility of the target. For a -/// proxy, dispatches to the `isExtensible` trap when present. -#[no_mangle] -pub extern "C" fn js_reflect_is_extensible(target: f64) -> f64 { - if let Some(id) = lookup(target) { - let (inner, handler, revoked) = PROXIES.with(|p| { - p.borrow() - .get(id as usize) - .and_then(|o| o.as_ref()) - .map(|e| (e.target, e.handler, e.revoked)) - .unwrap_or(( - f64::from_bits(TAG_UNDEFINED), - f64::from_bits(TAG_UNDEFINED), - false, - )) - }); - if revoked { - return revoked_return(); - } - let trap = handler_trap(handler, "isExtensible"); - if is_callable(trap) { - let scope = crate::gc::RuntimeHandleScope::new(); - let inner_h = scope.root_nanbox_f64(inner); - let trap_result = call_trap(handler, trap, &[inner_h.get_nanbox_f64()]); - let booleanized = crate::value::js_is_truthy(trap_result) != 0; - // Invariant: the trap result must equal the target's actual - // extensibility. - let target_ext = crate::value::js_is_truthy(crate::object::js_object_is_extensible( - inner_h.get_nanbox_f64(), - )) != 0; - if booleanized != target_ext { - return throw_type_error( - "proxy isExtensible trap result does not match target extensibility", - ); - } - return nanbox_bool(booleanized); - } - return crate::object::js_object_is_extensible(inner); - } - if !reflect_value_is_object(target) { - return reflect_non_object_typeerror("isExtensible"); - } - crate::object::js_object_is_extensible(target) -} - -/// `Reflect.preventExtensions(target)` — throws a `TypeError` for non-object -/// targets (#2762) and returns a boolean (`true` on success), unlike -/// `Object.preventExtensions` which returns the object. For a proxy, dispatches -/// to the `preventExtensions` trap when present and returns its coerced result. -#[no_mangle] -pub extern "C" fn js_reflect_prevent_extensions(target: f64) -> f64 { - if let Some(id) = lookup(target) { - let (inner, handler, revoked) = PROXIES.with(|p| { - p.borrow() - .get(id as usize) - .and_then(|o| o.as_ref()) - .map(|e| (e.target, e.handler, e.revoked)) - .unwrap_or(( - f64::from_bits(TAG_UNDEFINED), - f64::from_bits(TAG_UNDEFINED), - false, - )) - }); - if revoked { - return revoked_return(); - } - let trap = handler_trap(handler, "preventExtensions"); - if is_callable(trap) { - let scope = crate::gc::RuntimeHandleScope::new(); - let inner_h = scope.root_nanbox_f64(inner); - let trap_result = call_trap(handler, trap, &[inner_h.get_nanbox_f64()]); - let booleanized = crate::value::js_is_truthy(trap_result) != 0; - // Invariant: a `true` result requires the target to be non-extensible. - if booleanized { - let target_ext = crate::value::js_is_truthy( - crate::object::js_object_is_extensible(inner_h.get_nanbox_f64()), - ) != 0; - if target_ext { - return throw_type_error( - "proxy preventExtensions trap returned true but target is extensible", - ); - } - } - return nanbox_bool(booleanized); - } - crate::object::js_object_prevent_extensions(inner); - return nanbox_bool(true); - } - if !reflect_value_is_object(target) { - return reflect_non_object_typeerror("preventExtensions"); - } - crate::object::js_object_prevent_extensions(target); - nanbox_bool(true) -} - -/// Native trampoline backing the `revoke` function returned by -/// `Proxy.revocable`. The closure captures the proxy value in capture slot 0; -/// invoking it revokes that specific proxy. Idempotent — revoking an -/// already-revoked proxy is a no-op (Node's `revoke()` is idempotent). (#2846) -extern "C" fn proxy_revoke_trampoline(closure: *const crate::closure::ClosureHeader) -> f64 { - let proxy = crate::closure::js_closure_get_capture_f64(closure, 0); - js_proxy_revoke(proxy); - f64::from_bits(TAG_UNDEFINED) -} - /// `Proxy.revocable(target, handler)` — returns an ordinary object /// `{ proxy, revoke }` where `proxy` is a fresh revocable Proxy and `revoke` /// is a callable, idempotent function that revokes only that proxy. (#2846) @@ -1900,8 +1558,8 @@ pub extern "C" fn js_proxy_revocable(target: f64, handler: f64) -> f64 { let proxy = js_proxy_new(target, handler); // Build the revoke closure capturing the proxy value. - let revoke_closure = crate::closure::js_closure_alloc(proxy_revoke_trampoline as *const u8, 1); - crate::closure::js_register_closure_arity(proxy_revoke_trampoline as *const u8, 0); + let revoke_closure = crate::closure::js_closure_alloc(reflect_misc::proxy_revoke_trampoline as *const u8, 1); + crate::closure::js_register_closure_arity(reflect_misc::proxy_revoke_trampoline as *const u8, 0); crate::closure::js_closure_set_capture_f64(revoke_closure, 0, proxy); let revoke_boxed = f64::from_bits(POINTER_TAG | ((revoke_closure as u64) & POINTER_MASK)); diff --git a/crates/perry-runtime/src/proxy/reflect_misc.rs b/crates/perry-runtime/src/proxy/reflect_misc.rs new file mode 100644 index 0000000000..d0b86bd143 --- /dev/null +++ b/crates/perry-runtime/src/proxy/reflect_misc.rs @@ -0,0 +1,358 @@ +//! Reflect.* entry points + proxy [[GetPrototypeOf]] — extracted from +//! `proxy.rs` to keep it under the 2000-line gate (split-large-files +//! recipe). Pure relocation; `use super::*` preserves visibility of the +//! parent's private registry helpers. + +#![allow(unused_imports)] + +use super::*; + +pub(super) fn array_from_args(args: &[f64]) -> f64 { + let arr = crate::array::js_array_alloc(0); + let mut a = arr; + for &arg in args { + a = crate::array::js_array_push_f64(a, arg); + } + f64::from_bits(POINTER_TAG | ((a as u64) & POINTER_MASK)) +} + +#[no_mangle] +pub extern "C" fn js_reflect_construct(target: f64, args_like: f64, new_target: f64) -> f64 { + if !is_constructor_function(target) { + return throw_type_error("target is not a constructor"); + } + let nt = if new_target.to_bits() == TAG_UNDEFINED { + target + } else { + new_target + }; + if !is_constructor_function(nt) { + return throw_type_error("newTarget is not a constructor"); + } + let args = create_list_from_array_like(args_like); + if lookup(target).is_some() { + let args_array = array_from_args(&args); + return js_proxy_construct(target, args_array, nt); + } + let (ptr, n) = if args.is_empty() { + (std::ptr::null::(), 0usize) + } else { + (args.as_ptr(), args.len()) + }; + unsafe { crate::object::js_new_function_construct_with_new_target(target, ptr, n, nt) } +} + +/// `Reflect.ownKeys(target)` (#2763) — returns string own-property names +/// followed by own symbol keys (Node order: integer-index then insertion-order +/// string keys, then symbols). Throws `TypeError` for a non-object target. +/// +/// For a proxy, dispatches the `ownKeys` trap (with type/duplicate/invariant +/// validation) via [`js_proxy_own_keys`]. +#[no_mangle] +pub extern "C" fn js_reflect_own_keys(target: f64) -> f64 { + if lookup(target).is_some() { + return own_keys::js_proxy_own_keys(target); + } + let real = target; + if !reflect_value_is_object(real) { + return reflect_non_object_typeerror("ownKeys"); + } + // String own names (this fn already throws for null/undefined; we've + // validated above for the other primitives). + let names = crate::object::js_object_get_own_property_names(real); + let names_ptr = (names.to_bits() & POINTER_MASK) as *mut crate::array::ArrayHeader; + if names_ptr.is_null() { + return names; + } + // Append own symbol keys (#2763). + let syms_raw = unsafe { crate::symbol::js_object_get_own_property_symbols(real) }; + let syms_ptr = syms_raw as *const crate::array::ArrayHeader; + if !syms_ptr.is_null() { + let sym_count = crate::array::js_array_length(syms_ptr) as usize; + let mut out = names_ptr; + for i in 0..sym_count { + let sym = crate::array::js_array_get(syms_ptr, i as u32); + out = crate::array::js_array_push_f64(out, f64::from_bits(sym.bits())); + } + return f64::from_bits(POINTER_TAG | ((out as u64) & POINTER_MASK)); + } + names +} + +/// `Reflect.apply(fn, thisArg, argumentsList)` (#2767). +/// +/// - throws `TypeError` for a non-callable target, +/// - implements `CreateListFromArrayLike(argumentsList)` (throws for a +/// non-object `argumentsList`, reads `0..length` from any array-like), +/// - binds `thisArg` for the call. +/// +/// Proxy targets still dispatch to `js_proxy_apply` (which forwards the +/// already-constructed `args_array`). Proxy `apply` trap result fidelity for +/// an `undefined` trap return is out of scope here — Perry's proxy-apply path +/// keeps a pragmatic fallback (see `js_proxy_apply`). +#[no_mangle] +pub extern "C" fn js_reflect_apply(f: f64, this_arg: f64, args_array: f64) -> f64 { + // If `f` is a proxy with apply trap, dispatch through it. + if lookup(f).is_some() { + return js_proxy_apply(f, this_arg, args_array); + } + // Non-callable target → TypeError (before evaluating argumentsList, + // matching Node which reports the function check first). + if !is_callable(f) { + return throw_type_error("Reflect.apply target is not a function"); + } + let args = create_list_from_array_like(args_array); + call_with_this_and_args(f, this_arg, &args) +} + +/// `Reflect.defineProperty(obj, key, descriptor)` — returns `false` when the +/// definition cannot be applied (#2758): defining a *new* property on a +/// non-extensible object, or redefining an existing *non-configurable* +/// property. Successful definitions return `true`. For a proxy target, the +/// coerced `defineProperty` trap result is returned. +#[no_mangle] +pub extern "C" fn js_reflect_define_property(obj: f64, key: f64, descriptor: f64) -> f64 { + if lookup(obj).is_some() { + let id = lookup(obj).unwrap(); + let (target, handler, revoked) = PROXIES.with(|p| { + p.borrow() + .get(id as usize) + .and_then(|o| o.as_ref()) + .map(|e| (e.target, e.handler, e.revoked)) + .unwrap_or(( + f64::from_bits(TAG_UNDEFINED), + f64::from_bits(TAG_UNDEFINED), + false, + )) + }); + if revoked { + return revoked_return(); + } + let trap = handler_trap(handler, "defineProperty"); + if is_callable(trap) { + let scope = crate::gc::RuntimeHandleScope::new(); + let target_h = scope.root_nanbox_f64(target); + let key_h = scope.root_nanbox_f64(key); + let desc_h = scope.root_nanbox_f64(descriptor); + let trap_result = call_trap( + handler, + trap, + &[ + target_h.get_nanbox_f64(), + key_h.get_nanbox_f64(), + desc_h.get_nanbox_f64(), + ], + ); + if crate::value::js_is_truthy(trap_result) == 0 { + return nanbox_bool(false); + } + invariants::enforce_define_property_invariant( + target_h.get_nanbox_f64(), + key_h.get_nanbox_f64(), + desc_h.get_nanbox_f64(), + ); + return nanbox_bool(true); + } + // No trap — define on the underlying target. When the target is itself + // a Proxy, recurse through the proxy dispatch rather than the ordinary + // path, which would deref the fake pointer. + if lookup(target).is_some() { + return js_reflect_define_property(target, key, descriptor); + } + return crate::object::reflect_define_property(target, key, descriptor); + } + // ECMA-262 28.1.3: Reflect.defineProperty throws when target is not an + // Object (a Symbol / BigInt primitive slips past the heap-pointer probe). + if !reflect_value_is_object(obj) { + return reflect_non_object_typeerror("defineProperty"); + } + crate::object::reflect_define_property(obj, key, descriptor) +} + +/// `[[GetPrototypeOf]]` for a Proxy: invoke the handler's `getPrototypeOf` +/// trap when present, otherwise forward to the TARGET's `[[Prototype]]`. A +/// Proxy itself is a small registered id (not a heap object), so the generic +/// `js_object_get_prototype_of` would mis-read it and return `null` — which +/// broke `Object.getPrototypeOf(proxy).constructor` (drizzle aliases columns as +/// `new Proxy(column, …)` and its `is(value, type)` reads +/// `getPrototypeOf(value).constructor`, crashing on `null.constructor`). +/// Callers must have already confirmed `obj` is a registered proxy. +pub(crate) fn js_proxy_get_prototype_of(obj: f64) -> f64 { + if lookup(obj).is_none() { + return crate::object::js_object_get_prototype_of(obj); + } + proxy_get_prototype_of_impl(obj) +} + +/// Shared Proxy `[[GetPrototypeOf]]` (ECMA-262 §10.5.1): dispatch the trap +/// bound to the handler, validate the result is an Object or `null`, and (when +/// the target is non-extensible) enforce that the trap result matches the +/// target's actual prototype. Used by both `Object.getPrototypeOf(proxy)` and +/// `Reflect.getPrototypeOf(proxy)` so they validate identically. +pub(super) fn proxy_get_prototype_of_impl(obj: f64) -> f64 { + let Some(id) = lookup(obj) else { + return crate::object::js_object_get_prototype_of(obj); + }; + let (target, handler, revoked) = PROXIES.with(|p| { + p.borrow() + .get(id as usize) + .and_then(|o| o.as_ref()) + .map(|e| (e.target, e.handler, e.revoked)) + .unwrap_or(( + f64::from_bits(TAG_UNDEFINED), + f64::from_bits(TAG_UNDEFINED), + false, + )) + }); + if revoked { + return revoked_return(); + } + let trap = handler_trap(handler, "getPrototypeOf"); + let trap_bits = trap.to_bits(); + if trap_bits == TAG_UNDEFINED || trap_bits == TAG_NULL { + return reflect_target_get_prototype_of(target); + } + if !is_callable_function(trap) { + return throw_type_error("proxy getPrototypeOf trap is not a function"); + } + let scope = crate::gc::RuntimeHandleScope::new(); + let target_h = scope.root_nanbox_f64(target); + let result = call_trap(handler, trap, &[target_h.get_nanbox_f64()]); + let result_bits = result.to_bits(); + if result_bits != TAG_NULL && !reflect_value_is_object(result) { + return throw_type_error("proxy getPrototypeOf trap returned non-object"); + } + let target = target_h.get_nanbox_f64(); + if crate::object::obj_value_no_extend(target) { + let actual = reflect_target_get_prototype_of(target); + if actual.to_bits() != result_bits { + return throw_type_error("proxy getPrototypeOf trap violates target invariant"); + } + } + result +} + +/// `Reflect.getPrototypeOf(obj)` — shares the actual prototype lookup with +/// `Object.getPrototypeOf` (#2757): returns the object's `[[Prototype]]`, +/// including `null` for null-prototype objects, not the object itself. +#[no_mangle] +pub extern "C" fn js_reflect_get_prototype_of(obj: f64) -> f64 { + // Reflect.getPrototypeOf on a non-object target must throw TypeError (spec + // step 1). Note `Object.getPrototypeOf` is more lenient (ToObject-coerces + // primitives), so guard here before delegating. Proxies have a registered + // entry and are objects, so they pass this check and dispatch below. + if lookup(obj).is_some() { + return proxy_get_prototype_of_impl(obj); + } + if !reflect_value_is_object(obj) { + return reflect_non_object_typeerror("getPrototypeOf"); + } + crate::object::js_object_get_prototype_of(obj) +} + +/// `Reflect.isExtensible(target)` — throws a `TypeError` for non-object targets +/// (#2762), otherwise returns the boolean extensibility of the target. For a +/// proxy, dispatches to the `isExtensible` trap when present. +#[no_mangle] +pub extern "C" fn js_reflect_is_extensible(target: f64) -> f64 { + if let Some(id) = lookup(target) { + let (inner, handler, revoked) = PROXIES.with(|p| { + p.borrow() + .get(id as usize) + .and_then(|o| o.as_ref()) + .map(|e| (e.target, e.handler, e.revoked)) + .unwrap_or(( + f64::from_bits(TAG_UNDEFINED), + f64::from_bits(TAG_UNDEFINED), + false, + )) + }); + if revoked { + return revoked_return(); + } + let trap = handler_trap(handler, "isExtensible"); + if is_callable(trap) { + let scope = crate::gc::RuntimeHandleScope::new(); + let inner_h = scope.root_nanbox_f64(inner); + let trap_result = call_trap(handler, trap, &[inner_h.get_nanbox_f64()]); + let booleanized = crate::value::js_is_truthy(trap_result) != 0; + // Invariant: the trap result must equal the target's actual + // extensibility. + let target_ext = crate::value::js_is_truthy(crate::object::js_object_is_extensible( + inner_h.get_nanbox_f64(), + )) != 0; + if booleanized != target_ext { + return throw_type_error( + "proxy isExtensible trap result does not match target extensibility", + ); + } + return nanbox_bool(booleanized); + } + return crate::object::js_object_is_extensible(inner); + } + if !reflect_value_is_object(target) { + return reflect_non_object_typeerror("isExtensible"); + } + crate::object::js_object_is_extensible(target) +} + +/// `Reflect.preventExtensions(target)` — throws a `TypeError` for non-object +/// targets (#2762) and returns a boolean (`true` on success), unlike +/// `Object.preventExtensions` which returns the object. For a proxy, dispatches +/// to the `preventExtensions` trap when present and returns its coerced result. +#[no_mangle] +pub extern "C" fn js_reflect_prevent_extensions(target: f64) -> f64 { + if let Some(id) = lookup(target) { + let (inner, handler, revoked) = PROXIES.with(|p| { + p.borrow() + .get(id as usize) + .and_then(|o| o.as_ref()) + .map(|e| (e.target, e.handler, e.revoked)) + .unwrap_or(( + f64::from_bits(TAG_UNDEFINED), + f64::from_bits(TAG_UNDEFINED), + false, + )) + }); + if revoked { + return revoked_return(); + } + let trap = handler_trap(handler, "preventExtensions"); + if is_callable(trap) { + let scope = crate::gc::RuntimeHandleScope::new(); + let inner_h = scope.root_nanbox_f64(inner); + let trap_result = call_trap(handler, trap, &[inner_h.get_nanbox_f64()]); + let booleanized = crate::value::js_is_truthy(trap_result) != 0; + // Invariant: a `true` result requires the target to be non-extensible. + if booleanized { + let target_ext = crate::value::js_is_truthy( + crate::object::js_object_is_extensible(inner_h.get_nanbox_f64()), + ) != 0; + if target_ext { + return throw_type_error( + "proxy preventExtensions trap returned true but target is extensible", + ); + } + } + return nanbox_bool(booleanized); + } + crate::object::js_object_prevent_extensions(inner); + return nanbox_bool(true); + } + if !reflect_value_is_object(target) { + return reflect_non_object_typeerror("preventExtensions"); + } + crate::object::js_object_prevent_extensions(target); + nanbox_bool(true) +} + +/// Native trampoline backing the `revoke` function returned by +/// `Proxy.revocable`. The closure captures the proxy value in capture slot 0; +/// invoking it revokes that specific proxy. Idempotent — revoking an +/// already-revoked proxy is a no-op (Node's `revoke()` is idempotent). (#2846) +pub(super) extern "C" fn proxy_revoke_trampoline(closure: *const crate::closure::ClosureHeader) -> f64 { + let proxy = crate::closure::js_closure_get_capture_f64(closure, 0); + js_proxy_revoke(proxy); + f64::from_bits(TAG_UNDEFINED) +} + From 1b78840987d56f770591105de2398a1067cde2d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 10 Jun 2026 01:51:54 +0200 Subject: [PATCH 4/4] fix(runtime): zero-regression hardening for Function parity v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - js_proxy_get: call/apply/bind value reads on callable-wrapping proxies reify with the PROXY as receiver so later invocation routes the apply trap (built-ins/Proxy/apply/*); fn-proto expando fallback restricted to user expandos only (numeric keys excluded) - Object.seal(TypedArray): skip mark_all_keys (TA headers have no keys_array — garbage deref segfaulted on Linux); is_registered_set checks the registry before dereferencing the GC header (same latent Linux crash class) - hasOwnProperty('prototype') is predicate-only (materializing locked the slot before a later defineProperty — TypedArrayConstructors custom-proto) - proxy.rs split (reflect_misc.rs) for the 2000-line gate; #[used] keepalives for the new generated-code-only runtime entry points --- .../src/closure/dynamic_props.rs | 22 ++++++------- crates/perry-runtime/src/closure/mod.rs | 9 +++-- crates/perry-runtime/src/closure/registry.rs | 6 ++++ crates/perry-runtime/src/error.rs | 6 ++++ .../src/object/class_registry.rs | 16 +++++++++ .../perry-runtime/src/object/global_this.rs | 7 ++++ .../src/object/has_own_helpers.rs | 7 ++-- .../src/object/object_ops_frozen.rs | 15 +++++++++ crates/perry-runtime/src/proxy.rs | 33 +++++++++++++++++-- .../perry-runtime/src/proxy/reflect_misc.rs | 5 +-- crates/perry-runtime/src/set.rs | 15 ++++++--- 11 files changed, 114 insertions(+), 27 deletions(-) diff --git a/crates/perry-runtime/src/closure/dynamic_props.rs b/crates/perry-runtime/src/closure/dynamic_props.rs index e52b67a087..49944edda1 100644 --- a/crates/perry-runtime/src/closure/dynamic_props.rs +++ b/crates/perry-runtime/src/closure/dynamic_props.rs @@ -393,7 +393,8 @@ pub fn closure_get_dynamic_prop(ptr: usize, prop: &str) -> f64 { if !matches!( prop, "prototype" | "name" | "length" | "caller" | "arguments" | "constructor" - ) { + ) && !prop.as_bytes().first().is_some_and(|b| b.is_ascii_digit()) + { thread_local! { static IN_FN_PROTO_FALLBACK: std::cell::Cell = const { std::cell::Cell::new(false) }; @@ -405,16 +406,15 @@ pub fn closure_get_dynamic_prop(ptr: usize, prop: &str) -> f64 { let proto_jv = crate::value::JSValue::from_bits(proto_val.to_bits()); if proto_jv.is_pointer() { let proto_ptr = (proto_jv.bits() & crate::value::POINTER_MASK) as usize; - // USER expandos walk through (a `Function.prototype.x = …` - // write records no attrs), as do the spec call-routing - // methods `apply`/`call`/`bind` (so an OBJECT whose proto - // chain contains a function resolves `obj.apply` — - // S15.3.4.3_A1_T1). Every OTHER method installed at init - // (`hasOwnProperty`, `propertyIsEnumerable`, …) stays - // excluded: serving those generic object thunks to closure - // reads would hijack the dedicated closure-aware dispatch - // arms. - let routed_method = matches!(prop, "apply" | "call" | "bind"); + // ONLY user expandos walk through (a `Function.prototype.x + // = …` write records no attrs). Methods installed at init + // (`apply`, `call`, `hasOwnProperty`, …) stay excluded: + // serving those generic thunks to closure reads hijacks the + // dedicated dispatch arms (`p.call(...)`'s undefined-read + // fallback to method-dispatch-by-name is what routes the + // proxy APPLY trap). `fn.apply`-style VALUE reads through a + // proxy are reified receiver-correctly by `js_proxy_get`. + let routed_method = false; if proto_ptr != 0 && proto_ptr != ptr && !is_closure_ptr(proto_ptr) diff --git a/crates/perry-runtime/src/closure/mod.rs b/crates/perry-runtime/src/closure/mod.rs index bb6d185241..f7c06ee1f3 100644 --- a/crates/perry-runtime/src/closure/mod.rs +++ b/crates/perry-runtime/src/closure/mod.rs @@ -32,11 +32,10 @@ pub use registry::{ js_register_closure_arrow_function, js_register_closure_async_function, js_register_closure_async_generator_function, js_register_closure_generator_function, js_register_closure_length, js_register_closure_rest, js_register_closure_rest_and_arguments, - js_register_closure_strict_function, - js_register_closure_synthetic_arguments, lookup_closure_arity, lookup_closure_length, - lookup_closure_rest, lookup_closure_rest_full, real_capture_count, resolve_strategy, - DispatchStrategy, BOUND_FUNCTION_FUNC_PTR, BOUND_METHOD_FUNC_PTR, CAPTURES_THIS_FLAG, - CLOSURE_MAGIC, + js_register_closure_strict_function, js_register_closure_synthetic_arguments, + lookup_closure_arity, lookup_closure_length, lookup_closure_rest, lookup_closure_rest_full, + real_capture_count, resolve_strategy, DispatchStrategy, BOUND_FUNCTION_FUNC_PTR, + BOUND_METHOD_FUNC_PTR, CAPTURES_THIS_FLAG, CLOSURE_MAGIC, }; pub use dispatch::{ diff --git a/crates/perry-runtime/src/closure/registry.rs b/crates/perry-runtime/src/closure/registry.rs index 6d75d015bf..6798936c55 100644 --- a/crates/perry-runtime/src/closure/registry.rs +++ b/crates/perry-runtime/src/closure/registry.rs @@ -346,6 +346,12 @@ pub extern "C" fn js_register_closure_strict_function(func_ptr: *const u8) { }); } +/// Keepalive anchor for the auto-optimize whole-program build — the strict +/// registration is emitted only from generated module-init code. +#[used] +static KEEP_JS_REGISTER_CLOSURE_STRICT_FUNCTION: extern "C" fn(*const u8) = + js_register_closure_strict_function; + #[inline(always)] pub fn is_registered_strict_function(func_ptr: *const u8) -> bool { if func_ptr.is_null() { diff --git a/crates/perry-runtime/src/error.rs b/crates/perry-runtime/src/error.rs index 7377ba9f48..4c8e6da182 100644 --- a/crates/perry-runtime/src/error.rs +++ b/crates/perry-runtime/src/error.rs @@ -733,6 +733,12 @@ pub extern "C" fn js_throw_reference_error_unresolved_get() -> f64 { throw_reference_error_message(b"identifier is not defined") } +/// Keepalive anchor for the auto-optimize whole-program build (generated-code +///-only callee; see project_auto_optimize_keepalive_3320). +#[used] +static KEEP_JS_GLOBAL_GET_OR_THROW_UNRESOLVED: extern "C" fn(f64) -> f64 = + js_global_get_or_throw_unresolved; + /// Read a compile-time-unresolved identifier off `globalThis` (a global the /// program created dynamically — `Function("this.y = 2")()` — exists only at /// runtime), throwing the spec ReferenceError when no such global property diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index 876ec5d6d0..d28a170764 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -2591,6 +2591,22 @@ fn is_arrow_function_value(value: f64) -> bool { crate::closure::closure_is_arrow(ptr) } +/// Predicate-only sibling of `ordinary_function_prototype_value_for_read`: +/// would this function have an own `.prototype` slot? Crucially does NOT +/// materialize the prototype object — `fn.hasOwnProperty('prototype')` must +/// not lock the slot's attributes before a later +/// `Object.defineProperty(fn, "prototype", …)` (TypedArrayConstructors +/// custom-proto tests). +pub(crate) fn function_would_have_own_prototype(func_value: f64) -> bool { + if !is_callable_function_value(func_value) || is_arrow_function_value(func_value) { + return false; + } + if super::native_module::builtin_closure_is_non_constructable_value(func_value) { + return false; + } + synthetic_class_id_for_function(func_value) != 0 +} + pub(crate) fn ordinary_function_prototype_value_for_read(func_value: f64) -> Option { if !is_callable_function_value(func_value) || is_arrow_function_value(func_value) { return None; diff --git a/crates/perry-runtime/src/object/global_this.rs b/crates/perry-runtime/src/object/global_this.rs index fe5e2f515e..70fc110fd4 100644 --- a/crates/perry-runtime/src/object/global_this.rs +++ b/crates/perry-runtime/src/object/global_this.rs @@ -45,6 +45,13 @@ pub extern "C" fn js_module_top_this() -> f64 { val } +/// Keepalive anchor: `js_module_top_this` is referenced only from +/// codegen-generated `.o` files, so the auto-optimize whole-program LLVM +/// rebuild would dead-strip it without this `#[used]` pin (see +/// project_auto_optimize_keepalive_3320). +#[used] +static KEEP_JS_MODULE_TOP_THIS: extern "C" fn() -> f64 = js_module_top_this; + /// Issue #611: lazily allocate `globalThis` for computed global access. #[no_mangle] pub extern "C" fn js_get_global_this() -> f64 { diff --git a/crates/perry-runtime/src/object/has_own_helpers.rs b/crates/perry-runtime/src/object/has_own_helpers.rs index c051d3271a..2d07124496 100644 --- a/crates/perry-runtime/src/object/has_own_helpers.rs +++ b/crates/perry-runtime/src/object/has_own_helpers.rs @@ -24,12 +24,13 @@ pub(crate) fn closure_own_key_present(ptr: usize, key: &str) -> bool { // A constructor-capable function's `.prototype` is an own property // from birth even though Perry materializes the object lazily — // `f.hasOwnProperty('prototype')` must be true BEFORE any read of - // `f.prototype`. The for-read helper materializes (idempotently) and - // returns None for arrows/builtins, which really have no own slot. + // `f.prototype`. Predicate only: materializing here would lock the + // slot's attributes ahead of a later `defineProperty(fn, + // "prototype", …)`. "prototype" => { crate::closure::closure_has_own_dynamic_prop(ptr, key) || { let val = crate::value::js_nanbox_pointer(ptr as i64); - super::class_registry::ordinary_function_prototype_value_for_read(val).is_some() + super::class_registry::function_would_have_own_prototype(val) } } // User props are real own dynamic props in the side table. diff --git a/crates/perry-runtime/src/object/object_ops_frozen.rs b/crates/perry-runtime/src/object/object_ops_frozen.rs index 8214b4b43f..a50f97f65e 100644 --- a/crates/perry-runtime/src/object/object_ops_frozen.rs +++ b/crates/perry-runtime/src/object/object_ops_frozen.rs @@ -210,6 +210,21 @@ pub extern "C" fn js_object_seal(obj_value: f64) -> f64 { set_integrity_level_proxy(obj_value, /*frozen=*/ false) }; } + // A TypedArray is NOT an ObjectHeader: reading `keys_array` off its + // header yields garbage that `mark_all_keys` then dereferences + // (`Object.seal(new Uint32Array())` segfaulted on Linux). Seal on a TA + // only needs the no-extend/sealed flags — integer-indexed elements are + // exotic and never configurable through this table. + if crate::typedarray_props::typed_array_addr_from_value(obj_value).is_some() { + unsafe { + let obj = extract_obj_ptr(obj_value); + if !obj.is_null() && (obj as usize) > 0x10000 { + let gc = gc_header_for(obj); + (*gc)._reserved |= crate::gc::OBJ_FLAG_SEALED | crate::gc::OBJ_FLAG_NO_EXTEND; + } + } + return obj_value; + } unsafe { let obj = extract_obj_ptr(obj_value); if !obj.is_null() && (obj as usize) > 0x10000 { diff --git a/crates/perry-runtime/src/proxy.rs b/crates/perry-runtime/src/proxy.rs index 4266352911..49ebb4d934 100644 --- a/crates/perry-runtime/src/proxy.rs +++ b/crates/perry-runtime/src/proxy.rs @@ -566,6 +566,31 @@ pub extern "C" fn js_proxy_get(proxy_boxed: f64, key: f64) -> f64 { if lookup(target).is_some() { return js_proxy_get(target, key); } + // `p.apply` / `p.call` / `p.bind` VALUE reads on a callable-wrapping + // proxy resolve to Function.prototype's methods with the PROXY as the + // receiver — reify a bound method so a later invocation dispatches + // `js_native_call_method(proxy, "call", …)` and routes through the + // proxy's [[Call]] (apply trap). Reading off the target instead would + // bypass the trap. (Test262 proxy-toString reads `.apply` as a value; + // Function.prototype.toString on the reified method is the + // NativeFunction form.) + if crate::object::value_is_callable(target) { + if let Some(name) = key_to_rust_string(key) { + let method: Option<&'static [u8]> = match name.as_str() { + "apply" => Some(b"apply"), + "call" => Some(b"call"), + "bind" => Some(b"bind"), + _ => None, + }; + if let Some(m) = method { + // Only when the target has no OWN override of the slot. + let t_ptr = extract_pointer(target.to_bits()) as usize; + if !crate::closure::closure_has_own_dynamic_prop(t_ptr, &name) { + return unsafe { crate::closure::reify_function_method_value(proxy_boxed, m) }; + } + } + } + } target_get(target, key) } @@ -1558,8 +1583,12 @@ pub extern "C" fn js_proxy_revocable(target: f64, handler: f64) -> f64 { let proxy = js_proxy_new(target, handler); // Build the revoke closure capturing the proxy value. - let revoke_closure = crate::closure::js_closure_alloc(reflect_misc::proxy_revoke_trampoline as *const u8, 1); - crate::closure::js_register_closure_arity(reflect_misc::proxy_revoke_trampoline as *const u8, 0); + let revoke_closure = + crate::closure::js_closure_alloc(reflect_misc::proxy_revoke_trampoline as *const u8, 1); + crate::closure::js_register_closure_arity( + reflect_misc::proxy_revoke_trampoline as *const u8, + 0, + ); crate::closure::js_closure_set_capture_f64(revoke_closure, 0, proxy); let revoke_boxed = f64::from_bits(POINTER_TAG | ((revoke_closure as u64) & POINTER_MASK)); diff --git a/crates/perry-runtime/src/proxy/reflect_misc.rs b/crates/perry-runtime/src/proxy/reflect_misc.rs index d0b86bd143..d11764f7b7 100644 --- a/crates/perry-runtime/src/proxy/reflect_misc.rs +++ b/crates/perry-runtime/src/proxy/reflect_misc.rs @@ -350,9 +350,10 @@ pub extern "C" fn js_reflect_prevent_extensions(target: f64) -> f64 { /// `Proxy.revocable`. The closure captures the proxy value in capture slot 0; /// invoking it revokes that specific proxy. Idempotent — revoking an /// already-revoked proxy is a no-op (Node's `revoke()` is idempotent). (#2846) -pub(super) extern "C" fn proxy_revoke_trampoline(closure: *const crate::closure::ClosureHeader) -> f64 { +pub(super) extern "C" fn proxy_revoke_trampoline( + closure: *const crate::closure::ClosureHeader, +) -> f64 { let proxy = crate::closure::js_closure_get_capture_f64(closure, 0); js_proxy_revoke(proxy); f64::from_bits(TAG_UNDEFINED) } - diff --git a/crates/perry-runtime/src/set.rs b/crates/perry-runtime/src/set.rs index 2fd8bf5013..aa444e234e 100644 --- a/crates/perry-runtime/src/set.rs +++ b/crates/perry-runtime/src/set.rs @@ -116,13 +116,20 @@ pub fn is_registered_set(addr: usize) -> bool { if addr < 0x100000 { return false; } + // Registry FIRST: it is authoritative and dereference-free. Probing the + // GC header before consulting the registry dereferenced `addr - 8` for + // arbitrary candidate pointers (e.g. garbage read off a TypedArray + // header by a mis-typed caller) — segfaults on Linux where freed/foreign + // pages get unmapped (mimalloc on macOS retains them, hiding the bug). + if !SET_REGISTRY.with(|r| r.borrow().contains(&addr)) { + return false; + } + // A registered address is a live arena Set; the header read is safe and + // guards against a stale entry whose memory was reused by another type. unsafe { let header = (addr - crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; - if (*header).obj_type != crate::gc::GC_TYPE_SET { - return false; - } + (*header).obj_type == crate::gc::GC_TYPE_SET } - SET_REGISTRY.with(|r| r.borrow().contains(&addr)) } /// Resolve a NaN-boxed (or raw-i64) `this` receiver to a registered `Set`