diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index 7e349ed01c..6ad119deb7 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -1342,6 +1342,13 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { // write for `i++`/`i--` on a sloppy implicit global (#3575). module.declare_function("js_global_get_optional", DOUBLE, &[DOUBLE]); module.declare_function("js_global_update", DOUBLE, &[DOUBLE, DOUBLE, DOUBLE]); + // #5989: strict-mode `Date = wrapped` — write an EXISTING global + // property, throw the spec ReferenceError only when absent. + module.declare_function( + "js_global_assign_existing_or_throw", + DOUBLE, + &[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-hir/src/lower/expr_assign.rs b/crates/perry-hir/src/lower/expr_assign.rs index ac3c1a3f16..8d6294b114 100644 --- a/crates/perry-hir/src/lower/expr_assign.rs +++ b/crates/perry-hir/src/lower/expr_assign.rs @@ -14,7 +14,10 @@ use crate::destructuring::lower_destructuring_assignment; use crate::ir::{BinaryOp, Expr, LogicalOp}; use crate::lower_patterns::lower_assign_target_to_expr; -use super::{lower_expr, lower_expr_assignment, with_set_fallback_for_ident, LoweringContext}; +use super::{ + lower_expr, lower_expr_assignment, strict_global_assign_existing_or_throw, + with_set_fallback_for_ident, LoweringContext, +}; fn assignment_target_inferred_name(target: &ast::AssignTarget) -> Option { match target { @@ -448,10 +451,12 @@ fn lower_assignment_target( Ok(*value) } else { if ctx.current_strict { - return Ok(Expr::Sequence(vec![ - *value, - throw_reference_error_unresolvable_assignment(&name), - ])); + // #5989: strict-mode assignment to an existing global + // builtin is a property write, not a ReferenceError. See + // `strict_global_assign_existing_or_throw` for the full + // rationale (shared with the sibling arm in + // lower_expr/assignment.rs). + return Ok(strict_global_assign_existing_or_throw(name, value)); } eprintln!( " Warning: Assignment to undeclared variable '{}', creating sloppy global", diff --git a/crates/perry-hir/src/lower/lower_expr.rs b/crates/perry-hir/src/lower/lower_expr.rs index fe385359de..93b0df010e 100644 --- a/crates/perry-hir/src/lower/lower_expr.rs +++ b/crates/perry-hir/src/lower/lower_expr.rs @@ -44,8 +44,9 @@ pub(crate) use helpers::{ global_script_this_enabled, is_cjs_style_native_default_import, is_fetch_global_value_name, is_known_global_identifier_name, lower_expr_with_json_parse_type_hint, native_module_binding_value, opt_call_func_nullish_guard, opt_call_receiver_repeatable, - relower_trace, throw_reference_error_expr, typed_parse_codegen_supports, - with_implicit_unset_let, with_set_fallback_for_ident, wrap_with_gets, + relower_trace, strict_global_assign_existing_or_throw, throw_reference_error_expr, + typed_parse_codegen_supports, with_implicit_unset_let, with_set_fallback_for_ident, + wrap_with_gets, }; pub(crate) use reactive_text::try_desugar_reactive_text; diff --git a/crates/perry-hir/src/lower/lower_expr/assignment.rs b/crates/perry-hir/src/lower/lower_expr/assignment.rs index 3b259b448c..f592c6a583 100644 --- a/crates/perry-hir/src/lower/lower_expr/assignment.rs +++ b/crates/perry-hir/src/lower/lower_expr/assignment.rs @@ -4,7 +4,7 @@ use super::*; use crate::lower::*; use anyhow::{anyhow, Result}; -use perry_types::{LocalId, Type}; +use perry_types::LocalId; use swc_common::Spanned; use swc_ecma_ast as ast; @@ -42,12 +42,11 @@ pub(crate) fn lower_expr_assignment( Ok(*value) } else { if ctx.current_strict { - return Ok(Expr::Sequence(vec![ - *value, - throw_reference_error_expr( - "js_throw_reference_error_unresolved_assignment", - ), - ])); + // #5989: strict-mode assignment to an existing global + // builtin is a property write, not a ReferenceError. See + // `strict_global_assign_existing_or_throw` for the full + // rationale (shared with the sibling arm in expr_assign.rs). + return Ok(strict_global_assign_existing_or_throw(name, value)); } eprintln!( " Warning: Assignment to undeclared variable '{}', creating sloppy global", diff --git a/crates/perry-hir/src/lower/lower_expr/helpers.rs b/crates/perry-hir/src/lower/lower_expr/helpers.rs index 82497744d2..d3b0408398 100644 --- a/crates/perry-hir/src/lower/lower_expr/helpers.rs +++ b/crates/perry-hir/src/lower/lower_expr/helpers.rs @@ -47,6 +47,35 @@ pub(crate) fn throw_reference_error_expr(helper_name: &str) -> Expr { } } +/// #5989: lower a strict-mode assignment to an identifier with no lexical +/// binding. Per spec (PutValue on a reference that resolves to the global +/// environment), an EXISTING global property is a normal property write — +/// Next.js 16's `cacheComponents` node-environment extensions reassign +/// `Date` exactly this way in strict CJS (`Date = createDate(Date)`), and +/// the old unconditional throw made the install fail at boot, disarming +/// dynamic-IO clock detection. Only a genuinely absent binding throws; the +/// runtime helper does the presence probe + write-back (mirroring the +/// `js_global_update` shape for `++x` on globals). Argument order preserves +/// spec evaluation order: the RHS evaluates before the reference check can +/// throw. Sloppy mode never reaches here (it lowers to a globalThis property +/// set that may CREATE the binding). +/// +/// Shared by both assignment-lowering arms (`expr_assign.rs`'s +/// `lower_assignment_target` and `lower_expr/assignment.rs`) so they can't +/// drift. +pub(crate) fn strict_global_assign_existing_or_throw(name: String, value: Box) -> Expr { + Expr::Call { + callee: Box::new(Expr::ExternFuncRef { + name: "js_global_assign_existing_or_throw".to_string(), + param_types: vec![Type::Any, Type::Any], + return_type: Type::Any, + }), + args: vec![Expr::String(name), *value], + type_args: vec![], + byte_offset: 0, + } +} + pub(crate) fn is_known_global_identifier_name(name: &str) -> bool { matches!( name, diff --git a/crates/perry-hir/src/lower/mod.rs b/crates/perry-hir/src/lower/mod.rs index 583aefaca3..398824132a 100644 --- a/crates/perry-hir/src/lower/mod.rs +++ b/crates/perry-hir/src/lower/mod.rs @@ -106,8 +106,9 @@ pub use lower_module_fn::{ mod lower_expr; pub(crate) use lower_expr::{ - lower_expr, lower_expr_assignment, throw_reference_error_expr, try_desugar_reactive_text, - with_implicit_unset_let, with_set_fallback_for_ident, + lower_expr, lower_expr_assignment, strict_global_assign_existing_or_throw, + throw_reference_error_expr, try_desugar_reactive_text, with_implicit_unset_let, + with_set_fallback_for_ident, }; // Re-export extracted module functions diff --git a/crates/perry-runtime/src/error.rs b/crates/perry-runtime/src/error.rs index 4a2cf51550..96f04c25e0 100644 --- a/crates/perry-runtime/src/error.rs +++ b/crates/perry-runtime/src/error.rs @@ -1034,6 +1034,59 @@ pub extern "C" fn js_global_update(name_value: f64, is_increment: f64, is_prefix } } +/// Keepalive anchor for the auto-optimize whole-program build (generated-code +///-only callee; see project_auto_optimize_keepalive_3320). +#[used] +static KEEP_JS_GLOBAL_ASSIGN_EXISTING_OR_THROW: extern "C" fn(f64, f64) -> f64 = + js_global_assign_existing_or_throw; + +/// Strict-mode assignment to an identifier with no lexical binding +/// (#5989). Per spec (PutValue on an unresolvable-in-strict reference), +/// the name must first resolve against the global object: an EXISTING +/// global property is a normal property write — Next.js 16's +/// `cacheComponents` node-environment extensions do exactly this +/// (`Date = createDate(Date)` in strict CJS to install the dynamic-IO +/// clock interceptor, likewise `crypto`/`Math.random` wrappers) — and +/// only a genuinely absent binding throws the ReferenceError. The old +/// lowering threw unconditionally, so the extension install failed at +/// boot ("Failed to install `Date` class extension") and the dynamic +/// prerender-abort chain never armed. Presence probing + write-back +/// mirror `js_global_update` (the `++x`-on-global sibling). Sloppy mode +/// never reaches this helper — it lowers to a globalThis property set +/// that may CREATE the binding. +#[no_mangle] +pub extern "C" fn js_global_assign_existing_or_throw(name_value: f64, value: f64) -> f64 { + let g = crate::object::js_get_global_this(); + let gj = crate::value::JSValue::from_bits(g.to_bits()); + let key = crate::builtins::js_string_coerce(name_value); + let mut present = false; + if gj.is_pointer() && !key.is_null() { + let gptr = (gj.bits() & crate::value::POINTER_MASK) as *const crate::object::ObjectHeader; + if !gptr.is_null() { + let v = unsafe { crate::object::js_object_get_field_by_name(gptr, key) }; + if !v.is_undefined() + || unsafe { + crate::object::js_object_has_own(g, name_value).to_bits() + == crate::value::TAG_TRUE + } + { + present = true; + } + } + } + if !present { + 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); + return crate::exception::js_throw(crate::value::js_nanbox_pointer(err_ptr as i64)); + } + let gptr = (gj.bits() & crate::value::POINTER_MASK) as *mut crate::object::ObjectHeader; + crate::object::js_object_set_field_by_name(gptr, key, value); + // An assignment expression evaluates to its RHS. + value +} + /// Non-throwing variant of [`js_global_get_or_throw_unresolved`] for /// `typeof `: the spec's GetValue-skips-on-typeof rule means /// a missing global yields `undefined` rather than a ReferenceError, but a diff --git a/crates/perry-runtime/src/object/class_registry/class_meta.rs b/crates/perry-runtime/src/object/class_registry/class_meta.rs index 1c74479f6a..6c1e7aab69 100644 --- a/crates/perry-runtime/src/object/class_registry/class_meta.rs +++ b/crates/perry-runtime/src/object/class_registry/class_meta.rs @@ -215,6 +215,71 @@ pub(crate) fn identify_global_builtin_constructor(func_value: f64) -> Option<&'s if !is_global_builtin_func { return None; } + // #5989: dedicated per-builtin thunks map to their name DIRECTLY — + // without consulting globalThis. The name-record/singleton-walk + // fallbacks below break the moment user code REASSIGNS the global + // binding (Next.js 16's cacheComponents extensions install a `Date` + // wrapper via `Date = createDate(Date)`; the wrapper's captured + // ORIGINAL constructor then no longer matches any globalThis key, + // identification returned None, and `Reflect.construct(original, + // args, newTarget)` fell to the generic tail → unbranded "Invalid + // Date" instances). Only the SHARED thunks (noop, typed-array) + // still need the walk. + let direct: Option<&'static str> = + if func_ptr == global_this_date_thunk as *const u8 as usize { + Some("Date") + } else if func_ptr == global_this_array_thunk as *const u8 as usize { + Some("Array") + } else if func_ptr == global_this_object_thunk as *const u8 as usize { + Some("Object") + } else if func_ptr == global_this_string_thunk as *const u8 as usize { + Some("String") + } else if func_ptr == global_this_number_thunk as *const u8 as usize { + Some("Number") + } else if func_ptr == global_this_boolean_thunk as *const u8 as usize { + Some("Boolean") + } else if func_ptr == global_this_blob_thunk as *const u8 as usize { + Some("Blob") + } else if func_ptr == global_this_file_thunk as *const u8 as usize { + Some("File") + } else if func_ptr == global_this_headers_thunk as *const u8 as usize { + Some("Headers") + } else if func_ptr == global_this_request_thunk as *const u8 as usize { + Some("Request") + } else if func_ptr == global_this_response_thunk as *const u8 as usize { + Some("Response") + } else if func_ptr == error_constructor_call_thunk as *const u8 as usize { + Some("Error") + } else if func_ptr == type_error_constructor_call_thunk as *const u8 as usize { + Some("TypeError") + } else if func_ptr == range_error_constructor_call_thunk as *const u8 as usize { + Some("RangeError") + } else if func_ptr == reference_error_constructor_call_thunk as *const u8 as usize { + Some("ReferenceError") + } else if func_ptr == syntax_error_constructor_call_thunk as *const u8 as usize { + Some("SyntaxError") + } else if func_ptr == eval_error_constructor_call_thunk as *const u8 as usize { + Some("EvalError") + } else if func_ptr == uri_error_constructor_call_thunk as *const u8 as usize { + Some("URIError") + } else if func_ptr == map_constructor_call_thunk as *const u8 as usize { + Some("Map") + } else if func_ptr == set_constructor_call_thunk as *const u8 as usize { + Some("Set") + } else if func_ptr == weak_map_constructor_call_thunk as *const u8 as usize { + Some("WeakMap") + } else if func_ptr == weak_set_constructor_call_thunk as *const u8 as usize { + Some("WeakSet") + } else if func_ptr == weak_ref_constructor_call_thunk as *const u8 as usize { + Some("WeakRef") + } else if func_ptr == promise_constructor_call_thunk as *const u8 as usize { + Some("Promise") + } else { + None + }; + if direct.is_some() { + return direct; + } } // Prefer the per-closure built-in `.name` record. Full-suite Rust tests // temporarily seed GLOBAL_THIS_PTR with GC fixture pointers; relying only diff --git a/crates/perry-runtime/src/object/class_registry/construct.rs b/crates/perry-runtime/src/object/class_registry/construct.rs index 0afff54073..cb63856f95 100644 --- a/crates/perry-runtime/src/object/class_registry/construct.rs +++ b/crates/perry-runtime/src/object/class_registry/construct.rs @@ -1438,6 +1438,27 @@ pub unsafe extern "C" fn js_new_function_construct_with_new_target( // `Object.getPrototypeOf` and `.constructor` resolve through it (test262 // `ctors*/use-custom-proto-if-object` / `use-default-proto-if-…`). if let Some(ta_name) = identify_global_builtin_constructor(func_value) { + // `Reflect.construct(Date, args, newTarget)` (#5989) — Next.js 16's + // cacheComponents Date extension constructs through exactly this + // shape: its installed wrapper runs + // `Reflect.construct(OriginalDate, arguments, new.target)`. The + // generic tail below allocates a PLAIN object and invokes the Date + // thunk against it, yielding an unbranded date (`getTime()` broken / + // "Invalid Date"). Build the real branded Date, then honor + // `GetPrototypeFromConstructor(newTarget)` like the typed-array arm + // below so `instanceof newTarget` and subclass prototypes hold. + if ta_name == "Date" { + let proto_bits = new_target_custom_object_prototype(nt); + let result = js_new_function_construct(func_value, args_ptr, args_len); + if let Some(proto_bits) = proto_bits { + let jv = crate::value::JSValue::from_bits(result.to_bits()); + if jv.is_pointer() { + let addr = (jv.bits() & crate::value::POINTER_MASK) as usize; + super::super::prototype_chain::object_set_static_prototype(addr, proto_bits); + } + } + return result; + } if matches!( ta_name, "Int8Array" diff --git a/crates/perry-runtime/src/object/global_this.rs b/crates/perry-runtime/src/object/global_this.rs index 86a71741ac..bc197ded33 100644 --- a/crates/perry-runtime/src/object/global_this.rs +++ b/crates/perry-runtime/src/object/global_this.rs @@ -50,9 +50,9 @@ pub(crate) use bigint_promise::{ object_hasown_thunk, object_is_extensible_thunk, object_is_frozen_thunk, object_is_sealed_thunk, object_is_thunk, object_keys_thunk, object_prevent_extensions_thunk, object_seal_thunk, object_set_prototype_of_thunk, object_values_thunk, - promise_static_function_spec, reflect_apply_thunk, string_from_char_code_static, - string_from_code_point_static, string_raw_static, symbol_for_thunk, symbol_key_for_thunk, - typed_array_from_thunk, typed_array_of_thunk, + promise_static_function_spec, reflect_apply_thunk, reflect_construct_thunk, + string_from_char_code_static, string_from_code_point_static, string_raw_static, + symbol_for_thunk, symbol_key_for_thunk, typed_array_from_thunk, typed_array_of_thunk, }; pub use bigint_promise::{js_bigint_as_int_n_call, js_bigint_as_uint_n_call}; pub use builtin_thunks::js_function_ctor_from_strings; diff --git a/crates/perry-runtime/src/object/global_this/bigint_promise.rs b/crates/perry-runtime/src/object/global_this/bigint_promise.rs index dd12f8d31b..09740205bb 100644 --- a/crates/perry-runtime/src/object/global_this/bigint_promise.rs +++ b/crates/perry-runtime/src/object/global_this/bigint_promise.rs @@ -409,6 +409,24 @@ pub(crate) extern "C" fn reflect_apply_thunk( crate::proxy::js_reflect_apply(target, this_arg, args) } +/// #5989: the reified `Reflect.construct` VALUE was a no-op stub, so any +/// call through a captured binding — Next.js 16's cacheComponents Date +/// extension does `const construct = Reflect.construct; ... +/// construct(OriginalDate, arguments, new.target)` inside its installed +/// wrapper — silently returned `undefined` and the construction result +/// fell back to the unbranded implicit `this` ("Invalid Date" instances +/// everywhere once the wrapper installs). Route to the real +/// `js_reflect_construct`; a missing third argument arrives as +/// `undefined`, which it already resolves to `target` per spec. +pub(crate) extern "C" fn reflect_construct_thunk( + _closure: *const crate::closure::ClosureHeader, + target: f64, + args_like: f64, + new_target: f64, +) -> f64 { + crate::proxy::js_reflect_construct(target, args_like, new_target) +} + pub(crate) extern "C" fn symbol_for_thunk( _closure: *const crate::closure::ClosureHeader, key: f64, diff --git a/crates/perry-runtime/src/object/global_this/install_static.rs b/crates/perry-runtime/src/object/global_this/install_static.rs index a7c47c99fc..2ccbaea19e 100644 --- a/crates/perry-runtime/src/object/global_this/install_static.rs +++ b/crates/perry-runtime/src/object/global_this/install_static.rs @@ -813,7 +813,10 @@ pub(crate) fn install_reflect_namespace_members(ns_obj: *mut ObjectHeader) { ("defineProperty", noop, 3), ("deleteProperty", noop, 2), ("apply", reflect_apply_thunk as *const u8, 3), - ("construct", noop, 2), + // #5989: `construct` must be REAL as a value — Next.js's Date + // extension calls it through a captured binding (see + // `reflect_construct_thunk`). + ("construct", reflect_construct_thunk as *const u8, 2), ("get", noop, 2), ("getOwnPropertyDescriptor", noop, 2), ("getPrototypeOf", noop, 1), diff --git a/crates/perry-runtime/src/url/search_params.rs b/crates/perry-runtime/src/url/search_params.rs index 9fa83c24a4..5b496e0f27 100644 --- a/crates/perry-runtime/src/url/search_params.rs +++ b/crates/perry-runtime/src/url/search_params.rs @@ -837,7 +837,25 @@ pub(crate) fn shape_is_url_search_params(obj: *const ObjectHeader) -> bool { return false; } let keys_arr = (*obj).keys_array; - if keys_arr.is_null() || (*keys_arr).length == 0 { + if keys_arr.is_null() { + return false; + } + // #5989: `keys_array` itself must be validated before deref — a + // GC_TYPE_OBJECT receiver reached mid-transition (or with a typed + // layout) can carry a non-heap word here; reading `(*keys_arr).length` + // on it SIGSEGV'd during Next.js request handling (config.js method + // dispatch probing an arbitrary receiver through this shape check). + // Same try_read_gc_header gate as the receiver above. Require the + // EAGER `GC_TYPE_ARRAY` layout specifically: an object's own key list + // is always eager, and `(*keys_arr).length` / `js_array_get_f64` below + // read the eager `ArrayHeader` fields — a `GC_TYPE_LAZY_ARRAY` doesn't + // share that layout, so reject it (a real URLSearchParams shape never + // has a lazy keys_array; returning false is correct). + match crate::value::addr_class::try_read_gc_header(keys_arr as usize) { + Some(h) if h.obj_type == crate::gc::GC_TYPE_ARRAY => {} + _ => return false, + } + if (*keys_arr).length == 0 { return false; } let key0 = crate::array::js_array_get_f64(keys_arr, 0); diff --git a/crates/perry/tests/issue_5989_strict_global_builtin_assignment.rs b/crates/perry/tests/issue_5989_strict_global_builtin_assignment.rs new file mode 100644 index 0000000000..26d3feffd0 --- /dev/null +++ b/crates/perry/tests/issue_5989_strict_global_builtin_assignment.rs @@ -0,0 +1,139 @@ +//! Regression (#5989): strict-mode assignment to an identifier with no +//! lexical binding must resolve against the global object per spec +//! (PutValue): an EXISTING global property — `Date = wrapped` — is a +//! normal property write, and only a genuinely absent binding throws the +//! ReferenceError. +//! +//! Next.js 16's `cacheComponents` node-environment extensions install a +//! `Date` class extension exactly this way (strict CJS, +//! `Date = createDate(Date)`) so prerenders can detect clock reads as +//! dynamic IO. Perry's old lowering threw unconditionally for any +//! unresolved strict assignment, the install's `catch {}` swallowed it +//! ("Failed to install `Date` class extension" at every server boot), +//! and the dynamic-IO prerender-abort chain never armed — one layer of +//! the dynamic-RSC-route hang tracked in #5989. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn compile_and_run(src: &str) -> (bool, String) { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.ts"); + std::fs::write(&entry, src).expect("write entry"); + let output = dir.path().join("main_bin"); + + let compile = Command::new(perry_bin()) + .current_dir(dir.path()) + .args([ + "compile", + entry.to_str().unwrap(), + "-o", + output.to_str().unwrap(), + ]) + .env("PERRY_NO_AUTO_OPTIMIZE", "1") + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(&output).output().expect("run compiled binary"); + ( + run.status.success(), + String::from_utf8_lossy(&run.stdout).to_string(), + ) +} + +/// The exact Next.js date.js shape: clone Date's descriptors onto a +/// wrapper, reassign the global `Date` binding in strict code, and +/// observe the wrapper through bare `Date` reads afterwards. +#[test] +fn strict_assignment_to_existing_global_builtin_writes_through() { + let (ok, stdout) = compile_and_run( + r#" +"use strict"; +let ioCalls = 0; +function io() { + ioCalls++; +} +function createDate(originalConstructor: DateConstructor) { + const properties = Object.getOwnPropertyDescriptors(originalConstructor); + const originalNow = originalConstructor.now; + properties.now.value = function now() { + io(); + return originalNow(); + }; + const construct = Reflect.construct; + const apply = Reflect.apply; + const newConstructor = Object.defineProperties(function Date1() { + if (new.target === undefined) { + io(); + return apply(originalConstructor, undefined, arguments); + } + if (arguments.length === 0) { + io(); + } + return construct(originalConstructor, arguments, new.target); + }, properties); + Object.defineProperty(originalConstructor.prototype, "constructor", { + value: newConstructor, + }); + return newConstructor; +} +try { + // @ts-expect-error deliberate global builtin reassignment + Date = createDate(Date); + console.log("install ok"); +} catch (err) { + console.log("install FAILED:", (err as Error)?.message ?? String(err)); +} +const d = new Date(0); +console.log("iso:", d.toISOString()); +console.log("now is number:", typeof Date.now() === "number"); +console.log("instanceof:", d instanceof Date); +// Interception fires through the VALUE path (a captured `Date.now` +// binding routes through the installed wrapper). Syntactic `Date.now()` +// sites still compile to the builtin intrinsic and bypass the override — +// a known, separately-tracked gap. +const capturedNow = Date.now; +capturedNow(); +console.log("io calls:", ioCalls); +"#, + ); + assert!(ok, "run must exit cleanly"); + assert_eq!( + stdout, + "install ok\niso: 1970-01-01T00:00:00.000Z\nnow is number: true\ninstanceof: true\nio calls: 1\n", + "the wrapper must install and intercept a value-path Date.now" + ); +} + +/// A genuinely absent binding must still throw the spec ReferenceError +/// in strict mode (with the name in the message). +#[test] +fn strict_assignment_to_absent_global_still_throws_reference_error() { + let (ok, stdout) = compile_and_run( + r#" +"use strict"; +try { + // @ts-expect-error deliberate unresolved assignment + definitelyNotDeclaredAnywhere = 1; + console.log("no-throw"); +} catch (e) { + console.log("caught:", (e as Error).message); +} +"#, + ); + assert!(ok, "run must exit cleanly"); + assert_eq!( + stdout, + "caught: definitelyNotDeclaredAnywhere is not defined\n" + ); +}